@frankzhang2026/opencode-android-orchestrator 0.1.0 → 0.2.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 +75 -2
  2. package/LICENSE +21 -0
  3. package/README.md +391 -6
  4. package/THIRD_PARTY_NOTICES.md +119 -0
  5. package/dist/cli.js +243 -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 +472 -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 +56 -0
  28. package/dist/installer/adaptive-templates.d.ts.map +1 -0
  29. package/dist/installer/adaptive-templates.js +167 -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 +67 -0
  44. package/dist/installer/init.d.ts.map +1 -0
  45. package/dist/installer/init.js +389 -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 +31 -0
  52. package/dist/installer/opencode-config.d.ts.map +1 -0
  53. package/dist/installer/opencode-config.js +296 -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 +96 -0
  60. package/dist/installer/upgrade.d.ts.map +1 -0
  61. package/dist/installer/upgrade.js +1106 -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 +173 -0
  76. package/docs/SECURITY.md +212 -0
  77. package/docs/TROUBLESHOOTING.md +172 -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 +52 -9
  91. package/templates/automation/config.json +50 -0
  92. package/templates/automation/config.schema.json +163 -0
  93. package/templates/automation/task-contract.schema.json +71 -0
  94. package/templates/automation/tasks/TASK-TEMPLATE.json.example +53 -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 +697 -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 +57 -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 +699 -0
  121. package/templates/scripts/automation/transition-state.sh +21 -0
  122. package/templates/scripts/automation/validate-contract.sh +87 -0
  123. package/templates/scripts/automation/verify-integration.sh +37 -0
  124. package/templates/scripts/automation/verify-task.sh +54 -0
@@ -0,0 +1,697 @@
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_require_layout() {
73
+ [[ -f "$AUTOMATION_CONFIG" ]] || automation_die "missing $AUTOMATION_CONFIG"
74
+ [[ -d "$AUTOMATION_TASKS_DIR" ]] || automation_die "missing $AUTOMATION_TASKS_DIR"
75
+ }
76
+
77
+ automation_validate_config() {
78
+ automation_require_layout
79
+ jq -e '
80
+ def repository_path:
81
+ type == "string" and
82
+ length > 0 and
83
+ (startswith("/") | not) and
84
+ (test("^[A-Za-z]:[/\\\\]") | not) and
85
+ (test("(^|/)\\.\\.(/|$)") | not);
86
+ .schemaVersion == 3 and
87
+ (.enabled | type == "boolean") and
88
+ (.mode == "shadow" or .mode == "orchestrated") and
89
+ (.workspaceStrategy == "inPlaceExclusive" or .workspaceStrategy == "isolatedWorktree") and
90
+ .originalBranchDriftPolicy == "block" and
91
+ (.worktreeBase | type == "string") and
92
+ (.maxFixLoops | type == "number" and . >= 0 and . <= 1 and floor == .) and
93
+ (.maxReviewCycles | type == "number" and . >= 0 and . <= 2 and floor == .) and
94
+ (.maxReviewerRestarts | type == "number" and . >= 0 and . <= 3 and floor == .) and
95
+ (.autoCleanupWorktrees | type == "boolean") and
96
+ .pushAfterAcceptance == false and
97
+ (.approvalPhrases.proposal | type == "string" and length >= 4) and
98
+ (.approvalPhrases.contract | type == "string" and length >= 4) and
99
+ (.approvalPhrases.acceptance | type == "string" and length >= 4) and
100
+ (.approvalPhrases.abort | type == "string" and length >= 4) and
101
+ (.plugins | type == "object" and keys == ["superpowers"]) and
102
+ (.plugins.superpowers | type == "string" and length > 0) and
103
+ (.requiredSkills | type == "array" and length >= 6) and
104
+ (.androidProject as $project |
105
+ ($project | type == "object") and
106
+ ($project.name | type == "string" and length > 0) and
107
+ ($project.gradleDsl == "kotlin" or $project.gradleDsl == "groovy" or $project.gradleDsl == "mixed") and
108
+ ($project.settingsFile | repository_path) and
109
+ ($project.primaryModule | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
110
+ ($project.modules | type == "array" and length > 0 and all(.[];
111
+ (.gradlePath | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
112
+ (.directory | repository_path) and
113
+ (.buildFile | repository_path) and
114
+ (.dsl == "kotlin" or .dsl == "groovy") and
115
+ (.type == "application" or .type == "library" or .type == "dynamic-feature" or .type == "test" or .type == "asset-pack") and
116
+ (.namespace == null or (.namespace | type == "string" and length > 0)) and
117
+ (.applicationId == null or (.applicationId | type == "string" and length > 0)))) and
118
+ any($project.modules[]; .gradlePath == $project.primaryModule) and
119
+ ($project.productionPaths | type == "array" and length > 0 and all(.[];
120
+ repository_path and test("(^|/)src/main/\\*\\*$"))) and
121
+ ($project.testPaths | type == "array" and length > 0 and all(.[];
122
+ repository_path and test("(^|/)src/(?:test|androidTest)/\\*\\*$")))) and
123
+ (.protectedPaths | type == "array" and length > 0 and all(.[]; repository_path))
124
+ ' "$AUTOMATION_CONFIG" >/dev/null || automation_die "automation/config.json is invalid"
125
+ }
126
+
127
+ automation_require_orchestrated() {
128
+ automation_validate_config
129
+ [[ "$(automation_config_value '.enabled')" == "true" ]] || automation_die "automation is disabled"
130
+ [[ "$(automation_config_value '.mode')" == "orchestrated" ]] || automation_die "automation mode is not orchestrated"
131
+ }
132
+
133
+ automation_ensure_runtime_layout() {
134
+ mkdir -p \
135
+ "$AUTOMATION_STATE_DIR" \
136
+ "$AUTOMATION_EVIDENCE_DIR" \
137
+ "$AUTOMATION_LOCKS_DIR" \
138
+ "$AUTOMATION_WORKSPACES_DIR"
139
+ }
140
+
141
+ automation_validate_task_id() {
142
+ local task_id="${1:-}"
143
+ [[ "$task_id" =~ ^TASK-[A-Z0-9-]+$ ]] || automation_die "invalid task ID: ${task_id:-<empty>}"
144
+ }
145
+
146
+ automation_contract_path() {
147
+ local task_id="$1"
148
+ printf '%s/%s.json\n' "$AUTOMATION_TASKS_DIR" "$task_id"
149
+ }
150
+
151
+ automation_contract_relative_path() {
152
+ local task_id="$1"
153
+ printf 'automation/tasks/%s.json\n' "$task_id"
154
+ }
155
+
156
+ automation_state_path() {
157
+ local task_id="$1"
158
+ printf '%s/%s.json\n' "$AUTOMATION_STATE_DIR" "$task_id"
159
+ }
160
+
161
+ automation_evidence_path() {
162
+ local task_id="$1"
163
+ printf '%s/%s\n' "$AUTOMATION_EVIDENCE_DIR" "$task_id"
164
+ }
165
+
166
+ automation_workspace_path() {
167
+ local task_id="$1"
168
+ printf '%s/%s.json\n' "$AUTOMATION_WORKSPACES_DIR" "$task_id"
169
+ }
170
+
171
+ automation_workspace_strategy() {
172
+ local workspace_file="$1"
173
+ jq -er '.workspaceStrategy // "isolatedWorktree"' "$workspace_file"
174
+ }
175
+
176
+ automation_workspace_task_root() {
177
+ local workspace_file="$1"
178
+ jq -er '.taskRoot // .taskWorktree' "$workspace_file"
179
+ }
180
+
181
+ automation_origin_path() {
182
+ local task_id="$1"
183
+ printf '%s/origin.json\n' "$(automation_evidence_path "$task_id")"
184
+ }
185
+
186
+ automation_approvals_path() {
187
+ local task_id="$1"
188
+ printf '%s/approvals.jsonl\n' "$(automation_evidence_path "$task_id")"
189
+ }
190
+
191
+ automation_read_state() {
192
+ local task_id="$1"
193
+ local state_file
194
+ state_file="$(automation_state_path "$task_id")"
195
+ [[ -f "$state_file" ]] || automation_die "state does not exist for $task_id"
196
+ jq -er '.state' "$state_file"
197
+ }
198
+
199
+ automation_config_value() {
200
+ local query="$1"
201
+ jq -r "$query" "$AUTOMATION_CONFIG"
202
+ }
203
+
204
+ automation_require_approval() {
205
+ local kind="$1"
206
+ local supplied="$2"
207
+ local expected
208
+ expected="$(jq -er --arg kind "$kind" '.approvalPhrases[$kind]' "$AUTOMATION_CONFIG")" || {
209
+ automation_die "unknown approval kind: $kind"
210
+ return 1
211
+ }
212
+ [[ "$supplied" == "$expected" ]] || automation_die "approval option token does not match the configured $kind confirmation"
213
+ }
214
+
215
+ automation_file_sha256() {
216
+ local file="$1"
217
+ [[ -f "$file" ]] || automation_die "file does not exist: $file"
218
+ shasum -a 256 "$file" | awk '{print $1}'
219
+ }
220
+
221
+ automation_current_branch() {
222
+ local root="${1:-$AUTOMATION_ROOT}"
223
+ local branch
224
+ branch="$(git -C "$root" symbolic-ref --quiet --short HEAD 2>/dev/null)" || {
225
+ automation_die "detached HEAD is not supported: $root"
226
+ return 1
227
+ }
228
+ printf '%s\n' "$branch"
229
+ }
230
+
231
+ automation_path_matches() {
232
+ local path="$1"
233
+ local pattern="$2"
234
+ local prefix
235
+
236
+ if [[ "$pattern" == */ ]]; then
237
+ [[ "$path" == "$pattern"* ]]
238
+ return
239
+ fi
240
+
241
+ if [[ "$pattern" == *'/**' ]]; then
242
+ prefix="${pattern:0:${#pattern}-3}"
243
+ [[ "$path" == "$prefix" || "$path" == "$prefix/"* ]]
244
+ return
245
+ fi
246
+
247
+ case "$path" in
248
+ $pattern) return 0 ;;
249
+ *) return 1 ;;
250
+ esac
251
+ }
252
+
253
+ automation_array_matches_path() {
254
+ local json_file="$1"
255
+ local query="$2"
256
+ local path="$3"
257
+ local pattern
258
+
259
+ while IFS= read -r pattern; do
260
+ if automation_path_matches "$path" "$pattern"; then
261
+ return 0
262
+ fi
263
+ done < <(jq -r "$query[]" "$json_file")
264
+
265
+ return 1
266
+ }
267
+
268
+ automation_changed_paths_at() {
269
+ 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
274
+ }
275
+
276
+ automation_changed_paths() {
277
+ automation_changed_paths_at "$AUTOMATION_ROOT"
278
+ }
279
+
280
+ automation_assert_planning_artifacts_sealed() {
281
+ local task_id="$1"
282
+ local root="${2:-$AUTOMATION_ROOT}"
283
+ local origin_file contract_rel plan_rel contract_path plan_path
284
+
285
+ origin_file="$(automation_origin_path "$task_id")"
286
+ [[ -f "$origin_file" ]] || automation_die "origin evidence is missing for $task_id"
287
+ contract_rel="$(jq -er '.contractPath' "$origin_file")"
288
+ plan_rel="$(jq -er '.planPath' "$origin_file")"
289
+ contract_path="$root/$contract_rel"
290
+ plan_path="$root/$plan_rel"
291
+
292
+ [[ "$(automation_file_sha256 "$contract_path")" == "$(jq -er '.contractSha256' "$origin_file")" ]] || \
293
+ automation_die "approved contract changed after contract review"
294
+ [[ "$(automation_file_sha256 "$plan_path")" == "$(jq -er '.planSha256' "$origin_file")" ]] || \
295
+ automation_die "approved plan changed after contract review"
296
+ }
297
+
298
+ automation_is_planning_artifact() {
299
+ local task_id="$1"
300
+ local path="$2"
301
+ local origin_file contract_rel plan_rel
302
+
303
+ origin_file="$(automation_origin_path "$task_id")"
304
+ [[ -f "$origin_file" ]] || return 1
305
+ contract_rel="$(jq -er '.contractPath' "$origin_file")" || return 1
306
+ plan_rel="$(jq -er '.planPath' "$origin_file")" || return 1
307
+ [[ "$path" == "$contract_rel" || "$path" == "$plan_rel" ]]
308
+ }
309
+
310
+ automation_product_changed_paths_at() {
311
+ local task_id="$1"
312
+ local root="${2:-$AUTOMATION_ROOT}"
313
+ local path
314
+
315
+ while IFS= read -r path; do
316
+ [[ -n "$path" ]] || continue
317
+ if ! automation_is_planning_artifact "$task_id" "$path"; then
318
+ printf '%s\n' "$path"
319
+ fi
320
+ done < <(automation_changed_paths_at "$root")
321
+ }
322
+
323
+ automation_product_changed_paths_between() {
324
+ local task_id="$1"
325
+ local base="$2"
326
+ local head="$3"
327
+ local root="${4:-$AUTOMATION_ROOT}"
328
+ local path
329
+
330
+ while IFS= read -r path; do
331
+ [[ -n "$path" ]] || continue
332
+ if ! automation_is_planning_artifact "$task_id" "$path"; then
333
+ printf '%s\n' "$path"
334
+ fi
335
+ done < <(git -C "$root" diff --name-only "$base" "$head" -- | LC_ALL=C sort -u)
336
+ }
337
+
338
+ automation_changed_paths_between() {
339
+ local base="$1"
340
+ local head="$2"
341
+ git -C "$AUTOMATION_ROOT" diff --name-only "$base" "$head" -- | LC_ALL=C sort -u
342
+ }
343
+
344
+ automation_worktree_diff_sha() {
345
+ local root="${1:-$AUTOMATION_ROOT}"
346
+ local path
347
+ {
348
+ git -C "$root" diff --binary HEAD --
349
+ while IFS= read -r path; do
350
+ [[ -n "$path" ]] || continue
351
+ printf 'UNTRACKED %s\0' "$path"
352
+ git -C "$root" hash-object -- "$path"
353
+ done < <(git -C "$root" ls-files --others --exclude-standard | LC_ALL=C sort)
354
+ } | shasum -a 256 | awk '{print $1}'
355
+ }
356
+
357
+ automation_worktree_is_clean() {
358
+ local root="${1:-$AUTOMATION_ROOT}"
359
+ [[ -z "$(git -C "$root" status --porcelain --untracked-files=all)" ]]
360
+ }
361
+
362
+ automation_create_lock_dir() {
363
+ local lock_dir="$1"
364
+ local label="$2"
365
+ local owner_pid=""
366
+ if mkdir "$lock_dir" 2>/dev/null; then
367
+ return 0
368
+ fi
369
+
370
+ if [[ -f "$lock_dir/pid" ]]; then
371
+ owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
372
+ fi
373
+ if [[ "$owner_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$owner_pid" 2>/dev/null; then
374
+ automation_warn "removing stale $label lock owned by stopped PID $owner_pid"
375
+ rm -f "$lock_dir/pid" "$lock_dir/acquired-at"
376
+ rmdir "$lock_dir" 2>/dev/null || true
377
+ if mkdir "$lock_dir" 2>/dev/null; then
378
+ return 0
379
+ fi
380
+ fi
381
+
382
+ automation_die "$label lock is already held${owner_pid:+ by PID $owner_pid}"
383
+ }
384
+
385
+ automation_acquire_lock() {
386
+ local task_id="$1"
387
+ local lock_dir="$AUTOMATION_LOCKS_DIR/$task_id.state.lock"
388
+ automation_ensure_runtime_layout
389
+ automation_create_lock_dir "$lock_dir" "task state $task_id" || return 1
390
+ printf '%s\n' "$$" > "$lock_dir/pid"
391
+ printf '%s\n' "$(automation_now)" > "$lock_dir/acquired-at"
392
+ AUTOMATION_HELD_LOCK="$lock_dir"
393
+ }
394
+
395
+ automation_release_lock() {
396
+ if [[ -n "${AUTOMATION_HELD_LOCK:-}" && -d "$AUTOMATION_HELD_LOCK" ]]; then
397
+ rm -f "$AUTOMATION_HELD_LOCK/pid" "$AUTOMATION_HELD_LOCK/acquired-at"
398
+ rmdir "$AUTOMATION_HELD_LOCK" 2>/dev/null || true
399
+ fi
400
+ AUTOMATION_HELD_LOCK=""
401
+ }
402
+
403
+ automation_acquire_run_lock() {
404
+ local task_id="$1"
405
+ local lock_dir="$AUTOMATION_LOCKS_DIR/$task_id.run.lock"
406
+ automation_ensure_runtime_layout
407
+ automation_create_lock_dir "$lock_dir" "task orchestration $task_id" || return 1
408
+ printf '%s\n' "$$" > "$lock_dir/pid"
409
+ printf '%s\n' "$(automation_now)" > "$lock_dir/acquired-at"
410
+ AUTOMATION_HELD_RUN_LOCK="$lock_dir"
411
+ }
412
+
413
+ automation_release_run_lock() {
414
+ if [[ -n "${AUTOMATION_HELD_RUN_LOCK:-}" && -d "$AUTOMATION_HELD_RUN_LOCK" ]]; then
415
+ rm -f "$AUTOMATION_HELD_RUN_LOCK/pid" "$AUTOMATION_HELD_RUN_LOCK/acquired-at"
416
+ rmdir "$AUTOMATION_HELD_RUN_LOCK" 2>/dev/null || true
417
+ fi
418
+ AUTOMATION_HELD_RUN_LOCK=""
419
+ }
420
+
421
+ automation_repository_lease_dir() {
422
+ printf '%s/repository.workspace.lease\n' "$AUTOMATION_LOCKS_DIR"
423
+ }
424
+
425
+ automation_assert_repository_lease_available() {
426
+ local task_id="$1"
427
+ local lease_dir lease_file owner
428
+ lease_dir="$(automation_repository_lease_dir)"
429
+ lease_file="$lease_dir/lease.json"
430
+ [[ -d "$lease_dir" ]] || return 0
431
+ [[ -f "$lease_file" ]] || {
432
+ automation_die "repository workspace lease is malformed: $lease_dir"
433
+ return 1
434
+ }
435
+ owner="$(jq -er '.taskId' "$lease_file")" || return 1
436
+ [[ "$owner" == "$task_id" ]] || {
437
+ automation_die "repository workspace is leased by $owner"
438
+ return 1
439
+ }
440
+ }
441
+
442
+ automation_acquire_repository_lease() {
443
+ local task_id="$1"
444
+ local source_root="$2"
445
+ local strategy="$3"
446
+ local lease_dir lease_file tmp
447
+ automation_ensure_runtime_layout
448
+ lease_dir="$(automation_repository_lease_dir)"
449
+ lease_file="$lease_dir/lease.json"
450
+
451
+ if ! mkdir "$lease_dir" 2>/dev/null; then
452
+ automation_assert_repository_lease_available "$task_id" || return 1
453
+ jq -e \
454
+ --arg taskId "$task_id" \
455
+ --arg sourceRoot "$source_root" \
456
+ --arg strategy "$strategy" \
457
+ '.taskId == $taskId and .sourceRoot == $sourceRoot and .workspaceStrategy == $strategy' \
458
+ "$lease_file" >/dev/null || {
459
+ automation_die "repository workspace lease does not match the current task metadata"
460
+ return 1
461
+ }
462
+ return 0
463
+ fi
464
+
465
+ tmp="$(mktemp "$lease_dir/.lease.XXXXXX")"
466
+ jq -n \
467
+ --arg taskId "$task_id" \
468
+ --arg sourceRoot "$source_root" \
469
+ --arg strategy "$strategy" \
470
+ --arg acquiredAt "$(automation_now)" \
471
+ '{taskId: $taskId, sourceRoot: $sourceRoot,
472
+ workspaceStrategy: $strategy, acquiredAt: $acquiredAt}' > "$tmp"
473
+ mv "$tmp" "$lease_file"
474
+ }
475
+
476
+ automation_require_repository_lease() {
477
+ local task_id="$1"
478
+ local source_root="$2"
479
+ local strategy="$3"
480
+ local lease_file
481
+ lease_file="$(automation_repository_lease_dir)/lease.json"
482
+ [[ -f "$lease_file" ]] || {
483
+ automation_die "repository workspace lease is missing for $task_id"
484
+ return 1
485
+ }
486
+ jq -e \
487
+ --arg taskId "$task_id" \
488
+ --arg sourceRoot "$source_root" \
489
+ --arg strategy "$strategy" \
490
+ '.taskId == $taskId and .sourceRoot == $sourceRoot and .workspaceStrategy == $strategy' \
491
+ "$lease_file" >/dev/null || {
492
+ automation_die "repository workspace lease is owned by another task or workspace"
493
+ return 1
494
+ }
495
+ }
496
+
497
+ automation_release_repository_lease() {
498
+ local task_id="$1"
499
+ local lease_dir lease_file owner
500
+ lease_dir="$(automation_repository_lease_dir)"
501
+ lease_file="$lease_dir/lease.json"
502
+ [[ -d "$lease_dir" ]] || return 0
503
+ [[ -f "$lease_file" ]] || {
504
+ automation_die "repository workspace lease is malformed: $lease_dir"
505
+ return 1
506
+ }
507
+ owner="$(jq -er '.taskId' "$lease_file")" || return 1
508
+ [[ "$owner" == "$task_id" ]] || {
509
+ automation_die "cannot release repository workspace lease owned by $owner"
510
+ return 1
511
+ }
512
+ rm -f "$lease_file"
513
+ rmdir "$lease_dir"
514
+ }
515
+
516
+ automation_transition_allowed() {
517
+ local from="$1"
518
+ local to="$2"
519
+ local actor="$3"
520
+
521
+ case "$from:$to:$actor" in
522
+ CONTRACT_REVIEW:APPROVED_CONTRACT:human) return 0 ;;
523
+ APPROVED_CONTRACT:PREPARING:orchestrator) return 0 ;;
524
+ PREPARING:PENDING:orchestrator) return 0 ;;
525
+ PREPARING:BLOCKED:orchestrator) return 0 ;;
526
+ APPROVED_CONTRACT:PENDING:human) return 0 ;;
527
+ TEST_FAILED:PENDING:human) return 0 ;;
528
+ BLOCKED:PENDING:human) return 0 ;;
529
+ CHANGES_REQUESTED:PENDING:human) return 0 ;;
530
+ PENDING:CODING:coder-launcher) return 0 ;;
531
+ PENDING:BLOCKED:preflight) return 0 ;;
532
+ PENDING:BLOCKED:coder) return 0 ;;
533
+ PENDING:BLOCKED:orchestrator) return 0 ;;
534
+ CODING:BLOCKED:preflight) return 0 ;;
535
+ CODING:BLOCKED:coder) return 0 ;;
536
+ CODING:BLOCKED:orchestrator) return 0 ;;
537
+ CODING:READY_FOR_REVIEW:quality-gate) return 0 ;;
538
+ CODING:TEST_FAILED:quality-gate) return 0 ;;
539
+ CODING:BLOCKED:quality-gate) return 0 ;;
540
+ READY_FOR_REVIEW:REVIEWING:orchestrator) return 0 ;;
541
+ READY_FOR_REVIEW:BLOCKED:orchestrator) return 0 ;;
542
+ REVIEWING:AWAITING_HUMAN:reviewer) return 0 ;;
543
+ REVIEWING:CHANGES_REQUESTED:reviewer) return 0 ;;
544
+ REVIEWING:BLOCKED:orchestrator) return 0 ;;
545
+ BLOCKED:REVIEWING:human) return 0 ;;
546
+ CHANGES_REQUESTED:CODING:orchestrator) return 0 ;;
547
+ CHANGES_REQUESTED:NEEDS_HUMAN:orchestrator) return 0 ;;
548
+ AWAITING_HUMAN:INTEGRATING:integrator) return 0 ;;
549
+ INTEGRATING:COMPLETED:integrator) return 0 ;;
550
+ INTEGRATING:INTEGRATION_BLOCKED:integrator) return 0 ;;
551
+ 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 ;;
552
+ *) return 1 ;;
553
+ esac
554
+ }
555
+
556
+ automation_initialize_state() {
557
+ local task_id="$1"
558
+ local initial_state="$2"
559
+ local actor="$3"
560
+ local note="$4"
561
+ local state_file evidence_dir tmp now
562
+
563
+ automation_ensure_runtime_layout
564
+ state_file="$(automation_state_path "$task_id")"
565
+ [[ ! -e "$state_file" ]] || automation_die "state already exists for $task_id"
566
+ evidence_dir="$(automation_evidence_path "$task_id")"
567
+ mkdir -p "$evidence_dir"
568
+ now="$(automation_now)"
569
+ tmp="$(mktemp "$AUTOMATION_STATE_DIR/.${task_id}.XXXXXX")"
570
+ jq -n \
571
+ --arg taskId "$task_id" \
572
+ --arg state "$initial_state" \
573
+ --arg updatedAt "$now" \
574
+ --arg updatedBy "$actor" \
575
+ --arg note "$note" \
576
+ '{taskId: $taskId, state: $state, revision: 1, updatedAt: $updatedAt, updatedBy: $updatedBy, note: $note}' \
577
+ > "$tmp"
578
+ mv "$tmp" "$state_file"
579
+ jq -nc \
580
+ --arg taskId "$task_id" \
581
+ --arg from "" \
582
+ --arg to "$initial_state" \
583
+ --arg actor "$actor" \
584
+ --arg at "$now" \
585
+ --arg note "$note" \
586
+ '{taskId: $taskId, from: $from, to: $to, actor: $actor, at: $at, note: $note}' \
587
+ >> "$evidence_dir/transitions.jsonl"
588
+ }
589
+
590
+ automation_transition_state() {
591
+ local task_id="$1"
592
+ local expected="$2"
593
+ local next="$3"
594
+ local actor="$4"
595
+ local note="$5"
596
+ local state_file evidence_dir actual revision now tmp
597
+
598
+ automation_acquire_lock "$task_id" || return 1
599
+ state_file="$(automation_state_path "$task_id")"
600
+ evidence_dir="$(automation_evidence_path "$task_id")"
601
+ mkdir -p "$evidence_dir"
602
+
603
+ if [[ ! -f "$state_file" ]]; then
604
+ automation_release_lock
605
+ automation_die "state does not exist for $task_id"
606
+ return 1
607
+ fi
608
+
609
+ actual="$(jq -er '.state' "$state_file")" || {
610
+ automation_release_lock
611
+ automation_die "invalid state file for $task_id"
612
+ return 1
613
+ }
614
+ if [[ "$actual" != "$expected" ]]; then
615
+ automation_release_lock
616
+ automation_die "state mismatch for $task_id: expected $expected, found $actual"
617
+ return 1
618
+ fi
619
+ if ! automation_transition_allowed "$expected" "$next" "$actor"; then
620
+ automation_release_lock
621
+ automation_die "forbidden transition: $expected -> $next by $actor"
622
+ return 1
623
+ fi
624
+
625
+ revision="$(jq -er '.revision' "$state_file")"
626
+ revision=$((revision + 1))
627
+ now="$(automation_now)"
628
+ tmp="$(mktemp "$AUTOMATION_STATE_DIR/.${task_id}.XXXXXX")"
629
+ jq \
630
+ --arg state "$next" \
631
+ --argjson revision "$revision" \
632
+ --arg updatedAt "$now" \
633
+ --arg updatedBy "$actor" \
634
+ --arg note "$note" \
635
+ '.state = $state | .revision = $revision | .updatedAt = $updatedAt | .updatedBy = $updatedBy | .note = $note' \
636
+ "$state_file" > "$tmp"
637
+ mv "$tmp" "$state_file"
638
+ jq -nc \
639
+ --arg taskId "$task_id" \
640
+ --arg from "$expected" \
641
+ --arg to "$next" \
642
+ --arg actor "$actor" \
643
+ --arg at "$now" \
644
+ --arg note "$note" \
645
+ '{taskId: $taskId, from: $from, to: $to, actor: $actor, at: $at, note: $note}' \
646
+ >> "$evidence_dir/transitions.jsonl"
647
+ automation_release_lock
648
+ automation_info "$task_id: $expected -> $next"
649
+ }
650
+
651
+ automation_record_json() {
652
+ local destination="$1"
653
+ local tmp
654
+ mkdir -p "$(dirname "$destination")"
655
+ tmp="$(mktemp "$(dirname "$destination")/.record.XXXXXX")"
656
+ cat > "$tmp"
657
+ mv "$tmp" "$destination"
658
+ }
659
+
660
+ automation_append_json() {
661
+ local destination="$1"
662
+ local value
663
+ mkdir -p "$(dirname "$destination")"
664
+ value="$(cat)"
665
+ jq -ce . <<< "$value" >> "$destination"
666
+ }
667
+
668
+ automation_worktree_base() {
669
+ local configured repo_parent repo_name
670
+ configured="$(jq -r '.worktreeBase // ""' "$AUTOMATION_CONFIG")"
671
+ if [[ -n "${AUTOMATION_WORKTREE_BASE:-}" ]]; then
672
+ if [[ "${AUTOMATION_TEST_MODE:-0}" != "1" ]]; then
673
+ automation_die "AUTOMATION_WORKTREE_BASE is reserved for the test suite"
674
+ return 1
675
+ fi
676
+ configured="$AUTOMATION_WORKTREE_BASE"
677
+ fi
678
+ if [[ -z "$configured" ]]; then
679
+ repo_parent="$(dirname "$AUTOMATION_ROOT")"
680
+ repo_name="$(basename "$AUTOMATION_ROOT")"
681
+ configured="$repo_parent/$repo_name-worktrees"
682
+ elif [[ "$configured" != /* ]]; then
683
+ configured="$AUTOMATION_ROOT/$configured"
684
+ fi
685
+ case "$configured/" in
686
+ "$AUTOMATION_ROOT/"*)
687
+ automation_die "worktree base must be outside the source repository: $configured"
688
+ return 1
689
+ ;;
690
+ esac
691
+ printf '%s\n' "$configured"
692
+ }
693
+
694
+ automation_task_branch() {
695
+ local task_id="$1"
696
+ printf 'automation/%s\n' "$(tr '[:upper:]' '[:lower:]' <<< "$task_id")"
697
+ }