forge-workflow 0.0.5 → 0.0.7

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 (164) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. package/scripts/test.js +18 -1
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env bash
2
+ # pr-coordinator.sh — Parallel PR coordination: dependencies, merge order, simulation.
3
+ #
4
+ # Subcommands:
5
+ # dep add|remove|list|list-all PR dependency management
6
+ # merge-sim <branch> Merge simulation dry-run
7
+ # merge-order Dependency-aware merge sequence
8
+ # rebase-check Rebase guidance for open branches
9
+ # auto-label <issue-id> PR label auto-tagging
10
+ # stale-worktrees Abandoned worktree detection
11
+ # help Show usage
12
+ #
13
+ # Exit codes: 0=success, 1=error, 2=validation error
14
+
15
+ set -euo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ source "$SCRIPT_DIR/lib/sanitize.sh" || { echo "FATAL: failed to source sanitize.sh" >&2; exit 2; }
19
+
20
+ # Source these but don't fail if missing (tests may not have them)
21
+ [[ -f "$SCRIPT_DIR/file-index.sh" ]] && source "$SCRIPT_DIR/file-index.sh"
22
+ [[ -f "$SCRIPT_DIR/sync-utils.sh" ]] && source "$SCRIPT_DIR/sync-utils.sh"
23
+
24
+ # ── Stub implementations (replaced in Tasks 6-11) ────────────────────
25
+
26
+ cmd_dep() {
27
+ if [[ $# -lt 1 ]]; then
28
+ echo "Usage: pr-coordinator.sh dep <add|remove|list|list-all|set-pr> [args...]" >&2
29
+ return 1
30
+ fi
31
+
32
+ local action="$1"
33
+ shift
34
+
35
+ case "$action" in
36
+ add)
37
+ # dep add <issue-a> <issue-b> — issue-a depends on issue-b
38
+ if [[ $# -lt 2 ]]; then
39
+ echo "Usage: pr-coordinator.sh dep add <issue-a> <issue-b>" >&2
40
+ return 1
41
+ fi
42
+ local issue_a="$1" issue_b="$2"
43
+
44
+ # Validate issue IDs
45
+ [[ "$issue_a" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_a" >&2; return 2; }
46
+ [[ "$issue_b" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_b" >&2; return 2; }
47
+
48
+ # Add dependency
49
+ local add_output
50
+ add_output="$(${BD_CMD:-bd} dep add "$issue_a" "$issue_b" 2>&1)" || {
51
+ echo "Error: failed to add dependency: $add_output" >&2
52
+ return 1
53
+ }
54
+
55
+ # Check for cycles — use exit code as primary signal (exit 0 = no cycles)
56
+ if ! ${BD_CMD:-bd} dep cycles &>/dev/null; then
57
+ # Cycle detected — rollback
58
+ ${BD_CMD:-bd} dep remove "$issue_a" "$issue_b" 2>/dev/null || true
59
+ echo "Error: circular dependency detected — rolled back" >&2
60
+ return 1
61
+ fi
62
+
63
+ echo "Dependency added: $issue_a depends on $issue_b"
64
+ ;;
65
+
66
+ remove)
67
+ if [[ $# -lt 2 ]]; then
68
+ echo "Usage: pr-coordinator.sh dep remove <issue-a> <issue-b>" >&2
69
+ return 1
70
+ fi
71
+ local issue_a="$1" issue_b="$2"
72
+ [[ "$issue_a" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_a" >&2; return 2; }
73
+ [[ "$issue_b" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_b" >&2; return 2; }
74
+
75
+ ${BD_CMD:-bd} dep remove "$issue_a" "$issue_b" 2>&1 || {
76
+ echo "Error: failed to remove dependency" >&2
77
+ return 1
78
+ }
79
+ echo "Dependency removed: $issue_a no longer depends on $issue_b"
80
+ ;;
81
+
82
+ list)
83
+ if [[ $# -lt 1 ]]; then
84
+ echo "Usage: pr-coordinator.sh dep list <issue-id>" >&2
85
+ return 1
86
+ fi
87
+ local issue_id="$1"
88
+ [[ "$issue_id" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_id" >&2; return 2; }
89
+
90
+ local show_output
91
+ show_output="$(${BD_CMD:-bd} show "$issue_id" 2>&1)" || {
92
+ echo "Error: failed to show issue $issue_id" >&2
93
+ return 1
94
+ }
95
+
96
+ # Parse DEPENDS ON section
97
+ local deps
98
+ deps="$(printf '%s' "$show_output" | sed -n '/^DEPENDS ON/,/^$/p' | grep -E '^\s+' | grep -v '^DEPENDS ON' || true)"
99
+
100
+ if [[ -z "$deps" ]]; then
101
+ echo "No dependencies for $issue_id"
102
+ else
103
+ echo "Dependencies for $issue_id:"
104
+ printf '%s\n' "$deps"
105
+ fi
106
+ ;;
107
+
108
+ list-all)
109
+ local list_output
110
+ list_output="$(${BD_CMD:-bd} list --status=open,in_progress 2>&1)" || {
111
+ echo "Error: failed to list issues" >&2
112
+ return 1
113
+ }
114
+
115
+ if [[ -z "$list_output" ]]; then
116
+ echo "No open issues"
117
+ return 0
118
+ fi
119
+
120
+ # For each issue, show its dependencies
121
+ local issue_id
122
+ while IFS= read -r line; do
123
+ issue_id="$(printf '%s' "$line" | grep -oE '[A-Za-z]+-[A-Za-z0-9]+' | head -1)" || continue
124
+ [[ -z "$issue_id" ]] && continue
125
+
126
+ local show_out
127
+ show_out="$(${BD_CMD:-bd} show "$issue_id" 2>&1)" || continue
128
+ local deps
129
+ deps="$(printf '%s' "$show_out" | sed -n '/^DEPENDS ON/,/^$/p' | grep -E '^\s+' | grep -v '^DEPENDS ON' || true)"
130
+
131
+ if [[ -n "$deps" ]]; then
132
+ echo "$issue_id:"
133
+ printf '%s\n' "$deps"
134
+ echo ""
135
+ fi
136
+ done <<< "$list_output"
137
+ ;;
138
+
139
+ set-pr)
140
+ # Store PR number on issue: dep set-pr <issue-id> <pr-number>
141
+ if [[ $# -lt 2 ]]; then
142
+ echo "Usage: pr-coordinator.sh dep set-pr <issue-id> <pr-number>" >&2
143
+ return 1
144
+ fi
145
+ local issue_id="$1" pr_number="$2"
146
+ [[ "$issue_id" =~ ^[a-zA-Z0-9-]+$ ]] || { echo "Error: invalid issue-id: $issue_id" >&2; return 2; }
147
+ validate_pr_number "$pr_number" || return 2
148
+
149
+ ${BD_CMD:-bd} set-state "$issue_id" "pr_number=$pr_number" --reason "PR created by /ship" 2>&1 || {
150
+ echo "Error: failed to store PR number" >&2
151
+ return 1
152
+ }
153
+ echo "PR #$pr_number linked to $issue_id"
154
+ ;;
155
+
156
+ *)
157
+ echo "Error: unknown dep action '$action'" >&2
158
+ echo "Usage: pr-coordinator.sh dep <add|remove|list|list-all|set-pr>" >&2
159
+ return 1
160
+ ;;
161
+ esac
162
+ }
163
+ cmd_merge_sim() {
164
+ if [[ $# -lt 1 ]]; then
165
+ echo "Usage: pr-coordinator.sh merge-sim <branch> [--base=<base>]" >&2
166
+ return 1
167
+ fi
168
+
169
+ local branch="$1"
170
+ shift
171
+ local base
172
+ base="$(get_sync_branch 2>/dev/null || echo "master")"
173
+
174
+ # Parse optional --base flag
175
+ while [[ $# -gt 0 ]]; do
176
+ case "$1" in
177
+ --base=*) base="${1#--base=}"; shift ;;
178
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
179
+ esac
180
+ done
181
+
182
+ # Validate branch names (defense in depth — dispatcher also validates)
183
+ validate_branch_name "$branch" || return 2
184
+ validate_branch_name "$base" || return 2
185
+
186
+ # Verify branches exist
187
+ if ! git rev-parse --verify "$branch" &>/dev/null; then
188
+ echo "Error: branch '$branch' does not exist" >&2
189
+ return 1
190
+ fi
191
+ if ! git rev-parse --verify "$base" &>/dev/null; then
192
+ echo "Error: base branch '$base' does not exist" >&2
193
+ return 1
194
+ fi
195
+
196
+ # Save current state
197
+ local original_branch
198
+ original_branch="$(git rev-parse --abbrev-ref HEAD)"
199
+ local original_head
200
+ original_head="$(git rev-parse HEAD)"
201
+
202
+ # Set up trap for crash recovery — guard flag prevents double-cleanup when
203
+ # ERR fires followed by EXIT (both invoke the trap in bash)
204
+ _merge_sim_cleanup_done=0
205
+ _merge_sim_cleanup_once() {
206
+ [[ "$_merge_sim_cleanup_done" -eq 1 ]] && return
207
+ _merge_sim_cleanup_done=1
208
+ _merge_sim_cleanup "$original_branch" "$original_head"
209
+ }
210
+ trap '_merge_sim_cleanup_once' EXIT ERR INT TERM
211
+
212
+ # Checkout base branch (detached to avoid moving branch pointer)
213
+ git checkout --detach "$base" 2>/dev/null || {
214
+ echo "Error: failed to checkout base '$base'" >&2
215
+ trap - EXIT ERR INT TERM
216
+ return 1
217
+ }
218
+
219
+ # Attempt merge
220
+ local merge_output
221
+ if merge_output="$(git merge --no-commit --no-ff "$branch" 2>&1)"; then
222
+ # Clean merge — no conflicts
223
+ git merge --abort 2>/dev/null || true
224
+ git checkout "$original_branch" 2>/dev/null || git checkout "$original_head" 2>/dev/null
225
+ trap - EXIT ERR INT TERM
226
+ echo "No conflicts detected with $base"
227
+ return 0
228
+ else
229
+ # Conflicts detected
230
+ local conflicted_files
231
+ conflicted_files="$(git diff --name-only --diff-filter=U 2>/dev/null || true)"
232
+ git merge --abort 2>/dev/null || true
233
+ git checkout "$original_branch" 2>/dev/null || git checkout "$original_head" 2>/dev/null
234
+ trap - EXIT ERR INT TERM
235
+
236
+ if [[ -n "$conflicted_files" ]]; then
237
+ echo "Conflicts detected with $base:"
238
+ printf '%s\n' "$conflicted_files"
239
+ else
240
+ echo "Merge failed (non-conflict reason):"
241
+ printf '%s\n' "$merge_output"
242
+ fi
243
+ return 1
244
+ fi
245
+ }
246
+
247
+ _merge_sim_cleanup() {
248
+ local original_branch="$1"
249
+ local original_head="$2"
250
+ git merge --abort 2>/dev/null || true
251
+ git checkout "$original_branch" 2>/dev/null || git checkout "$original_head" 2>/dev/null || true
252
+ }
253
+ cmd_merge_order() {
254
+ local format="text"
255
+
256
+ # Parse flags
257
+ while [[ $# -gt 0 ]]; do
258
+ case "$1" in
259
+ --format=*) format="${1#--format=}"; shift ;;
260
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
261
+ esac
262
+ done
263
+
264
+ # Check for cycles first — use exit code as primary signal
265
+ if ! ${BD_CMD:-bd} dep cycles &>/dev/null; then
266
+ echo "Error: dependency cycle detected — cannot compute merge order" >&2
267
+ return 1
268
+ fi
269
+
270
+ # Get all open/in_progress issues
271
+ local issues_output
272
+ issues_output="$(${BD_CMD:-bd} list --status=open,in_progress 2>&1)" || {
273
+ echo "Error: failed to list issues" >&2
274
+ return 1
275
+ }
276
+
277
+ if [[ -z "$issues_output" ]]; then
278
+ echo "Nothing to merge — no open issues"
279
+ return 0
280
+ fi
281
+
282
+ # Extract issue IDs
283
+ local -a all_issues=()
284
+ while IFS= read -r line; do
285
+ local id
286
+ id="$(printf '%s' "$line" | grep -oE '[A-Za-z]+-[A-Za-z0-9]+' | head -1)" || continue
287
+ [[ -n "$id" ]] && all_issues+=("$id")
288
+ done <<< "$issues_output"
289
+
290
+ if [[ ${#all_issues[@]} -eq 0 ]]; then
291
+ echo "Nothing to merge — no open issues"
292
+ return 0
293
+ fi
294
+
295
+ # Build adjacency list and in-degree count
296
+ # dependency: A depends on B means B must merge before A
297
+ # So edge is B -> A (B blocks A)
298
+ local -A in_degree=()
299
+ local -A adj=() # adj[B] = "A1 A2 A3" (B blocks these)
300
+
301
+ for id in "${all_issues[@]}"; do
302
+ in_degree[$id]=0
303
+ adj[$id]=""
304
+ done
305
+
306
+ # For each issue, get its dependencies
307
+ for id in "${all_issues[@]}"; do
308
+ local show_out
309
+ show_out="$(${BD_CMD:-bd} show "$id" 2>&1)" || continue
310
+
311
+ # Parse DEPENDS ON section — extract issue IDs
312
+ local deps
313
+ deps="$(printf '%s' "$show_out" | sed -n '/^DEPENDS ON/,/^$/p' | grep -oE '[A-Za-z]+-[A-Za-z0-9]+' || true)"
314
+
315
+ while IFS= read -r dep_id; do
316
+ [[ -z "$dep_id" ]] && continue
317
+ # Only count deps that are in our open issues list
318
+ local found=0
319
+ for oid in "${all_issues[@]}"; do
320
+ if [[ "$oid" == "$dep_id" ]]; then
321
+ found=1
322
+ break
323
+ fi
324
+ done
325
+ [[ "$found" -eq 0 ]] && continue
326
+
327
+ # dep_id blocks id (dep_id must merge first)
328
+ in_degree[$id]=$(( ${in_degree[$id]} + 1 ))
329
+ adj[$dep_id]="${adj[$dep_id]} $id"
330
+ done <<< "$deps"
331
+ done
332
+
333
+ # Kahn's algorithm
334
+ local -a queue=()
335
+ local -a result=()
336
+
337
+ # Find all nodes with in-degree 0
338
+ for id in "${all_issues[@]}"; do
339
+ if [[ "${in_degree[$id]}" -eq 0 ]]; then
340
+ queue+=("$id")
341
+ fi
342
+ done
343
+
344
+ while [[ ${#queue[@]} -gt 0 ]]; do
345
+ # Pop first element
346
+ local current="${queue[0]}"
347
+ queue=("${queue[@]:1}")
348
+ result+=("$current")
349
+
350
+ # Reduce in-degree of neighbors
351
+ for neighbor in ${adj[$current]}; do
352
+ in_degree[$neighbor]=$(( ${in_degree[$neighbor]} - 1 ))
353
+ if [[ "${in_degree[$neighbor]}" -eq 0 ]]; then
354
+ queue+=("$neighbor")
355
+ fi
356
+ done
357
+ done
358
+
359
+ # Output
360
+ if [[ "$format" == "json" ]]; then
361
+ printf '%s\n' "${result[@]}" | jq -R . | jq -s -c '.'
362
+ else
363
+ if [[ ${#result[@]} -eq 0 ]]; then
364
+ echo "Nothing to merge"
365
+ elif [[ ${#result[@]} -eq 1 ]]; then
366
+ echo "Ready to merge: ${result[0]}"
367
+ else
368
+ # Check if all are independent (all in-degree 0 initially)
369
+ local all_independent=1
370
+ for id in "${all_issues[@]}"; do
371
+ local show_out
372
+ show_out="$(${BD_CMD:-bd} show "$id" 2>&1)" || continue
373
+ local deps
374
+ deps="$(printf '%s' "$show_out" | sed -n '/^DEPENDS ON/,/^$/p' | grep -oE '[A-Za-z]+-[A-Za-z0-9]+' || true)"
375
+ # Check if any dep is in our open list
376
+ while IFS= read -r dep_id; do
377
+ [[ -z "$dep_id" ]] && continue
378
+ for oid in "${all_issues[@]}"; do
379
+ if [[ "$oid" == "$dep_id" ]]; then
380
+ all_independent=0
381
+ break 2
382
+ fi
383
+ done
384
+ done <<< "$deps"
385
+ [[ "$all_independent" -eq 0 ]] && break
386
+ done
387
+
388
+ if [[ "$all_independent" -eq 1 ]]; then
389
+ echo "Can merge in any order:"
390
+ local i=1
391
+ for id in "${result[@]}"; do
392
+ echo " $i. $id"
393
+ i=$((i + 1))
394
+ done
395
+ else
396
+ echo "Recommended merge order:"
397
+ local i=1
398
+ for id in "${result[@]}"; do
399
+ echo " $i. $id"
400
+ i=$((i + 1))
401
+ done
402
+ fi
403
+ fi
404
+ fi
405
+ }
406
+ cmd_rebase_check() {
407
+ local after_merge=""
408
+ local base
409
+ base="$(get_sync_branch 2>/dev/null || echo "master")"
410
+
411
+ while [[ $# -gt 0 ]]; do
412
+ case "$1" in
413
+ --after-merge=*) after_merge="${1#--after-merge=}"; shift ;;
414
+ --base=*) base="${1#--base=}"; shift ;;
415
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
416
+ esac
417
+ done
418
+
419
+ # Get list of feature branches (not merged into base)
420
+ local branches
421
+ branches="$(git branch --no-merged "$base" --format='%(refname:short)' 2>/dev/null | grep -v '^HEAD' || true)"
422
+
423
+ if [[ -z "$branches" ]]; then
424
+ echo "No branches need rebasing"
425
+ return 0
426
+ fi
427
+
428
+ local found_any=0
429
+
430
+ while IFS= read -r branch; do
431
+ [[ -z "$branch" ]] && continue
432
+ [[ "$branch" == "$base" ]] && continue
433
+
434
+ # Files changed on the branch (relative to base)
435
+ local branch_files
436
+ branch_files="$(git log --name-only --pretty=format: "$base".."$branch" 2>/dev/null | sort -u | grep -v '^$' || true)"
437
+
438
+ # Files changed on base since branch diverged
439
+ local base_files
440
+ if [[ -n "$after_merge" ]]; then
441
+ # Only check overlap with specific merged branch's changes
442
+ base_files="$(git log --name-only --pretty=format: "$branch".."$after_merge" 2>/dev/null | sort -u | grep -v '^$' || true)"
443
+ else
444
+ base_files="$(git log --name-only --pretty=format: "$branch".."$base" 2>/dev/null | sort -u | grep -v '^$' || true)"
445
+ fi
446
+
447
+ if [[ -z "$base_files" ]]; then
448
+ continue # Branch is up-to-date with base
449
+ fi
450
+
451
+ # Check for file overlap
452
+ local overlap
453
+ overlap="$(comm -12 <(printf '%s\n' "$branch_files" | sort) <(printf '%s\n' "$base_files" | sort) 2>/dev/null || true)"
454
+
455
+ if [[ -n "$overlap" ]]; then
456
+ found_any=1
457
+ echo "CONFLICT REBASE: $branch"
458
+ echo " Overlapping files:"
459
+ printf '%s\n' "$overlap" | while IFS= read -r f; do
460
+ [[ -z "$f" ]] && continue
461
+ echo " - $f"
462
+ done
463
+ else
464
+ found_any=1
465
+ echo "CLEAN REBASE: $branch (behind but no file overlap)"
466
+ fi
467
+ done <<< "$branches"
468
+
469
+ if [[ "$found_any" -eq 0 ]]; then
470
+ echo "No branches need rebasing"
471
+ fi
472
+
473
+ return 0
474
+ }
475
+ cmd_auto_label() {
476
+ if [[ $# -lt 1 ]]; then
477
+ echo "Usage: pr-coordinator.sh auto-label <issue-id>" >&2
478
+ return 1
479
+ fi
480
+
481
+ local issue_id="$1"
482
+
483
+ # Get issue details
484
+ local show_output
485
+ show_output="$(${BD_CMD:-bd} show "$issue_id" 2>&1)" || {
486
+ echo "Error: failed to show issue $issue_id" >&2
487
+ return 1
488
+ }
489
+
490
+ # Get PR number from bd state metadata (pr_number:NNN in show output)
491
+ local pr_number=""
492
+ pr_number="$(printf '%s' "$show_output" | grep -oE 'pr_number:[0-9]+' | head -1 | cut -d: -f2 || true)"
493
+
494
+ if [[ -z "$pr_number" ]]; then
495
+ echo "No PR found for $issue_id, skipping labels"
496
+ return 0
497
+ fi
498
+
499
+ # Validate PR number
500
+ validate_pr_number "$pr_number" || return 2
501
+
502
+ # Determine label state
503
+ local has_deps=0
504
+ local blocks_others=0
505
+ local needs_rebase=0
506
+
507
+ # Check DEPENDS ON section
508
+ if printf '%s' "$show_output" | grep -q '^DEPENDS ON'; then
509
+ local dep_lines
510
+ dep_lines="$(printf '%s' "$show_output" | sed -n '/^DEPENDS ON/,/^$/p' | grep -E '^\s+' || true)"
511
+ [[ -n "$dep_lines" ]] && has_deps=1
512
+ fi
513
+
514
+ # Check BLOCKS section
515
+ if printf '%s' "$show_output" | grep -q '^BLOCKS'; then
516
+ local block_lines
517
+ block_lines="$(printf '%s' "$show_output" | sed -n '/^BLOCKS/,/^$/p' | grep -E '^\s+' || true)"
518
+ [[ -n "$block_lines" ]] && blocks_others=1
519
+ fi
520
+
521
+ # Check if branch needs rebase (behind base)
522
+ local pr_branch=""
523
+ pr_branch="$(printf '%s' "$show_output" | grep -oE 'pr_branch:[a-zA-Z0-9./_@-]+' | head -1 | cut -d: -f2 || true)"
524
+ if [[ -n "$pr_branch" ]]; then
525
+ local base
526
+ base="$(get_sync_branch 2>/dev/null || echo "master")"
527
+ local behind_count
528
+ behind_count="$(git rev-list --count "$pr_branch".."$base" 2>/dev/null || echo 0)"
529
+ [[ "$behind_count" -gt 0 ]] && needs_rebase=1
530
+ fi
531
+
532
+ # Apply or remove labels
533
+ local GH_CMD="${GH_CMD:-gh}"
534
+ local labels_added=()
535
+ local labels_removed=()
536
+
537
+ if [[ "$has_deps" -eq 1 ]]; then
538
+ $GH_CMD pr edit "$pr_number" --add-label "forge/has-deps" 2>/dev/null && labels_added+=("forge/has-deps") || true
539
+ else
540
+ $GH_CMD pr edit "$pr_number" --remove-label "forge/has-deps" 2>/dev/null && labels_removed+=("forge/has-deps") || true
541
+ fi
542
+
543
+ if [[ "$blocks_others" -eq 1 ]]; then
544
+ $GH_CMD pr edit "$pr_number" --add-label "forge/blocks-others" 2>/dev/null && labels_added+=("forge/blocks-others") || true
545
+ else
546
+ $GH_CMD pr edit "$pr_number" --remove-label "forge/blocks-others" 2>/dev/null && labels_removed+=("forge/blocks-others") || true
547
+ fi
548
+
549
+ if [[ "$needs_rebase" -eq 1 ]]; then
550
+ $GH_CMD pr edit "$pr_number" --add-label "forge/needs-rebase" 2>/dev/null && labels_added+=("forge/needs-rebase") || true
551
+ else
552
+ $GH_CMD pr edit "$pr_number" --remove-label "forge/needs-rebase" 2>/dev/null && labels_removed+=("forge/needs-rebase") || true
553
+ fi
554
+
555
+ # Report
556
+ if [[ ${#labels_added[@]} -gt 0 ]]; then
557
+ echo "Labels added: ${labels_added[*]}"
558
+ fi
559
+ if [[ ${#labels_removed[@]} -gt 0 ]]; then
560
+ echo "Labels removed: ${labels_removed[*]}"
561
+ fi
562
+ if [[ ${#labels_added[@]} -eq 0 ]] && [[ ${#labels_removed[@]} -eq 0 ]]; then
563
+ echo "No label changes needed for PR #$pr_number"
564
+ fi
565
+ }
566
+ collect_worktree_entries() {
567
+ local git_cmd porcelain current_path current_branch entries line separator
568
+ entries=""
569
+ current_path=""
570
+ current_branch=""
571
+ separator=$'\t'
572
+ git_cmd="${GIT_CMD:-git}"
573
+ porcelain="$("$git_cmd" worktree list --porcelain 2>/dev/null || true)"
574
+
575
+ while IFS= read -r line; do
576
+ case "$line" in
577
+ worktree\ *)
578
+ current_path="${line#worktree }"
579
+ ;;
580
+ branch\ refs/heads/*)
581
+ current_branch="${line#branch refs/heads/}"
582
+ ;;
583
+ "")
584
+ if [[ -n "$current_path" ]]; then
585
+ if [[ -n "$entries" ]]; then
586
+ entries+=$'\n'
587
+ fi
588
+ entries+="${current_path}${separator}${current_branch}"
589
+ fi
590
+ current_path=""
591
+ current_branch=""
592
+ ;;
593
+ esac
594
+ done <<EOF
595
+ $porcelain
596
+
597
+ EOF
598
+
599
+ if [[ -n "$current_path" ]]; then
600
+ if [[ -n "$entries" ]]; then
601
+ entries+=$'\n'
602
+ fi
603
+ entries+="${current_path}${separator}${current_branch}"
604
+ fi
605
+
606
+ printf '%s' "$entries"
607
+ }
608
+
609
+ cmd_stale_worktrees() {
610
+ local threshold_hours=48
611
+ local worktree_filter=""
612
+ local git_cmd
613
+ git_cmd="${GIT_CMD:-git}"
614
+
615
+ while [[ $# -gt 0 ]]; do
616
+ case "$1" in
617
+ --threshold=*)
618
+ local val="${1#--threshold=}"
619
+ # Parse hours: "48h" -> 48, "72h" -> 72, just a number -> hours
620
+ threshold_hours="${val%h}"
621
+ if [[ ! "$threshold_hours" =~ ^[0-9]+$ ]]; then
622
+ echo "Error: invalid threshold '$val' (use e.g. 48h or 48)" >&2
623
+ return 1
624
+ fi
625
+ shift ;;
626
+ --dir=*) worktree_filter="${1#--dir=}"; shift ;;
627
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
628
+ esac
629
+ done
630
+
631
+ local repo_root
632
+ repo_root="$("$git_cmd" rev-parse --show-toplevel 2>/dev/null)" || repo_root="."
633
+
634
+ local worktree_entries
635
+ worktree_entries="$(collect_worktree_entries)"
636
+
637
+ if [[ -z "$worktree_entries" ]]; then
638
+ echo "No worktrees found"
639
+ return 0
640
+ fi
641
+
642
+ # OWASP A01: validate each worktree path with realpath
643
+ local found_any=0
644
+ local stale_count=0
645
+
646
+ while IFS=$'\t' read -r entry_path entry_branch; do
647
+ [[ -n "$entry_path" ]] || continue
648
+
649
+ if [[ "$entry_path" == "$repo_root" ]]; then
650
+ continue
651
+ fi
652
+
653
+ if [[ -n "$worktree_filter" ]] && [[ "$entry_path" != *"$worktree_filter"* ]]; then
654
+ continue
655
+ fi
656
+
657
+ found_any=1
658
+
659
+ # Get last commit date
660
+ local last_commit_date
661
+ last_commit_date="$("$git_cmd" -C "$entry_path" log -1 --format=%ci 2>/dev/null)" || continue
662
+
663
+ # Get branch name
664
+ local branch_name="${entry_branch:-}"
665
+ if [[ -z "$branch_name" ]]; then
666
+ branch_name="$("$git_cmd" -C "$entry_path" branch --show-current 2>/dev/null)" || branch_name="unknown"
667
+ fi
668
+ branch_name="${branch_name:-unknown}"
669
+
670
+ # Calculate age in hours
671
+ local last_epoch now_epoch
672
+ last_epoch="$(date -d "$last_commit_date" +%s 2>/dev/null || date -jf "%Y-%m-%d %H:%M:%S %z" "$last_commit_date" +%s 2>/dev/null || echo 0)"
673
+ now_epoch="$(date +%s)"
674
+ local age_hours=$(( (now_epoch - last_epoch) / 3600 ))
675
+
676
+ if [[ "$age_hours" -ge "$threshold_hours" ]]; then
677
+ stale_count=$((stale_count + 1))
678
+ local dir_name
679
+ dir_name="$(basename "$entry_path")"
680
+ echo "STALE: $dir_name (branch: $branch_name, last commit: ${age_hours}h ago)"
681
+ fi
682
+ done <<EOF
683
+ $worktree_entries
684
+ EOF
685
+
686
+ if [[ "$found_any" -eq 0 ]]; then
687
+ echo "No worktrees found"
688
+ elif [[ "$stale_count" -eq 0 ]]; then
689
+ echo "All worktrees are active"
690
+ else
691
+ echo ""
692
+ echo "$stale_count potentially abandoned worktree(s) found"
693
+ fi
694
+
695
+ return 0 # Always exit 0 (informational only)
696
+ }
697
+
698
+ cmd_help() {
699
+ cat <<'EOF'
700
+ Usage: pr-coordinator.sh <subcommand> [args...]
701
+
702
+ Subcommands:
703
+ dep add <issue-a> <issue-b> Record issue-a depends on issue-b
704
+ dep remove <issue-a> <issue-b> Remove dependency
705
+ dep list <issue-id> Show dependencies for issue
706
+ dep list-all Show all open issues with dependencies
707
+ merge-sim <branch> Dry-run merge simulation
708
+ merge-order [--format=text|json] Recommended merge sequence
709
+ rebase-check [--after-merge=<branch>] Rebase guidance
710
+ auto-label <issue-id> Auto-tag PR labels
711
+ stale-worktrees [--threshold=48h] Detect abandoned worktrees
712
+ help Show this message
713
+ EOF
714
+ }
715
+
716
+ # ── Main dispatcher ──────────────────────────────────────────────────
717
+
718
+ main() {
719
+ if [[ $# -lt 1 ]]; then
720
+ cmd_help >&2
721
+ exit 1
722
+ fi
723
+
724
+ local subcommand="$1"
725
+ shift
726
+
727
+ case "$subcommand" in
728
+ dep) cmd_dep "$@" ;;
729
+ merge-sim)
730
+ # Validate branch name if provided
731
+ if [[ $# -ge 1 ]]; then
732
+ validate_branch_name "$1" || exit 2
733
+ fi
734
+ cmd_merge_sim "$@"
735
+ ;;
736
+ merge-order) cmd_merge_order "$@" ;;
737
+ rebase-check) cmd_rebase_check "$@" ;;
738
+ auto-label)
739
+ # Validate issue-id if provided
740
+ if [[ $# -ge 1 ]] && [[ ! "$1" =~ ^[a-zA-Z0-9-]+$ ]]; then
741
+ echo "Error: invalid issue-id format" >&2
742
+ exit 2
743
+ fi
744
+ cmd_auto_label "$@"
745
+ ;;
746
+ stale-worktrees) cmd_stale_worktrees "$@" ;;
747
+ help) cmd_help ;;
748
+ *)
749
+ echo "Error: unknown subcommand '$subcommand'" >&2
750
+ cmd_help >&2
751
+ exit 1
752
+ ;;
753
+ esac
754
+ }
755
+
756
+ main "$@"