forge-workflow 0.0.8 → 0.0.10

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 (135) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/premerge.md +2 -2
  4. package/.claude/commands/review.md +5 -2
  5. package/.claude/commands/ship.md +6 -5
  6. package/.claude/commands/status.md +4 -4
  7. package/.claude/rules/greptile-review-process.md +4 -4
  8. package/.cline/workflows/dev.md +2 -2
  9. package/.cline/workflows/plan.md +2 -2
  10. package/.cline/workflows/premerge.md +2 -2
  11. package/.cline/workflows/review.md +5 -2
  12. package/.cline/workflows/ship.md +6 -5
  13. package/.cline/workflows/status.md +4 -4
  14. package/.codex/skills/dev/SKILL.md +2 -2
  15. package/.codex/skills/plan/SKILL.md +2 -2
  16. package/.codex/skills/premerge/SKILL.md +2 -2
  17. package/.codex/skills/review/SKILL.md +5 -2
  18. package/.codex/skills/ship/SKILL.md +6 -5
  19. package/.codex/skills/status/SKILL.md +4 -4
  20. package/.cursor/commands/dev.md +2 -2
  21. package/.cursor/commands/plan.md +2 -2
  22. package/.cursor/commands/premerge.md +2 -2
  23. package/.cursor/commands/review.md +5 -2
  24. package/.cursor/commands/ship.md +6 -5
  25. package/.cursor/commands/status.md +4 -4
  26. package/.github/prompts/dev.prompt.md +2 -2
  27. package/.github/prompts/plan.prompt.md +2 -2
  28. package/.github/prompts/premerge.prompt.md +2 -2
  29. package/.github/prompts/review.prompt.md +5 -2
  30. package/.github/prompts/ship.prompt.md +6 -5
  31. package/.github/prompts/status.prompt.md +4 -4
  32. package/.github/workflows/beads-to-github.yml +44 -11
  33. package/.github/workflows/github-to-beads.yml +11 -8
  34. package/.kilocode/workflows/dev.md +2 -2
  35. package/.kilocode/workflows/plan.md +2 -2
  36. package/.kilocode/workflows/premerge.md +2 -2
  37. package/.kilocode/workflows/review.md +5 -2
  38. package/.kilocode/workflows/ship.md +6 -5
  39. package/.kilocode/workflows/status.md +4 -4
  40. package/.opencode/commands/dev.md +2 -2
  41. package/.opencode/commands/plan.md +2 -2
  42. package/.opencode/commands/premerge.md +2 -2
  43. package/.opencode/commands/review.md +5 -2
  44. package/.opencode/commands/ship.md +6 -5
  45. package/.opencode/commands/status.md +4 -4
  46. package/.roo/commands/dev.md +2 -2
  47. package/.roo/commands/plan.md +2 -2
  48. package/.roo/commands/premerge.md +2 -2
  49. package/.roo/commands/review.md +5 -2
  50. package/.roo/commands/ship.md +6 -5
  51. package/.roo/commands/status.md +4 -4
  52. package/AGENTS.md +10 -9
  53. package/CLAUDE.md +12 -0
  54. package/README.md +12 -6
  55. package/bin/forge.js +24 -8
  56. package/docs/BEADS_GITHUB_SYNC.md +32 -2
  57. package/docs/EXAMPLES.md +22 -22
  58. package/docs/ROADMAP.md +3 -3
  59. package/docs/TOOLCHAIN.md +150 -160
  60. package/lib/agents/codex.plugin.json +3 -0
  61. package/lib/agents-config.js +18 -12
  62. package/lib/beads-bootstrap.js +225 -0
  63. package/lib/beads-health-check.js +55 -10
  64. package/lib/beads-setup.js +104 -28
  65. package/lib/beads-sync-scaffold.js +11 -6
  66. package/lib/codex-skills.js +54 -1
  67. package/lib/commands/_issue.js +11 -1
  68. package/lib/commands/issues.js +49 -0
  69. package/lib/commands/plan.js +5 -2
  70. package/lib/commands/recommend.js +22 -1
  71. package/lib/commands/setup.js +247 -27
  72. package/lib/commands/ship.js +188 -5
  73. package/lib/commands/status.js +187 -19
  74. package/lib/commands/team.js +11 -1
  75. package/lib/commands/test.js +125 -25
  76. package/lib/commands/validate.js +232 -9
  77. package/lib/commands/worktree.js +27 -54
  78. package/lib/dep-guard/keyword-ripple.js +184 -0
  79. package/lib/detect-worktree.js +9 -10
  80. package/lib/forge-issues.js +326 -0
  81. package/lib/issue-sync/authority.js +100 -0
  82. package/lib/issue-sync/github-pull.js +184 -0
  83. package/lib/issue-sync/import-primitives.js +98 -0
  84. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  85. package/lib/issue-sync/link-store.js +292 -0
  86. package/lib/issue-sync/project-github.js +123 -0
  87. package/lib/issue-sync/reconcile.js +195 -0
  88. package/lib/issue-sync/schema.js +126 -0
  89. package/lib/lefthook-check.js +5 -2
  90. package/lib/project-memory.js +564 -0
  91. package/lib/runtime-health.js +100 -12
  92. package/lib/setup-action-log.js +2 -0
  93. package/lib/setup-summary-renderer.js +15 -11
  94. package/lib/smart-status/conflicts.js +205 -0
  95. package/lib/smart-status/scoring.js +177 -0
  96. package/lib/status/beads-snapshot.js +102 -0
  97. package/lib/status/presenter.js +65 -0
  98. package/lib/workflow/enforce-stage.js +14 -8
  99. package/lib/workflow/state-manager.js +349 -0
  100. package/package.json +12 -4
  101. package/scripts/beads-context.sh +124 -5
  102. package/scripts/beads-context.test.js +21 -4
  103. package/scripts/beads-migrate-to-dolt.sh +7 -0
  104. package/scripts/beads-upgrade-smoke.sh +263 -0
  105. package/scripts/behavioral-judge.sh +115 -11
  106. package/scripts/benchmark.js +349 -63
  107. package/scripts/dep-guard-analyze.js +52 -17
  108. package/scripts/dep-guard-keyword-ripple.js +29 -0
  109. package/scripts/dep-guard-render-review.js +86 -0
  110. package/scripts/dep-guard.sh +45 -222
  111. package/scripts/forge-team/lib/hooks.sh +1 -1
  112. package/scripts/forge-team/lib/sync-github.sh +160 -28
  113. package/scripts/forge-team/lib/verify.sh +1 -1
  114. package/scripts/forge-team/lib/workload.sh +56 -27
  115. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  116. package/scripts/forge-team/tests/workload.test.sh +35 -4
  117. package/scripts/github-beads-sync/index.mjs +122 -98
  118. package/scripts/github-beads-sync/mapping.mjs +54 -0
  119. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  120. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  121. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  122. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  123. package/scripts/preflight.sh +181 -0
  124. package/scripts/smart-status-score.js +31 -0
  125. package/scripts/smart-status-sessions.js +51 -0
  126. package/scripts/smart-status.sh +84 -330
  127. package/scripts/sync-agentic-workflow.js +48 -0
  128. package/scripts/sync-utils.sh +39 -0
  129. package/scripts/test-ci-shard.js +244 -0
  130. package/scripts/test-dashboard.js +188 -52
  131. package/scripts/test-full-suite.js +186 -0
  132. package/scripts/test-profile.js +278 -0
  133. package/scripts/test.js +335 -38
  134. package/scripts/validate.js +143 -0
  135. package/scripts/validate.sh +18 -1
@@ -77,15 +77,38 @@ _status_icon() {
77
77
  esac
78
78
  }
79
79
 
80
+ _json_escape() {
81
+ local value="${1:-}"
82
+ value="${value//\\/\\\\}"
83
+ value="${value//\"/\\\"}"
84
+ value="${value//$'\n'/\\n}"
85
+ value="${value//$'\r'/\\r}"
86
+ value="${value//$'\t'/\\t}"
87
+ printf '%s' "$value"
88
+ }
89
+
80
90
  # _collect_issues — Gather all open/in_progress issues with details
81
91
  # Outputs lines: id|title|status|owner|updated|depends_on
82
92
  _collect_issues() {
83
93
  local bd_cmd="${BD_CMD:-bd}"
84
94
  local list_output
85
- list_output="$("$bd_cmd" list --status=open,in_progress 2>/dev/null)" || return 1
95
+ local combined_exit=0
96
+
97
+ # beads 0.62 rejects comma-separated status filters, so fall back to
98
+ # separate queries when the combined form fails.
99
+ list_output="$("$bd_cmd" list --status=open,in_progress 2>/dev/null)"
100
+ combined_exit=$?
101
+ if [[ "$combined_exit" -ne 0 ]]; then
102
+ list_output="$(
103
+ {
104
+ "$bd_cmd" list --status=open 2>/dev/null || true
105
+ "$bd_cmd" list --status=in_progress 2>/dev/null || true
106
+ } | awk 'NF && !seen[$0]++'
107
+ )"
108
+ fi
86
109
 
87
110
  # Filter empty lines
88
- if [[ -z "$list_output" ]] || [[ -z "$(echo "$list_output" | tr -d '[:space:]')" ]]; then
111
+ if [[ -z "$list_output" ]] || [[ -z "$(printf '%s' "$list_output" | tr -d '[:space:]')" ]]; then
89
112
  return 0
90
113
  fi
91
114
 
@@ -95,7 +118,7 @@ _collect_issues() {
95
118
 
96
119
  # Extract issue id: first field like "forge-xxx" after optional icon
97
120
  local issue_id
98
- issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+' | head -1)"
121
+ issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)"
99
122
  [[ -z "$issue_id" ]] && continue
100
123
 
101
124
  # Get details via bd show
@@ -141,9 +164,10 @@ cmd_workload() {
141
164
 
142
165
  # Parse arguments
143
166
  while [[ $# -gt 0 ]]; do
144
- case "$1" in
167
+ local arg="${1%$'\r'}"
168
+ case "$arg" in
145
169
  --developer=*)
146
- filter_developer="${1#--developer=}"
170
+ filter_developer="${arg#--developer=}"
147
171
  shift
148
172
  ;;
149
173
  --me)
@@ -160,11 +184,11 @@ cmd_workload() {
160
184
  shift
161
185
  ;;
162
186
  --format=*)
163
- _workload_error "Unsupported format: ${1#--format=}"
187
+ _workload_error "Unsupported format: ${arg#--format=}"
164
188
  return 1
165
189
  ;;
166
190
  *)
167
- _workload_error "Unknown argument: $1"
191
+ _workload_error "Unknown argument: $arg"
168
192
  return 1
169
193
  ;;
170
194
  esac
@@ -232,16 +256,13 @@ cmd_workload() {
232
256
  [[ -n "$stale_flag" ]] && stale_bool="true"
233
257
  local blocked_ids=""
234
258
  [[ -n "$depends_on" ]] && blocked_ids="$depends_on"
235
-
236
- # Use jq to build safe JSON
237
- jq -n -c \
238
- --arg id "$issue_id" \
239
- --arg title "$title" \
240
- --arg status "$status" \
241
- --arg updated "$updated" \
242
- --argjson stale "$stale_bool" \
243
- --arg blocked_by "$blocked_ids" \
244
- '{id: $id, title: $title, status: $status, updated: $updated, stale: $stale, blocked_by: $blocked_by}' \
259
+ printf '{"id":"%s","title":"%s","status":"%s","updated":"%s","stale":%s,"blocked_by":"%s"}\n' \
260
+ "$(_json_escape "$issue_id")" \
261
+ "$(_json_escape "$title")" \
262
+ "$(_json_escape "$status")" \
263
+ "$(_json_escape "$updated")" \
264
+ "$stale_bool" \
265
+ "$(_json_escape "$blocked_ids")" \
245
266
  >> "$work_dir/json/${owner}"
246
267
  fi
247
268
  done <<< "$issues_data"
@@ -260,22 +281,30 @@ cmd_workload() {
260
281
  # Output
261
282
  if [[ "$format" == "json" ]]; then
262
283
  # Build JSON object: { "devone": [...], "devtwo": [...] }
263
- local json_result="{"
264
- local first=true
284
+ local first_dev="true"
285
+ printf '{'
265
286
  for dev_file in "$work_dir/json"/*; do
266
287
  local dev_name
267
288
  dev_name="$(basename "$dev_file")"
268
- local issues_array
269
- issues_array="$(jq -s '.' "$dev_file")"
270
- if [[ "$first" == "true" ]]; then
271
- first=false
289
+ local first_issue="true"
290
+ if [[ "$first_dev" == "true" ]]; then
291
+ first_dev="false"
272
292
  else
273
- json_result+=","
293
+ printf ','
274
294
  fi
275
- json_result+="$(jq -n -c --arg dev "$dev_name" --argjson issues "$issues_array" '{($dev): $issues}' | sed 's/^{//' | sed 's/}$//')"
295
+ printf '"%s":[' "$(_json_escape "$dev_name")"
296
+ while IFS= read -r issue_json; do
297
+ [[ -z "$issue_json" ]] && continue
298
+ if [[ "$first_issue" == "true" ]]; then
299
+ first_issue="false"
300
+ else
301
+ printf ','
302
+ fi
303
+ printf '%s' "$issue_json"
304
+ done < "$dev_file"
305
+ printf ']'
276
306
  done
277
- json_result+="}"
278
- echo "$json_result"
307
+ printf '}\n'
279
308
  else
280
309
  local first_dev=true
281
310
  for dev_file in "$work_dir/devs"/*; do
@@ -3,16 +3,17 @@ set -euo pipefail
3
3
 
4
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
5
 
6
- # ── Setup temp dir for test isolation ────────────────────────────────────
7
6
  TEST_TMP="$(mktemp -d)"
8
7
  trap 'rm -rf "$TEST_TMP" "$mock_dir"' EXIT
9
8
 
10
- # ── Create mock dir and log file ─────────────────────────────────────────
11
- mock_dir="$(mktemp -d /c/tmp/forge-sync-test.XXXXXX)"
9
+ mock_dir="$(mktemp -d)"
10
+ portable_bin="$(mktemp -d)"
12
11
  log_file="$mock_dir/calls.log"
13
12
  touch "$log_file"
13
+ REAL_GREP="$(command -v grep)"
14
+ ORIGINAL_PATH="$PATH"
15
+ export REAL_GREP
14
16
 
15
- # ── Mock gh ──────────────────────────────────────────────────────────────
16
17
  cat > "$mock_dir/gh" << 'MOCK'
17
18
  #!/usr/bin/env bash
18
19
  echo "$*" >> "$LOG_FILE"
@@ -23,45 +24,69 @@ case "$1 $2" in
23
24
  "issue comment") echo ""; exit 0 ;;
24
25
  "api user") echo "testuser"; exit 0 ;;
25
26
  esac
26
- echo "unknown gh command: $*" >&2; exit 1
27
+ echo "unknown gh command: $*" >&2
28
+ exit 1
27
29
  MOCK
28
30
  chmod +x "$mock_dir/gh"
29
31
 
30
- # ── Mock bd ──────────────────────────────────────────────────────────────
31
32
  cat > "$mock_dir/bd" << 'MOCK'
32
33
  #!/usr/bin/env bash
33
34
  echo "$*" >> "$LOG_FILE"
34
35
  case "$1" in
35
36
  show)
36
- echo "$2 · Test Issue [github_issue:42]"
37
- echo "Title: Test Issue"
37
+ if [[ "${3:-}" == "--json" ]]; then
38
+ cat <<'JSON'
39
+ {"id":"beads-001","github":{"number":42,"nodeId":"I_kwDOForge42","url":"https://github.com/test/repo/issues/42"},"shared":{"title":"Test Issue"}}
40
+ JSON
41
+ else
42
+ echo "o $2 - Test Issue"
43
+ echo "Title: Test Issue"
44
+ fi
38
45
  ;;
39
46
  set-state) echo "State set" ;;
40
47
  esac
41
48
  MOCK
42
49
  chmod +x "$mock_dir/bd"
43
50
 
44
- # ── Mock bd that returns NO github_issue ─────────────────────────────────
45
51
  cat > "$mock_dir/bd-no-gh" << 'MOCK'
46
52
  #!/usr/bin/env bash
47
53
  echo "$*" >> "$LOG_FILE"
48
54
  case "$1" in
49
55
  show)
50
- echo "$2 · Test Issue"
51
- echo "Title: Test Issue"
56
+ if [[ "${3:-}" == "--json" ]]; then
57
+ cat <<'JSON'
58
+ {"id":"beads-999","shared":{"title":"Test Issue"}}
59
+ JSON
60
+ else
61
+ echo "o $2 - Test Issue"
62
+ echo "Title: Test Issue"
63
+ fi
52
64
  ;;
53
65
  set-state) echo "State set" ;;
54
66
  esac
55
67
  MOCK
56
68
  chmod +x "$mock_dir/bd-no-gh"
57
69
 
58
- # Export env for mocks
70
+ cat > "$portable_bin/grep" << 'MOCK'
71
+ #!/usr/bin/env bash
72
+ for arg in "$@"; do
73
+ case "$arg" in
74
+ -P|--perl-regexp|-*P*)
75
+ echo "grep: PCRE support disabled for test" >&2
76
+ exit 2
77
+ ;;
78
+ esac
79
+ done
80
+ exec "$REAL_GREP" "$@"
81
+ MOCK
82
+ chmod +x "$portable_bin/grep"
83
+
59
84
  export LOG_FILE="$log_file"
60
85
  export GH_CMD="$mock_dir/gh"
61
86
  export BD_CMD="$mock_dir/bd"
62
87
  export TEAM_MAP_ROOT="$TEST_TMP"
88
+ mapping_file="$TEAM_MAP_ROOT/.github/beads-mapping.json"
63
89
 
64
- # Source the library under test
65
90
  source "$SCRIPT_DIR/lib/sync-github.sh"
66
91
 
67
92
  PASS=0
@@ -70,119 +95,193 @@ FAIL=0
70
95
  assert_eq() {
71
96
  local label="$1" expected="$2" actual="$3"
72
97
  if [[ "$actual" == "$expected" ]]; then
73
- PASS=$((PASS + 1)); echo " PASS: $label"
98
+ PASS=$((PASS + 1))
99
+ echo " PASS: $label"
74
100
  else
75
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected '$expected', got '$actual')"
101
+ FAIL=$((FAIL + 1))
102
+ echo " FAIL: $label (expected '$expected', got '$actual')"
76
103
  fi
77
104
  }
78
105
 
79
106
  assert_contains() {
80
107
  local label="$1" expected="$2" actual="$3"
81
108
  if [[ "$actual" == *"$expected"* ]]; then
82
- PASS=$((PASS + 1)); echo " PASS: $label"
109
+ PASS=$((PASS + 1))
110
+ echo " PASS: $label"
111
+ else
112
+ FAIL=$((FAIL + 1))
113
+ echo " FAIL: $label (expected to contain '$expected', got '$actual')"
114
+ fi
115
+ }
116
+
117
+ assert_not_contains() {
118
+ local label="$1" unexpected="$2" actual="$3"
119
+ if [[ "$actual" == *"$unexpected"* ]]; then
120
+ FAIL=$((FAIL + 1))
121
+ echo " FAIL: $label (did not expect '$unexpected', got '$actual')"
83
122
  else
84
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected to contain '$expected', got '$actual')"
123
+ PASS=$((PASS + 1))
124
+ echo " PASS: $label"
85
125
  fi
86
126
  }
87
127
 
88
128
  assert_exit() {
89
129
  local label="$1" expected="$2" actual="$3"
90
130
  if [[ "$actual" -eq "$expected" ]]; then
91
- PASS=$((PASS + 1)); echo " PASS: $label"
131
+ PASS=$((PASS + 1))
132
+ echo " PASS: $label"
92
133
  else
93
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected exit $expected, got $actual)"
134
+ FAIL=$((FAIL + 1))
135
+ echo " FAIL: $label (expected exit $expected, got $actual)"
94
136
  fi
95
137
  }
96
138
 
97
- # ── Test 1: sync_issue_create — creates GitHub issue, calls bd set-state ─
98
- echo "── Test 1: sync_issue_create ──"
99
- > "$log_file" # clear log
139
+ echo "== Test 1: sync_issue_create =="
140
+ > "$log_file"
100
141
  rc=0
101
142
  sync_issue_create "beads-001" || rc=$?
102
143
  assert_exit "exit code 0" 0 "$rc"
103
- # Check gh was called with issue create
104
144
  log_contents="$(cat "$log_file")"
105
145
  assert_contains "gh issue create called" "issue create" "$log_contents"
106
146
  assert_contains "title includes Test Issue" "Test Issue" "$log_contents"
107
147
  assert_contains "body includes beads-001" "beads-001" "$log_contents"
108
- # Check bd set-state was called with github_issue=42
109
- assert_contains "bd set-state called" "set-state" "$log_contents"
110
- assert_contains "github_issue=42 set" "github_issue=42" "$log_contents"
148
+ assert_contains "sync create writes legacy github_issue state" "github_issue=42" "$log_contents"
149
+ assert_contains "sync create calls set-state for hook compatibility" "set-state" "$log_contents"
150
+ assert_contains "sync create persists mapping entry" '"42": "beads-001"' "$(cat "$mapping_file")"
111
151
 
112
- # ── Test 2: sync_issue_claim — updates GitHub assignee ───────────────────
113
- echo ""
114
- echo "── Test 2: sync_issue_claim ──"
152
+ echo
153
+ echo "== Test 2: sync_issue_claim =="
115
154
  > "$log_file"
116
155
  unset _GITHUB_USER_CACHE
117
156
  rc=0
118
157
  sync_issue_claim "beads-001" || rc=$?
119
158
  assert_exit "exit code 0" 0 "$rc"
120
159
  log_contents="$(cat "$log_file")"
160
+ assert_contains "canonical lookup uses --json" "--json" "$log_contents"
121
161
  assert_contains "gh issue edit called" "issue edit" "$log_contents"
122
162
  assert_contains "add-assignee used" "--add-assignee" "$log_contents"
123
163
  assert_contains "assignee is testuser" "testuser" "$log_contents"
124
164
 
125
- # ── Test 3: sync_issue_status — removes old labels, adds new one ─────────
126
- echo ""
127
- echo "── Test 3: sync_issue_status ──"
165
+ echo
166
+ echo "== Test 3: sync_issue_status =="
128
167
  > "$log_file"
129
168
  rc=0
130
169
  sync_issue_status "beads-001" "in_progress" || rc=$?
131
170
  assert_exit "exit code 0" 0 "$rc"
132
171
  log_contents="$(cat "$log_file")"
172
+ assert_contains "canonical lookup uses --json" "--json" "$log_contents"
133
173
  assert_contains "gh issue edit called" "issue edit" "$log_contents"
134
174
  assert_contains "removes status/open" "--remove-label status/open" "$log_contents"
135
175
  assert_contains "adds status/in-progress" "--add-label status/in-progress" "$log_contents"
136
176
 
137
- # ── Test 4: sync_issue_close — closes GitHub issue ───────────────────────
138
- echo ""
139
- echo "── Test 4: sync_issue_close ──"
177
+ echo
178
+ echo "== Test 4: sync_issue_close =="
140
179
  > "$log_file"
141
180
  rc=0
142
181
  sync_issue_close "beads-001" || rc=$?
143
182
  assert_exit "exit code 0" 0 "$rc"
144
183
  log_contents="$(cat "$log_file")"
184
+ assert_contains "canonical lookup uses --json" "--json" "$log_contents"
145
185
  assert_contains "gh issue close called" "issue close" "$log_contents"
146
186
  assert_contains "closes issue 42" " 42" "$log_contents"
147
187
 
148
- # ── Test 5: sync_issue_deps — adds comment on GitHub issue ───────────────
149
- echo ""
150
- echo "── Test 5: sync_issue_deps ──"
188
+ echo
189
+ echo "== Test 5: sync_issue_deps =="
151
190
  > "$log_file"
152
191
  rc=0
153
192
  sync_issue_deps "beads-001" "beads-002" || rc=$?
154
193
  assert_exit "exit code 0" 0 "$rc"
155
194
  log_contents="$(cat "$log_file")"
195
+ assert_contains "canonical lookup uses --json" "--json" "$log_contents"
156
196
  assert_contains "gh issue comment called" "issue comment" "$log_contents"
157
197
  assert_contains "Blocked by referenced" "Blocked by #42" "$log_contents"
158
198
 
159
- # ── Test 6: _get_github_issue_number — extracts number from bd show ──────
160
- echo ""
161
- echo "── Test 6: _get_github_issue_number ──"
199
+ echo
200
+ echo "== Test 6: _get_github_issue_number =="
162
201
  result="$(_get_github_issue_number "beads-001")"
163
202
  assert_eq "extracts issue number 42" "42" "$result"
203
+ log_contents="$(cat "$log_file")"
204
+ assert_contains "_get_github_issue_number uses JSON" "--json" "$log_contents"
164
205
 
165
- # ── Test 7: Missing GitHub issue number → returns error ──────────────────
166
- echo ""
167
- echo "── Test 7: Missing GitHub issue number ──"
206
+ echo
207
+ echo "== Test 7: Mapping fallback when canonical GitHub number missing =="
208
+ mkdir -p "$(dirname "$mapping_file")"
209
+ cat > "$mapping_file" << 'JSON'
210
+ {
211
+ "77": "beads-999"
212
+ }
213
+ JSON
214
+ export BD_CMD="$mock_dir/bd-no-gh"
215
+ result="$(_get_github_issue_number "beads-999")"
216
+ assert_eq "mapping fallback returns issue number 77" "77" "$result"
217
+ export BD_CMD="$mock_dir/bd"
218
+
219
+ echo
220
+ echo "== Test 8: Mapping fallback returns newest issue number =="
221
+ cat > "$mapping_file" << 'JSON'
222
+ {
223
+ "77": "beads-999",
224
+ "91": "beads-999",
225
+ "88": "beads-other"
226
+ }
227
+ JSON
228
+ export BD_CMD="$mock_dir/bd-no-gh"
229
+ result="$(_get_github_issue_number "beads-999")"
230
+ assert_eq "mapping fallback returns newest matching issue number" "91" "$result"
231
+ export BD_CMD="$mock_dir/bd"
232
+
233
+ echo
234
+ echo "== Test 9: Missing canonical GitHub number =="
235
+ rm -f "$mapping_file"
168
236
  export BD_CMD="$mock_dir/bd-no-gh"
169
237
  rc=0
170
238
  result="$(_get_github_issue_number "beads-999" 2>/dev/null)" || rc=$?
171
- assert_exit "returns error when no github_issue" 1 "$rc"
172
- # Restore normal bd mock
239
+ assert_exit "returns error when no canonical github number" 1 "$rc"
240
+ export BD_CMD="$mock_dir/bd"
241
+
242
+ echo
243
+ echo "== Test 10: Legacy github_issue state fallback =="
244
+ cat > "$mock_dir/bd-legacy" << 'MOCK'
245
+ #!/usr/bin/env bash
246
+ echo "$*" >> "$LOG_FILE"
247
+ case "$1" in
248
+ show)
249
+ if [[ "${3:-}" == "--json" ]]; then
250
+ cat <<'JSON'
251
+ {"id":"beads-legacy","github_issue":43,"shared":{"title":"Legacy Issue"}}
252
+ JSON
253
+ else
254
+ echo "o $2 - Legacy Issue"
255
+ echo "github_issue:43"
256
+ echo "Title: Legacy Issue"
257
+ fi
258
+ ;;
259
+ set-state) echo "State set" ;;
260
+ esac
261
+ MOCK
262
+ chmod +x "$mock_dir/bd-legacy"
263
+ export BD_CMD="$mock_dir/bd-legacy"
264
+ rc=0
265
+ result="$(_get_github_issue_number "beads-legacy" 2>/dev/null)" || rc=$?
266
+ assert_exit "legacy github_issue exits successfully" 0 "$rc"
267
+ assert_eq "legacy github_issue returns number" "43" "$result"
173
268
  export BD_CMD="$mock_dir/bd"
174
269
 
175
- # ── Test 8: Injection in issue title → sanitized before gh call ──────────
176
- echo ""
177
- echo "── Test 8: Injection in title sanitized ──"
178
- # Create a bd mock that returns injection-laden title
270
+ echo
271
+ echo "== Test 11: Injection in title sanitized =="
179
272
  cat > "$mock_dir/bd-inject" << 'MOCK'
180
273
  #!/usr/bin/env bash
181
274
  echo "$*" >> "$LOG_FILE"
182
275
  case "$1" in
183
276
  show)
184
- echo '○ beads-inject · Evil $(rm -rf /) Issue [github_issue:99]'
185
- echo 'Title: Evil $(rm -rf /) Issue'
277
+ if [[ "${3:-}" == "--json" ]]; then
278
+ cat <<'JSON'
279
+ {"id":"beads-inject","github":{"number":99,"url":"https://github.com/test/repo/issues/99"},"shared":{"title":"Evil $(rm -rf /) Issue"}}
280
+ JSON
281
+ else
282
+ echo 'o beads-inject - Evil $(rm -rf /) Issue'
283
+ echo 'Title: Evil $(rm -rf /) Issue'
284
+ fi
186
285
  ;;
187
286
  set-state) echo "State set" ;;
188
287
  esac
@@ -194,16 +293,54 @@ rc=0
194
293
  sync_issue_create "beads-inject" || rc=$?
195
294
  assert_exit "exit code 0 with injection" 0 "$rc"
196
295
  log_contents="$(cat "$log_file")"
197
- # The $(...) pattern must NOT appear in the gh call
198
296
  if [[ "$log_contents" == *'$(rm'* ]]; then
199
- FAIL=$((FAIL + 1)); echo " FAIL: injection NOT sanitized — \$(rm found in gh call"
297
+ FAIL=$((FAIL + 1))
298
+ echo " FAIL: injection NOT sanitized - \$\(rm found in gh call"
200
299
  else
201
- PASS=$((PASS + 1)); echo " PASS: injection sanitized — no \$(rm in gh call"
300
+ PASS=$((PASS + 1))
301
+ echo " PASS: injection sanitized - no \$\(rm in gh call"
202
302
  fi
203
- # Restore normal bd mock
204
303
  export BD_CMD="$mock_dir/bd"
205
304
 
206
- # ── Results ──────────────────────────────────────────────────────────────
207
- echo ""
305
+ echo
306
+ echo "== Test 11: sync_issue_create without grep -P =="
307
+ export PATH="$portable_bin:$ORIGINAL_PATH"
308
+ > "$log_file"
309
+ rc=0
310
+ sync_issue_create "beads-001" || rc=$?
311
+ assert_exit "sync_issue_create works when grep -P is unavailable" 0 "$rc"
312
+ log_contents="$(cat "$log_file")"
313
+ assert_contains "gh issue create called without PCRE grep" "issue create" "$log_contents"
314
+ export PATH="$ORIGINAL_PATH"
315
+
316
+ echo
317
+ echo "== Test 12: sync_issue_create with summary-only bd show =="
318
+ cat > "$mock_dir/bd-summary-only" << 'MOCK'
319
+ #!/usr/bin/env bash
320
+ echo "$*" >> "$LOG_FILE"
321
+ case "$1" in
322
+ show)
323
+ if [[ "${3:-}" == "--json" ]]; then
324
+ cat <<'JSON'
325
+ {"id":"beads-summary","github":{"number":77,"url":"https://github.com/test/repo/issues/77"},"shared":{"title":"Summary Only Title"}}
326
+ JSON
327
+ else
328
+ echo "o $2 - Summary Only Title"
329
+ fi
330
+ ;;
331
+ set-state) echo "State set" ;;
332
+ esac
333
+ MOCK
334
+ chmod +x "$mock_dir/bd-summary-only"
335
+ export BD_CMD="$mock_dir/bd-summary-only"
336
+ > "$log_file"
337
+ rc=0
338
+ sync_issue_create "beads-summary" || rc=$?
339
+ assert_exit "sync_issue_create works with summary-only bd show" 0 "$rc"
340
+ log_contents="$(cat "$log_file")"
341
+ assert_contains "summary-only title is passed to gh issue create" "Summary Only Title" "$log_contents"
342
+ export BD_CMD="$mock_dir/bd"
343
+
344
+ echo
208
345
  echo "Results: $PASS passed, $FAIL failed"
209
346
  [[ $FAIL -eq 0 ]] && exit 0 || exit 1
@@ -32,9 +32,15 @@ cat > "$mock_dir/bd" << 'MOCK'
32
32
  #!/usr/bin/env bash
33
33
  case "$1 $2" in
34
34
  "list --status=open,in_progress")
35
- echo "◐ forge-aaa · Feature A"
35
+ exit 1
36
+ ;;
37
+ "list --status=open")
36
38
  echo "○ forge-bbb · Feature B"
39
+ ;;
40
+ "list --status=in_progress")
41
+ echo "◐ forge-aaa · Feature A"
37
42
  echo "◐ forge-ccc · Feature C"
43
+ echo "◐ forge-m1n8.6 · Sub-feature X (dotted ID)"
38
44
  ;;
39
45
  "show forge-aaa")
40
46
  echo "◐ forge-aaa · Feature A [● P2 · IN_PROGRESS]"
@@ -54,6 +60,19 @@ case "$1 $2" in
54
60
  echo "DEPENDS ON"
55
61
  echo " → forge-aaa: Feature A"
56
62
  ;;
63
+ "show forge-m1n8.6")
64
+ # Dotted sub-ID — bd show must receive the full ID including .6
65
+ echo "◐ forge-m1n8.6 · Sub-feature X [● P2 · IN_PROGRESS]"
66
+ echo "Owner: devthree"
67
+ echo "Updated: 2026-03-27T08:00:00Z"
68
+ ;;
69
+ "show forge-m1n8")
70
+ # Parent epic — if a buggy parser truncates forge-m1n8.6 to forge-m1n8,
71
+ # the workload would get this WRONG owner and trigger the guard below.
72
+ echo "◐ forge-m1n8 · PARENT EPIC (WRONG — dotted ID was truncated)"
73
+ echo "Owner: WRONG_OWNER_DO_NOT_USE"
74
+ echo "Updated: 2026-03-20T10:00:00Z"
75
+ ;;
57
76
  esac
58
77
  MOCK
59
78
  chmod +x "$mock_dir/bd"
@@ -65,6 +84,12 @@ case "$1 $2" in
65
84
  "list --status=open,in_progress")
66
85
  echo ""
67
86
  ;;
87
+ "list --status=open")
88
+ echo "○ forge-should-not-appear · Open fallback sentinel"
89
+ ;;
90
+ "list --status=in_progress")
91
+ echo "◐ forge-should-not-appear-2 · In-progress fallback sentinel"
92
+ ;;
68
93
  esac
69
94
  MOCK
70
95
  chmod +x "$mock_dir/bd-empty"
@@ -125,6 +150,11 @@ assert_contains "shows devtwo header" "Developer: devtwo" "$output"
125
150
  assert_contains "shows forge-aaa" "forge-aaa" "$output"
126
151
  assert_contains "shows forge-bbb" "forge-bbb" "$output"
127
152
  assert_contains "shows forge-ccc" "forge-ccc" "$output"
153
+ # Dotted ID regression (forge-hpev): the full forge-m1n8.6 must be extracted,
154
+ # not truncated to forge-m1n8. devthree owns the sub-issue, not WRONG_OWNER.
155
+ assert_contains "shows devthree (dotted ID owner)" "Developer: devthree" "$output"
156
+ assert_contains "shows dotted ID forge-m1n8.6 in full" "forge-m1n8.6" "$output"
157
+ assert_not_contains "does NOT attribute to WRONG_OWNER (parent epic)" "WRONG_OWNER" "$output"
128
158
 
129
159
  # ── Test 2: --developer=devone filters correctly ─────────────────────────
130
160
  echo ""
@@ -158,6 +188,7 @@ rc=0
158
188
  output="$(cmd_workload 2>/dev/null)" || rc=$?
159
189
  assert_exit "exits 0" 0 "$rc"
160
190
  assert_contains "shows no active work message" "No active work" "$output"
191
+ assert_not_contains "does not fall back when combined query succeeds with no issues" "forge-should-not-appear" "$output"
161
192
  export BD_CMD="$mock_dir/bd"
162
193
 
163
194
  # ── Test 5: Stale assignment flagged (>48h) ──────────────────────────────
@@ -178,18 +209,18 @@ rc=0
178
209
  output="$(cmd_workload --format=json 2>/dev/null)" || rc=$?
179
210
  assert_exit "exits 0" 0 "$rc"
180
211
  # Validate it's parseable JSON
181
- if echo "$output" | jq . >/dev/null 2>&1; then
212
+ if echo "$output" | node -e 'JSON.parse(require("fs").readFileSync(0, "utf8"));' >/dev/null 2>&1; then
182
213
  PASS=$((PASS + 1)); echo " PASS: output is valid JSON"
183
214
  else
184
215
  FAIL=$((FAIL + 1)); echo " FAIL: output is not valid JSON: $output"
185
216
  fi
186
217
  # Check JSON has developer keys
187
- if echo "$output" | jq -e '.devone' >/dev/null 2>&1; then
218
+ if echo "$output" | node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(data.devone ? 0 : 1);' >/dev/null 2>&1; then
188
219
  PASS=$((PASS + 1)); echo " PASS: JSON contains devone key"
189
220
  else
190
221
  FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devone key"
191
222
  fi
192
- if echo "$output" | jq -e '.devtwo' >/dev/null 2>&1; then
223
+ if echo "$output" | node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(data.devtwo ? 0 : 1);' >/dev/null 2>&1; then
193
224
  PASS=$((PASS + 1)); echo " PASS: JSON contains devtwo key"
194
225
  else
195
226
  FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devtwo key"