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
@@ -19,6 +19,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
19
19
  set -euo pipefail
20
20
  fi
21
21
 
22
+ # Source shared libraries
23
+ _FI_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ source "$_FI_SCRIPT_DIR/lib/sanitize.sh"
25
+ if [[ -f "$_FI_SCRIPT_DIR/lib/jsonl-lock.sh" ]]; then
26
+ source "$_FI_SCRIPT_DIR/lib/jsonl-lock.sh"
27
+ fi
28
+
22
29
  # ── Helpers ──────────────────────────────────────────────────────────────
23
30
 
24
31
  # Resolve the JSONL file path.
@@ -28,24 +35,6 @@ _file_index_path() {
28
35
  printf '%s' "$root/.beads/file-index.jsonl"
29
36
  }
30
37
 
31
- # Sanitize a string: strip shell-injection patterns (OWASP A03)
32
- # Reused from dep-guard.sh pattern.
33
- # Removes: double quotes, $(...), backticks, semicolons, and newlines
34
- sanitize() {
35
- local val="$1"
36
- # Remove double quotes
37
- val="${val//\"/}"
38
- # Remove $(...) command substitution patterns (loop handles nested)
39
- val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
40
- # Remove backtick command substitution
41
- val="${val//\`/}"
42
- # Remove semicolons (command chaining)
43
- val="${val//;/}"
44
- # Replace newlines with spaces
45
- val="$(printf '%s' "$val" | tr '\n' ' ')"
46
- printf '%s' "$val"
47
- }
48
-
49
38
  # Validate issue_id: alphanumeric + hyphens only
50
39
  _validate_issue_id() {
51
40
  local id="$1"
@@ -127,7 +116,11 @@ file_index_add() {
127
116
  # Ensure parent directory exists
128
117
  mkdir -p "$(dirname "$jsonl_path")"
129
118
 
130
- printf '%s\n' "$json_line" >> "$jsonl_path"
119
+ if command -v atomic_jsonl_append &>/dev/null; then
120
+ atomic_jsonl_append "$jsonl_path" "$json_line"
121
+ else
122
+ printf '%s\n' "$json_line" >> "$jsonl_path"
123
+ fi
131
124
  }
132
125
 
133
126
  # file_index_remove <issue_id>
@@ -163,7 +156,11 @@ file_index_remove() {
163
156
  local jsonl_path
164
157
  jsonl_path="$(_file_index_path)"
165
158
  mkdir -p "$(dirname "$jsonl_path")"
166
- printf '%s\n' "$json_line" >> "$jsonl_path"
159
+ if command -v atomic_jsonl_append &>/dev/null; then
160
+ atomic_jsonl_append "$jsonl_path" "$json_line"
161
+ else
162
+ printf '%s\n' "$json_line" >> "$jsonl_path"
163
+ fi
167
164
  }
168
165
 
169
166
  # file_index_read
@@ -320,12 +317,28 @@ file_index_update_from_tasks() {
320
317
  # Build deduplicated JSON arrays using jq (no associative arrays needed)
321
318
  local files_json modules_json
322
319
  if [[ -n "$sanitized_paths" ]]; then
323
- files_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | jq -R . | jq -s -c 'unique')"
320
+ # Save and restore pipefail state to avoid side effects
321
+ local _prev_pipefail
322
+ _prev_pipefail="$(set +o | grep pipefail)" || true
323
+ set -o pipefail
324
+
325
+ files_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | jq -R . | jq -s -c 'unique')" || {
326
+ eval "$_prev_pipefail"
327
+ echo "Error: failed to build files JSON array" >&2
328
+ return 1
329
+ }
324
330
  modules_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | while IFS= read -r p; do
325
331
  local d
326
332
  d="$(dirname "$p")"
327
333
  if [[ "$d" == "." ]]; then printf '%s\n' "./"; else printf '%s\n' "${d}/"; fi
328
- done | jq -R . | jq -s -c 'unique')"
334
+ done | jq -R . | jq -s -c 'unique')" || {
335
+ eval "$_prev_pipefail"
336
+ echo "Error: failed to build modules JSON array" >&2
337
+ return 1
338
+ }
339
+
340
+ # Restore pipefail state
341
+ eval "$_prev_pipefail"
329
342
  else
330
343
  files_json="[]"
331
344
  modules_json="[]"
@@ -358,13 +371,92 @@ file_index_update_from_tasks() {
358
371
  tombstone: false,
359
372
  confidence: $conf
360
373
  }')"
361
- printf '%s\n' "$json_line" >> "$jsonl_path"
374
+ if command -v atomic_jsonl_append &>/dev/null; then
375
+ atomic_jsonl_append "$jsonl_path" "$json_line"
376
+ else
377
+ printf '%s\n' "$json_line" >> "$jsonl_path"
378
+ fi
362
379
  else
363
380
  # Normal entry via file_index_add (no confidence field needed)
364
381
  file_index_add "$issue_id" "$developer" "$files_json" "$modules_json"
365
382
  fi
366
383
  }
367
384
 
385
+ # file_index_auto_update <issue_id> [--from-git]
386
+ # Update the file index with the current set of changed files.
387
+ # Files come from: --from-git flag (git diff HEAD~1) or stdin.
388
+ file_index_auto_update() {
389
+ local raw_issue_id="$1"
390
+ shift
391
+ local from_git=0
392
+
393
+ # Parse flags
394
+ while [[ $# -gt 0 ]]; do
395
+ case "$1" in
396
+ --from-git) from_git=1; shift ;;
397
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
398
+ esac
399
+ done
400
+
401
+ # Sanitize and validate issue_id
402
+ local issue_id
403
+ issue_id="$(sanitize "$raw_issue_id")"
404
+ issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
405
+ _validate_issue_id "$issue_id" || return 1
406
+
407
+ # Get file list
408
+ local -a files=()
409
+ if [[ "$from_git" -eq 1 ]]; then
410
+ while IFS= read -r f; do
411
+ [[ -n "$f" ]] && files+=("$f")
412
+ done < <(git diff --name-only HEAD~1 2>/dev/null || true)
413
+ else
414
+ # Read from stdin (non-blocking check)
415
+ if [[ ! -t 0 ]]; then
416
+ while IFS= read -r f; do
417
+ [[ -n "$f" ]] && files+=("$f")
418
+ done
419
+ else
420
+ echo "Error: no file list provided (use --from-git or pipe files via stdin)" >&2
421
+ return 1
422
+ fi
423
+ fi
424
+
425
+ # Empty file list = nothing to do
426
+ if [[ ${#files[@]} -eq 0 ]]; then
427
+ echo "No files changed"
428
+ return 0
429
+ fi
430
+
431
+ # Get session identity
432
+ local developer
433
+ if ! command -v get_session_identity &>/dev/null; then
434
+ local script_dir
435
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
436
+ if [[ -f "$script_dir/sync-utils.sh" ]]; then
437
+ source "$script_dir/sync-utils.sh"
438
+ fi
439
+ fi
440
+ developer="$(get_session_identity)" || {
441
+ echo "Error: could not determine session identity" >&2
442
+ return 1
443
+ }
444
+
445
+ # Build JSON arrays using jq
446
+ local files_json modules_json
447
+ files_json="$(printf '%s\n' "${files[@]}" | jq -R . | jq -s -c 'unique')"
448
+ modules_json="$(printf '%s\n' "${files[@]}" | while IFS= read -r p; do
449
+ local d
450
+ d="$(dirname "$p")"
451
+ if [[ "$d" == "." ]]; then printf '%s\n' "./"; else printf '%s\n' "${d}/"; fi
452
+ done | jq -R . | jq -s -c 'unique')"
453
+
454
+ # Append via file_index_add (which uses atomic locking)
455
+ file_index_add "$issue_id" "$developer" "$files_json" "$modules_json"
456
+
457
+ echo "File index updated for $issue_id (${#files[@]} files)"
458
+ }
459
+
368
460
  # ── Main dispatcher (when run as a script) ────────────────────────────────
369
461
 
370
462
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
@@ -378,6 +470,7 @@ Subcommands:
378
470
  read
379
471
  get <issue_id>
380
472
  update-from-tasks <issue_id> <task_file_path> [action]
473
+ auto-update <issue_id> [--from-git]
381
474
  EOF
382
475
  exit 1
383
476
  fi
@@ -391,6 +484,7 @@ EOF
391
484
  read) file_index_read ;;
392
485
  get) file_index_get "$@" ;;
393
486
  update-from-tasks) file_index_update_from_tasks "$@" ;;
487
+ auto-update) file_index_auto_update "$@" ;;
394
488
  *)
395
489
  echo "Error: Unknown subcommand '${subcommand}'" >&2
396
490
  exit 1
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # forge-team — Team orchestration plugin for Forge.
3
+ #
4
+ # Subcommands:
5
+ # workload Show team workload by developer
6
+ # epic Epic progress rollup
7
+ # dashboard Team health dashboard
8
+ # add Add developer to team map
9
+ # verify Check 1:1 Beads<>GitHub enforcement
10
+ # sync Manual GitHub<>Beads sync
11
+ # claim Claim an issue with pre-check
12
+ # help Show usage
13
+ #
14
+ # Exit codes: 0=success, 1=error, 2=validation error
15
+
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+
20
+ # Source libs (graceful if missing — stubs used in early dev)
21
+ [[ -f "$SCRIPT_DIR/lib/agent-prompt.sh" ]] && source "$SCRIPT_DIR/lib/agent-prompt.sh"
22
+ [[ -f "$SCRIPT_DIR/lib/identity.sh" ]] && source "$SCRIPT_DIR/lib/identity.sh"
23
+ [[ -f "$SCRIPT_DIR/lib/workload.sh" ]] && source "$SCRIPT_DIR/lib/workload.sh"
24
+ [[ -f "$SCRIPT_DIR/lib/epic.sh" ]] && source "$SCRIPT_DIR/lib/epic.sh"
25
+ [[ -f "$SCRIPT_DIR/lib/dashboard.sh" ]] && source "$SCRIPT_DIR/lib/dashboard.sh"
26
+ [[ -f "$SCRIPT_DIR/lib/hooks.sh" ]] && source "$SCRIPT_DIR/lib/hooks.sh"
27
+ [[ -f "$SCRIPT_DIR/lib/verify.sh" ]] && source "$SCRIPT_DIR/lib/verify.sh"
28
+ [[ -f "$SCRIPT_DIR/lib/claim.sh" ]] && source "$SCRIPT_DIR/lib/claim.sh"
29
+
30
+ # Source shared forge utilities
31
+ FORGE_SCRIPTS="$(cd "$SCRIPT_DIR/.." && pwd)"
32
+ [[ -f "$FORGE_SCRIPTS/lib/sanitize.sh" ]] && source "$FORGE_SCRIPTS/lib/sanitize.sh"
33
+
34
+ # ── Stub implementations ──
35
+ # cmd_workload provided by lib/workload.sh
36
+ # cmd_epic provided by lib/epic.sh
37
+ # cmd_dashboard provided by lib/dashboard.sh
38
+ cmd_add() { auto_detect_identity "$@"; }
39
+ # cmd_verify provided by lib/verify.sh
40
+ cmd_sync() { forge_team_sync "$@"; }
41
+ cmd_claim() { forge_team_claim "$@"; }
42
+
43
+ cmd_help() {
44
+ cat <<'EOF'
45
+ Usage: forge team <subcommand> [args...]
46
+
47
+ Subcommands:
48
+ workload [--developer=<user>] [--me] Show team workload
49
+ epic <issue-id> Epic progress rollup
50
+ dashboard Team health dashboard
51
+ add [--github=<user>] Add developer to team map
52
+ verify Check 1:1 Beads<>GitHub sync
53
+ sync Manual GitHub<>Beads sync
54
+ claim <issue-id> [--force] Claim issue with pre-check
55
+ help Show this message
56
+ EOF
57
+ }
58
+
59
+ # ── Main dispatcher ──
60
+ main() {
61
+ if [[ $# -lt 1 ]]; then
62
+ cmd_help >&2
63
+ exit 1
64
+ fi
65
+
66
+ local subcommand="$1"
67
+ shift
68
+
69
+ case "$subcommand" in
70
+ workload) cmd_workload "$@" ;;
71
+ epic) cmd_epic "$@" ;;
72
+ dashboard) cmd_dashboard "$@" ;;
73
+ add) cmd_add "$@" ;;
74
+ verify) cmd_verify "$@" ;;
75
+ sync) cmd_sync "$@" ;;
76
+ claim) cmd_claim "$@" ;;
77
+ help) cmd_help ;;
78
+ *)
79
+ echo "Error: unknown subcommand '$subcommand'" >&2
80
+ cmd_help >&2
81
+ exit 1
82
+ ;;
83
+ esac
84
+ }
85
+
86
+ main "$@"
@@ -0,0 +1,52 @@
1
+ # agent-prompt.sh — Shared library for structured agent communication
2
+ # All user-facing output from `forge team` goes through these helpers.
3
+ # Uses non-guessable prefix to prevent injection from GitHub data.
4
+ #
5
+ # Usage: source this file from other scripts.
6
+ # This library does NOT set errexit/pipefail.
7
+
8
+ # Guard against double-sourcing (readonly would error on second source)
9
+ if [[ -n "${_AGENT_PROMPT_LIB_LOADED:-}" ]]; then
10
+ return 0 2>/dev/null || true
11
+ fi
12
+ _AGENT_PROMPT_LIB_LOADED=1
13
+
14
+ readonly FORGE_AGENT_PREFIX="FORGE_AGENT_7f3a"
15
+
16
+ # Output a prompt for the AI agent to ask the user.
17
+ # Format: FORGE_AGENT_7f3a:PROMPT: <message> (to stderr)
18
+ agent_prompt() {
19
+ echo "${FORGE_AGENT_PREFIX}:PROMPT: $1" >&2
20
+ }
21
+
22
+ # Informational output.
23
+ # Format: FORGE_AGENT_7f3a:INFO: <message> (to stderr)
24
+ agent_info() {
25
+ echo "${FORGE_AGENT_PREFIX}:INFO: $1" >&2
26
+ }
27
+
28
+ # Error output.
29
+ # Format: FORGE_AGENT_7f3a:ERROR: <message> (to stderr)
30
+ agent_error() {
31
+ echo "${FORGE_AGENT_PREFIX}:ERROR: $1" >&2
32
+ }
33
+
34
+ # Strip any occurrence of the FORGE_AGENT_7f3a: prefix from input strings.
35
+ # Prevents injection from GitHub issue titles or other external data.
36
+ # Takes input via stdin or as $1 arg, outputs sanitized text to stdout.
37
+ sanitize_for_agent() {
38
+ local input
39
+ if [[ $# -gt 0 ]]; then
40
+ input="$1"
41
+ else
42
+ input="$(cat)"
43
+ fi
44
+ # Loop until no more prefix occurrences (prevents split-prefix bypass)
45
+ local prev
46
+ while true; do
47
+ prev="$input"
48
+ input="${input//${FORGE_AGENT_PREFIX}:/}"
49
+ [[ "$input" == "$prev" ]] && break
50
+ done
51
+ printf '%s' "$input"
52
+ }
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/forge-team/lib/claim.sh — Claim locking with pre-claim check
3
+ #
4
+ # Before allowing issue claim, checks GitHub assignee and uses flock/mkdir
5
+ # to prevent race conditions between concurrent agents.
6
+ #
7
+ # Functions:
8
+ # pre_claim_check — Check if issue is claimable
9
+ # claim_with_lock — Atomic claim with file locking
10
+ # forge_team_claim — Top-level dispatcher entry point
11
+ #
12
+ # Env overrides (for testing):
13
+ # GH_CMD — Path to gh binary
14
+ # BD_CMD — Path to bd binary
15
+ #
16
+ # This file does NOT set errexit/pipefail — callers manage their own shell options.
17
+
18
+ # Guard against double-sourcing
19
+ if [[ -n "${_CLAIM_LIB_LOADED:-}" ]]; then
20
+ return 0 2>/dev/null || true
21
+ fi
22
+ _CLAIM_LIB_LOADED=1
23
+
24
+ _CLAIM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+
26
+ # Source dependencies
27
+ if [[ -f "$_CLAIM_DIR/agent-prompt.sh" ]]; then
28
+ source "$_CLAIM_DIR/agent-prompt.sh"
29
+ fi
30
+
31
+ if [[ -f "$_CLAIM_DIR/identity.sh" ]]; then
32
+ source "$_CLAIM_DIR/identity.sh"
33
+ fi
34
+
35
+ # ── Internal helpers ─────────────────────────────────────────────────────
36
+
37
+ # _claim_error <message>
38
+ _claim_error() {
39
+ if declare -f agent_error &>/dev/null; then
40
+ agent_error "$1"
41
+ else
42
+ echo "ERROR: $1" >&2
43
+ fi
44
+ }
45
+
46
+ # _claim_info <message>
47
+ _claim_info() {
48
+ if declare -f agent_info &>/dev/null; then
49
+ agent_info "$1"
50
+ else
51
+ echo "INFO: $1" >&2
52
+ fi
53
+ }
54
+
55
+ # _claim_prompt <message>
56
+ _claim_prompt() {
57
+ if declare -f agent_prompt &>/dev/null; then
58
+ agent_prompt "$1"
59
+ else
60
+ echo "PROMPT: $1" >&2
61
+ fi
62
+ }
63
+
64
+ # _get_github_issue_number <beads-id>
65
+ # Extracts github_issue:N from bd show output. Returns number or empty.
66
+ _get_github_issue_number() {
67
+ local beads_id="$1"
68
+ local bd_cmd="${BD_CMD:-bd}"
69
+ local bd_output
70
+ bd_output="$("$bd_cmd" show "$beads_id" 2>/dev/null)" || return 1
71
+
72
+ local issue_num
73
+ issue_num="$(printf '%s' "$bd_output" | grep -oP 'github_issue:\K[0-9]+' || true)"
74
+ if [[ -z "$issue_num" ]]; then
75
+ return 1
76
+ fi
77
+ printf '%s' "$issue_num"
78
+ }
79
+
80
+ # _get_github_assignee <issue-number>
81
+ # Returns the GitHub login of the assignee, or empty if unassigned.
82
+ _get_github_assignee() {
83
+ local issue_num="$1"
84
+ local gh_cmd="${GH_CMD:-gh}"
85
+ local assignee
86
+ assignee="$("$gh_cmd" issue view "$issue_num" --json assignees --jq '.assignees[0].login // empty' 2>/dev/null)" || return 1
87
+ printf '%s' "$assignee"
88
+ }
89
+
90
+ # _claim_lock_dir — Returns the path to the claim lock directory
91
+ _claim_lock_dir() {
92
+ local root="${TEAM_MAP_ROOT:-.}"
93
+ printf '%s' "$root/.beads"
94
+ }
95
+
96
+ # ── Public API ───────────────────────────────────────────────────────────
97
+
98
+ # pre_claim_check <beads-id>
99
+ # Checks if issue is claimable. Returns 0 if clear, 1 if blocked.
100
+ pre_claim_check() {
101
+ local beads_id="${1:-}"
102
+ if [[ -z "$beads_id" ]]; then
103
+ _claim_error "Usage: pre_claim_check <beads-id>"
104
+ return 1
105
+ fi
106
+
107
+ # Get current user first (validates gh auth)
108
+ local current_user
109
+ if ! current_user="$(get_github_user)"; then
110
+ _claim_error "GitHub CLI not authenticated. Run 'gh auth login' first."
111
+ return 1
112
+ fi
113
+
114
+ # Get GitHub issue number from beads
115
+ local issue_num
116
+ if ! issue_num="$(_get_github_issue_number "$beads_id")"; then
117
+ _claim_error "No github_issue number found for $beads_id"
118
+ return 1
119
+ fi
120
+
121
+ if [[ -z "$issue_num" ]]; then
122
+ _claim_error "No github_issue number found for $beads_id"
123
+ return 1
124
+ fi
125
+
126
+ # Query GitHub assignee
127
+ local assignee
128
+ assignee="$(_get_github_assignee "$issue_num")" || true
129
+
130
+ # If unassigned or assigned to current user: proceed
131
+ if [[ -z "$assignee" ]] || [[ "$assignee" == "$current_user" ]]; then
132
+ return 0
133
+ fi
134
+
135
+ # Assigned to someone else: prompt
136
+ _claim_prompt "$beads_id is claimed by $assignee. Override? Run: forge team claim $beads_id --force"
137
+ return 1
138
+ }
139
+
140
+ # claim_with_lock <beads-id> [--force]
141
+ # Atomic claim using flock/mkdir locking pattern.
142
+ # With --force, skips the re-check of assignee inside the lock.
143
+ claim_with_lock() {
144
+ local beads_id="${1:-}"
145
+ local force="${2:-}"
146
+ if [[ -z "$beads_id" ]]; then
147
+ _claim_error "Usage: claim_with_lock <beads-id>"
148
+ return 1
149
+ fi
150
+
151
+ local gh_cmd="${GH_CMD:-gh}"
152
+ local bd_cmd="${BD_CMD:-bd}"
153
+ local lock_dir
154
+ lock_dir="$(_claim_lock_dir)"
155
+ mkdir -p "$lock_dir"
156
+
157
+ local lock_file="$lock_dir/claim.lock"
158
+
159
+ # Get current user
160
+ local current_user
161
+ if ! current_user="$(get_github_user)"; then
162
+ _claim_error "GitHub CLI not authenticated."
163
+ return 1
164
+ fi
165
+
166
+ # Get GitHub issue number
167
+ local issue_num
168
+ if ! issue_num="$(_get_github_issue_number "$beads_id")"; then
169
+ _claim_error "No github_issue number found for $beads_id"
170
+ return 1
171
+ fi
172
+
173
+ if command -v flock &>/dev/null; then
174
+ # flock-based locking
175
+ (
176
+ flock -w 5 200 || {
177
+ _claim_error "Claim lock timeout after 5s"
178
+ exit 1
179
+ }
180
+ # Re-check assignee inside lock (skip if --force)
181
+ if [[ "$force" != "--force" ]]; then
182
+ local assignee
183
+ assignee="$(_get_github_assignee "$issue_num")" || true
184
+ if [[ -n "$assignee" ]] && [[ "$assignee" != "$current_user" ]]; then
185
+ _claim_error "$beads_id was claimed by $assignee while waiting for lock"
186
+ exit 1
187
+ fi
188
+ fi
189
+ # Claim on both Beads and GitHub
190
+ "$bd_cmd" update "$beads_id" --claim >/dev/null 2>&1 || true
191
+ "$gh_cmd" issue edit "$issue_num" --add-assignee "$current_user" >/dev/null 2>&1 || true
192
+ ) 200>"$lock_file"
193
+ local subshell_rc=$?
194
+ [[ $subshell_rc -ne 0 ]] && return $subshell_rc
195
+ else
196
+ # mkdir-based fallback
197
+ local lock_mkdir="$lock_dir/claim.lock.d"
198
+ local attempts=0
199
+ while ! mkdir "$lock_mkdir" 2>/dev/null; do
200
+ attempts=$((attempts + 1))
201
+ if [[ $attempts -ge 50 ]]; then
202
+ _claim_error "Claim lock timeout after 5s"
203
+ return 1
204
+ fi
205
+ sleep 0.1
206
+ done
207
+ trap 'rmdir "$lock_mkdir" 2>/dev/null; trap - RETURN' RETURN
208
+
209
+ # Re-check assignee inside lock (skip if --force)
210
+ if [[ "$force" != "--force" ]]; then
211
+ local assignee
212
+ assignee="$(_get_github_assignee "$issue_num")" || true
213
+ if [[ -n "$assignee" ]] && [[ "$assignee" != "$current_user" ]]; then
214
+ _claim_error "$beads_id was claimed by $assignee while waiting for lock"
215
+ return 1
216
+ fi
217
+ fi
218
+ # Claim on both Beads and GitHub
219
+ "$bd_cmd" update "$beads_id" --claim >/dev/null 2>&1 || true
220
+ "$gh_cmd" issue edit "$issue_num" --add-assignee "$current_user" >/dev/null 2>&1 || true
221
+ fi
222
+
223
+ return 0
224
+ }
225
+
226
+ # forge_team_claim <beads-id> [--force]
227
+ # Top-level function called by the dispatcher.
228
+ forge_team_claim() {
229
+ local beads_id="${1:-}"
230
+ local force_flag="${2:-}"
231
+
232
+ if [[ -z "$beads_id" ]]; then
233
+ _claim_error "Usage: forge team claim <beads-id> [--force]"
234
+ return 1
235
+ fi
236
+
237
+ local bd_cmd="${BD_CMD:-bd}"
238
+
239
+ if [[ "$force_flag" == "--force" ]]; then
240
+ # Skip pre-claim check, claim directly (pass --force to skip lock re-check too)
241
+ claim_with_lock "$beads_id" "--force" || return 1
242
+ # Log override via bd comments
243
+ "$bd_cmd" comments add "$beads_id" "Force-claimed by $(get_github_user 2>/dev/null || echo 'unknown')" >/dev/null 2>&1 || true
244
+ _claim_info "$beads_id force-claimed successfully"
245
+ return 0
246
+ fi
247
+
248
+ # Normal flow: pre-claim check then lock-claim
249
+ if ! pre_claim_check "$beads_id"; then
250
+ return 1
251
+ fi
252
+
253
+ claim_with_lock "$beads_id" || return 1
254
+ _claim_info "$beads_id claimed successfully"
255
+ return 0
256
+ }