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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # dep-guard.sh Dependency-guard helper for pre-change impact analysis.
2
+ # dep-guard.sh -- Dependency-guard helper for pre-change impact analysis.
3
3
  #
4
4
  # Subcommands:
5
5
  # find-consumers <file-path> Find files that import/require a given module
@@ -14,9 +14,10 @@ set -euo pipefail
14
14
 
15
15
  # Source shared sanitize library
16
16
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ NODE_CMD="${NODE_CMD:-node}"
17
18
  source "$SCRIPT_DIR/lib/sanitize.sh"
18
19
 
19
- # ── Helpers ──────────────────────────────────────────────────────────────
20
+ # ---- Helpers ----------------------------------------------------------------
20
21
 
21
22
  usage() {
22
23
  cat >&2 <<'EOF'
@@ -212,90 +213,28 @@ extract_task_file_from_design() {
212
213
  }
213
214
 
214
215
  run_phase3_analyzer() {
215
- local current_json_file="$1"
216
- local open_json_file="$2"
217
- local in_progress_json_file="$3"
216
+ local current_json="$1"
217
+ local open_json="$2"
218
+ local in_progress_json="$3"
218
219
  local task_file="$4"
219
220
  local repository_root="$5"
220
221
  local analyzer_script="${DEP_GUARD_ANALYZE_SCRIPT:-scripts/dep-guard-analyze.js}"
221
222
 
222
- node "$analyzer_script" \
223
- "$current_json_file" \
224
- "$open_json_file" \
225
- "$in_progress_json_file" \
226
- "$task_file" \
227
- "$repository_root"
223
+ printf '{"currentIssue":%s,"openIssues":%s,"inProgressIssues":%s,"taskFile":%s,"repositoryRoot":%s}' \
224
+ "$current_json" \
225
+ "${open_json:-[]}" \
226
+ "${in_progress_json:-[]}" \
227
+ "$(printf '%s' "$task_file" | jq -R '.')" \
228
+ "$(printf '%s' "$repository_root" | jq -R '.')" \
229
+ | "$NODE_CMD" "$analyzer_script" --stdin
228
230
  }
229
231
 
230
232
  render_phase3_review() {
231
- local analysis_file="$1"
232
-
233
- node - "$analysis_file" <<'NODE'
234
- const fs = require('node:fs');
235
-
236
- const analysisPath = process.argv[2];
237
- const analysis = JSON.parse(fs.readFileSync(analysisPath, 'utf8'));
238
- const detectorCategories = Object.entries(analysis.scores ?? {})
239
- .filter(([name, score]) => name !== 'rubric' && Number(score) > 0)
240
- .map(([name]) => name);
241
-
242
- const lines = [
243
- '',
244
- `Structured dependency review for ${analysis.currentIssue.id}...`,
245
- '',
246
- ];
247
-
248
- if (!Array.isArray(analysis.issues) || analysis.issues.length === 0) {
249
- lines.push('No conflicts detected');
250
- process.stdout.write(`${lines.join('\n')}\n`);
251
- process.exit(0);
233
+ local renderer_script="${DEP_GUARD_RENDER_SCRIPT:-scripts/dep-guard-render-review.js}"
234
+ "$NODE_CMD" "$renderer_script"
252
235
  }
253
236
 
254
- const seenPairs = new Set();
255
- for (const finding of analysis.issues) {
256
- const key = `${analysis.currentIssue.id}->${finding.targetIssueId}`;
257
- if (seenPairs.has(key)) {
258
- continue;
259
- }
260
- seenPairs.add(key);
261
- lines.push(`Issue pair: ${analysis.currentIssue.id} -> ${finding.targetIssueId}`);
262
- }
263
-
264
- lines.push(`Rubric score: ${analysis.scores?.rubric ?? 0}`);
265
- lines.push(
266
- `Confidence: ${(analysis.confidence?.score ?? 0).toFixed(2)}${
267
- analysis.confidence?.belowThreshold ? ' (below 70% threshold)' : ''
268
- }`,
269
- );
270
- lines.push(`Detector categories: ${detectorCategories.join(', ') || 'none'}`);
271
- lines.push(`Needs user decision: ${analysis.needsUserDecision ? 'yes' : 'no'}`);
272
-
273
- if (Array.isArray(analysis.detectorConflicts) && analysis.detectorConflicts.length > 0) {
274
- lines.push('Conflicts:');
275
- for (const conflict of analysis.detectorConflicts) {
276
- lines.push(` - ${conflict}`);
277
- }
278
- }
279
-
280
- if (Array.isArray(analysis.proposals) && analysis.proposals.length > 0) {
281
- lines.push('');
282
- lines.push('Proposed dependency updates:');
283
- for (const proposal of analysis.proposals) {
284
- lines.push(` - ${proposal.dependentIssueId} depends on ${proposal.dependsOnIssueId}`);
285
- if (Array.isArray(proposal.pros) && proposal.pros.length > 0) {
286
- lines.push(` Pros: ${proposal.pros.join('; ')}`);
287
- }
288
- if (Array.isArray(proposal.cons) && proposal.cons.length > 0) {
289
- lines.push(` Cons: ${proposal.cons.join('; ')}`);
290
- }
291
- }
292
- }
293
-
294
- process.stdout.write(`${lines.join('\n')}\n`);
295
- NODE
296
- }
297
-
298
- # ── Subcommands ──────────────────────────────────────────────────────────
237
+ # ---- Subcommands ------------------------------------------------------------
299
238
 
300
239
  cmd_find_consumers() {
301
240
  if [[ $# -lt 1 || -z "$1" ]]; then
@@ -343,11 +282,11 @@ cmd_check_ripple_keyword_v1() {
343
282
  local issue_id
344
283
  issue_id="$(sanitize "$1")"
345
284
 
346
- # ── Step 1: Validate issue exists ──────────────────────────────────────
285
+ # ---- Step 1: Validate issue exists ----------------------------------------
347
286
  local src_json
348
287
  src_json="$(bd_show_json "$issue_id")"
349
288
 
350
- # ── Step 2: Extract source title ───────────────────────────────────────
289
+ # ---- Step 2: Extract source title ----------------------------------------
351
290
  local src_title=""
352
291
  if command -v jq &>/dev/null; then
353
292
  src_title="$(printf '%s' "$src_json" | jq -r '.title // ""' 2>/dev/null)" || true
@@ -358,15 +297,15 @@ cmd_check_ripple_keyword_v1() {
358
297
  fi
359
298
 
360
299
  if [[ -z "$src_title" ]]; then
361
- echo "⚠️ Warning: could not extract title for ${issue_id} ripple check skipped" >&2
300
+ echo "Warning: could not extract title for ${issue_id} - ripple check skipped" >&2
362
301
  return 0
363
302
  fi
364
303
 
365
304
  echo ""
366
- printf '%s\n' "📋 Ripple check for ${issue_id}..."
305
+ printf '%s\n' "Ripple check for ${issue_id}..."
367
306
  echo ""
368
307
 
369
- # ── Step 3: Collect active issues ──────────────────────────────────────
308
+ # ---- Step 3: Collect active issues ---------------------------------------
370
309
  # Run bd list for open and in_progress separately, combine results
371
310
  local list_output=""
372
311
  local open_list=""
@@ -390,122 +329,24 @@ cmd_check_ripple_keyword_v1() {
390
329
  fi
391
330
 
392
331
  if [[ -z "$list_output" ]]; then
393
- echo "⚠️ Warning: could not fetch active issue list ripple check skipped" >&2
332
+ echo "Warning: could not fetch active issue list - ripple check skipped" >&2
394
333
  return 0
395
334
  fi
396
335
 
397
- # ── Step 4: Parse each active issue (excluding source) ─────────────────
398
- # Format: forge-xxx [P2] [feature] - Title of the issue
399
- # forge-yyy [P1] [task] - Another issue title
400
- local overlap_count=0
401
- local overlap_report=""
402
-
403
- # Stop words to exclude from keyword matching
404
- local stop_words=" the a an and or is in to for of with on at by from add fix update implement create remove delete make use get set run test check all this that it be as not no but if do we they are was were been have has had will would could should may can each every both also into than then when where which what how why who its new first last same other "
405
-
406
- # Tokenize source title: lowercase, split on non-alpha, filter stop words + short terms
407
- local src_terms=""
408
- src_terms="$(printf '%s' "$src_title" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alpha:]' '\n' | sort -u)"
409
-
410
- # Filter source terms
411
- local filtered_src_terms=""
412
- while IFS= read -r term; do
413
- [[ -z "$term" ]] && continue
414
- # Skip terms < 3 characters
415
- [[ ${#term} -lt 3 ]] && continue
416
- # Skip stop words
417
- if [[ "$stop_words" == *" ${term} "* ]]; then
418
- continue
419
- fi
420
- filtered_src_terms="${filtered_src_terms} ${term}"
421
- done <<< "$src_terms"
422
-
423
- # Process each line in list_output
424
- while IFS= read -r line; do
425
- [[ -z "$line" ]] && continue
426
-
427
- # Extract issue ID (forge-xxx pattern)
428
- local cand_id=""
429
- cand_id="$(printf '%s' "$line" | grep -oE 'forge-[a-z0-9]+' | head -1)" || continue
430
- [[ -z "$cand_id" ]] && continue
431
-
432
- # Skip the source issue itself
433
- [[ "$cand_id" == "$issue_id" ]] && continue
434
-
435
- # Extract status symbol and map to label
436
- local cand_status="open"
437
- if printf '%s' "$line" | grep -q '◐'; then
438
- cand_status="in_progress"
439
- fi
440
-
441
- # Extract priority (P1, P2, P3, etc.)
442
- local cand_priority=""
443
- cand_priority="$(printf '%s' "$line" | grep -oE 'P[0-9]+' | head -1)" || true
444
- [[ -z "$cand_priority" ]] && cand_priority="P2"
445
-
446
- # Extract title (everything after " - ")
447
- local cand_title=""
448
- cand_title="$(printf '%s' "$line" | sed 's/^[^]]*\] [^]]*\] - //')" || continue
449
- [[ -z "$cand_title" ]] && continue
450
-
451
- # Tokenize candidate title
452
- local cand_terms=""
453
- cand_terms="$(printf '%s' "$cand_title" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alpha:]' '\n' | sort -u)"
454
-
455
- # Filter candidate terms
456
- local filtered_cand_terms=""
457
- while IFS= read -r term; do
458
- [[ -z "$term" ]] && continue
459
- [[ ${#term} -lt 3 ]] && continue
460
- if [[ "$stop_words" == *" ${term} "* ]]; then
461
- continue
462
- fi
463
- filtered_cand_terms="${filtered_cand_terms} ${term}"
464
- done <<< "$cand_terms"
465
-
466
- # ── Step 6: Find shared meaningful terms ───────────────────────────────
467
- local shared_terms=""
468
- local shared_count=0
469
- for src_t in $filtered_src_terms; do
470
- for cand_t in $filtered_cand_terms; do
471
- if [[ "$src_t" == "$cand_t" ]]; then
472
- if [[ -z "$shared_terms" ]]; then
473
- shared_terms="\"${src_t}\""
474
- else
475
- shared_terms="${shared_terms}, \"${src_t}\""
476
- fi
477
- shared_count=$((shared_count + 1))
478
- break
479
- fi
480
- done
481
- done
482
-
483
- # ── Step 7: Report if >= 2 shared terms ────────────────────────────────
484
- if [[ $shared_count -ge 2 ]]; then
485
- overlap_count=$((overlap_count + 1))
486
- overlap_report="${overlap_report} ${cand_id} (${cand_status}, ${cand_priority}): \"${cand_title}\""$'\n'
487
- overlap_report="${overlap_report} Overlap: keyword match — ${shared_terms}"$'\n'
488
- overlap_report="${overlap_report} Confidence: LOW (keyword only, no contract data)"$'\n'
489
- overlap_report="${overlap_report}"$'\n'
490
- overlap_report="${overlap_report} Options:"$'\n'
491
- overlap_report="${overlap_report} (a) Add dependency: bd dep add ${issue_id} ${cand_id}"$'\n'
492
- overlap_report="${overlap_report} (b) Proceed — no real conflict"$'\n'
493
- overlap_report="${overlap_report} (c) Investigate: bd show ${cand_id}"$'\n'
494
- overlap_report="${overlap_report}"$'\n'
495
- fi
496
-
497
- done <<< "$list_output"
498
-
499
- # ── Step 8: Output final report ──────────────────────────────────────
500
- if [[ $overlap_count -gt 0 ]]; then
501
- printf '%s\n' "⚠️ Potential overlap with ${overlap_count} issue(s):"
502
- echo ""
503
- printf '%s' "$overlap_report"
504
- else
505
- printf '%s\n' "✅ No conflicts detected"
336
+ # ---- Step 4: Parse each active issue (excluding source) ------------------
337
+ # Format: - forge-xxx [P2] [feature] - Title of the issue
338
+ # - forge-yyy [P1] [task] - Another issue title
339
+ if ! command -v "$NODE_CMD" >/dev/null 2>&1; then
340
+ echo "Error: ${NODE_CMD} is required but not found." >&2
341
+ exit 1
506
342
  fi
507
343
 
344
+ ISSUE_ID="$issue_id" \
345
+ SOURCE_TITLE="$src_title" \
346
+ LIST_OUTPUT="$list_output" \
347
+ "$NODE_CMD" "$SCRIPT_DIR/dep-guard-keyword-ripple.js"
508
348
  return 0
349
+
509
350
  }
510
351
 
511
352
  cmd_check_ripple() {
@@ -529,7 +370,7 @@ cmd_check_ripple() {
529
370
  fi
530
371
 
531
372
  if [[ -z "$src_title" ]]; then
532
- echo "⚠️ Warning: could not extract title for ${issue_id} ripple check skipped" >&2
373
+ echo "Warning: could not extract title for ${issue_id} - ripple check skipped" >&2
533
374
  return 0
534
375
  fi
535
376
 
@@ -544,7 +385,7 @@ cmd_check_ripple() {
544
385
  local task_file=""
545
386
  task_file="$(extract_task_file_from_design "$design_text" || true)"
546
387
  if [[ -z "$task_file" || ! -f "$task_file" ]]; then
547
- echo "⚠️ Structured analyzer unavailable, falling back to keyword-only ripple check." >&2
388
+ echo "Warning: structured analyzer unavailable, falling back to keyword-only ripple check." >&2
548
389
  cmd_check_ripple_keyword_v1 "$issue_id"
549
390
  return 0
550
391
  fi
@@ -555,41 +396,23 @@ cmd_check_ripple() {
555
396
  in_progress_json="$(${BD_CMD:-bd} list --status=in_progress --json 2>/dev/null)" || true
556
397
 
557
398
  if [[ -z "$open_json" && -z "$in_progress_json" ]]; then
558
- echo "⚠️ Warning: could not fetch active issue list ripple check skipped" >&2
399
+ echo "Warning: could not fetch active issue list - ripple check skipped" >&2
559
400
  return 0
560
401
  fi
561
402
 
562
- local tmp_dir
563
- tmp_dir="$(mktemp -d)"
564
- trap 'rm -rf "$tmp_dir"' RETURN
565
-
566
- printf '%s' "$src_json" > "${tmp_dir}/current.json"
567
- printf '%s' "${open_json:-[]}" > "${tmp_dir}/open.json"
568
- printf '%s' "${in_progress_json:-[]}" > "${tmp_dir}/in-progress.json"
569
403
 
570
404
  local repository_root="${DEP_GUARD_REPOSITORY_ROOT:-$PWD}"
571
405
  if run_phase3_analyzer \
572
- "${tmp_dir}/current.json" \
573
- "${tmp_dir}/open.json" \
574
- "${tmp_dir}/in-progress.json" \
406
+ "$src_json" \
407
+ "${open_json:-[]}" \
408
+ "${in_progress_json:-[]}" \
575
409
  "$task_file" \
576
410
  "$repository_root" \
577
- > "${tmp_dir}/analysis.json" 2> "${tmp_dir}/analysis.err"; then
578
- if render_phase3_review "${tmp_dir}/analysis.json"; then
579
- trap - RETURN
580
- rm -rf "$tmp_dir"
581
- return 0
582
- fi
583
- echo "⚠️ Phase 3 review rendering failed — falling back to keyword-only ripple check." >&2
584
- else
585
- echo "⚠️ Structured analyzer unavailable, falling back to keyword-only ripple check." >&2
586
- if [[ -s "${tmp_dir}/analysis.err" ]]; then
587
- cat "${tmp_dir}/analysis.err" >&2
588
- fi
411
+ | render_phase3_review; then
412
+ return 0
589
413
  fi
590
414
 
591
- trap - RETURN
592
- rm -rf "$tmp_dir"
415
+ echo "Warning: structured analyzer unavailable, falling back to keyword-only ripple check." >&2
593
416
  cmd_check_ripple_keyword_v1 "$issue_id"
594
417
  }
595
418
 
@@ -614,7 +437,7 @@ cmd_apply_decision() {
614
437
  die "Failed to add dependency ${dependent_issue} -> ${depends_on_issue}"
615
438
  }
616
439
 
617
- # Check for cycles use exit code as primary signal
440
+ # Check for cycles -- use exit code as primary signal
618
441
  if ! ${BD_CMD:-bd} dep cycles &>/dev/null; then
619
442
  rollback_dependency "$dependent_issue" "$depends_on_issue"
620
443
  die "Cycle detected for ${dependent_issue} -> ${depends_on_issue}"
@@ -712,7 +535,7 @@ cmd_extract_contracts() {
712
535
  local all_contracts=""
713
536
 
714
537
  while IFS= read -r line || [[ -n "$line" ]]; do
715
- # New task block flush previous
538
+ # New task block -- flush previous
716
539
  if [[ "$line" =~ ^##\ Task ]]; then
717
540
  local _emitted
718
541
  _emitted="$(emit_contracts "$current_files" "$current_what")"
@@ -767,7 +590,7 @@ cmd_extract_contracts() {
767
590
  printf '%s\n' "$contracts"
768
591
  }
769
592
 
770
- # ── Main dispatcher ──────────────────────────────────────────────────────
593
+ # ---- Main dispatcher --------------------------------------------------------
771
594
 
772
595
  if [[ $# -lt 1 ]]; then
773
596
  usage
@@ -148,7 +148,7 @@ forge_team_sync() {
148
148
 
149
149
  # Extract issue id
150
150
  local issue_id
151
- issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+' | head -1)"
151
+ issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)"
152
152
  [[ -z "$issue_id" ]] && continue
153
153
 
154
154
  # Get details to check for github_issue state
@@ -7,7 +7,7 @@
7
7
  # sync_issue_status <beads-id> <status> — Update status label on GitHub
8
8
  # sync_issue_close <beads-id> — Close GitHub issue
9
9
  # sync_issue_deps <beads-id-a> <beads-id-b> — Add "Blocked by" comment
10
- # _get_github_issue_number <beads-id> — Extract github_issue from bd show
10
+ # _get_github_issue_number <beads-id> — Extract canonical GitHub issue from bd show
11
11
  #
12
12
  # Env overrides (for testing):
13
13
  # GH_CMD — Path to gh binary (default: gh)
@@ -71,9 +71,132 @@ _safe_sanitize() {
71
71
  printf '%s' "$input"
72
72
  }
73
73
 
74
+ _extract_issue_number_from_url() {
75
+ local input="${1:-}"
76
+ local suffix="${input##*/issues/}"
77
+
78
+ if [[ "$suffix" == "$input" ]]; then
79
+ return 1
80
+ fi
81
+
82
+ suffix="${suffix%%[^0-9]*}"
83
+ if [[ -z "$suffix" ]]; then
84
+ return 1
85
+ fi
86
+
87
+ printf '%s' "$suffix"
88
+ }
89
+
90
+ _extract_issue_title_from_show() {
91
+ local input="${1:-}"
92
+
93
+ printf '%s' "$input" | node -e '
94
+ const fs = require("node:fs");
95
+ const input = fs.readFileSync(0, "utf8");
96
+ const lines = input.split(/\r?\n/);
97
+ const titledLine = lines.find((line) => /^Title:\s*/.test(line));
98
+
99
+ if (titledLine) {
100
+ process.stdout.write(titledLine.replace(/^Title:\s*/, "").trim());
101
+ process.exit(0);
102
+ }
103
+
104
+ const summaryLine = lines.find((line) => line.trim().length > 0);
105
+ if (!summaryLine) {
106
+ process.exit(0);
107
+ }
108
+
109
+ const separators = [" - ", " \u00B7 ", " \u2022 "];
110
+ for (const separator of separators) {
111
+ const separatorIndex = summaryLine.indexOf(separator);
112
+ if (separatorIndex === -1) {
113
+ continue;
114
+ }
115
+
116
+ const candidate = summaryLine
117
+ .slice(separatorIndex + separator.length)
118
+ .replace(/\s+\[[^\]]*\]\s*$/u, "")
119
+ .trim();
120
+
121
+ if (candidate.length > 0) {
122
+ process.stdout.write(candidate);
123
+ break;
124
+ }
125
+ }
126
+ '
127
+ }
128
+
129
+ _sync_mapping_file() {
130
+ local root="${TEAM_MAP_ROOT:-.}"
131
+ printf '%s' "${SYNC_MAPPING_FILE:-$root/.github/beads-mapping.json}"
132
+ }
133
+
134
+ _persist_issue_mapping() {
135
+ local beads_id="${1:-}"
136
+ local issue_num="${2:-}"
137
+ local mapping_file
138
+ mapping_file="$(_sync_mapping_file)"
139
+
140
+ node - "$mapping_file" "$issue_num" "$beads_id" <<'NODE'
141
+ const fs = require('node:fs');
142
+ const path = require('node:path');
143
+
144
+ const [mappingPath, issueNum, beadsId] = process.argv.slice(2);
145
+ let data = {};
146
+
147
+ if (fs.existsSync(mappingPath)) {
148
+ const raw = fs.readFileSync(mappingPath, 'utf8').trim();
149
+ if (raw) {
150
+ data = JSON.parse(raw);
151
+ }
152
+ }
153
+
154
+ fs.mkdirSync(path.dirname(mappingPath), { recursive: true });
155
+ data[String(issueNum)] = beadsId;
156
+ fs.writeFileSync(mappingPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
157
+ NODE
158
+ }
159
+
160
+ _get_issue_number_from_mapping() {
161
+ local beads_id="${1:-}"
162
+ local mapping_file
163
+ mapping_file="$(_sync_mapping_file)"
164
+
165
+ node - "$mapping_file" "$beads_id" <<'NODE'
166
+ const fs = require('node:fs');
167
+
168
+ const [mappingPath, beadsId] = process.argv.slice(2);
169
+ if (!fs.existsSync(mappingPath)) {
170
+ process.exit(0);
171
+ }
172
+
173
+ const raw = fs.readFileSync(mappingPath, 'utf8').trim();
174
+ if (!raw) {
175
+ process.exit(0);
176
+ }
177
+
178
+ const data = JSON.parse(raw);
179
+ let newestIssueNum = null;
180
+ for (const [issueNum, mappedBeadsId] of Object.entries(data)) {
181
+ if (mappedBeadsId !== beadsId) {
182
+ continue;
183
+ }
184
+ const parsedIssueNum = Number.parseInt(issueNum, 10);
185
+ if (!Number.isFinite(parsedIssueNum)) {
186
+ continue;
187
+ }
188
+ if (newestIssueNum == null || parsedIssueNum > newestIssueNum) {
189
+ newestIssueNum = parsedIssueNum;
190
+ }
191
+ }
192
+ if (newestIssueNum != null) {
193
+ process.stdout.write(String(newestIssueNum));
194
+ }
195
+ NODE
196
+ }
197
+
74
198
  # ── _get_github_issue_number ─────────────────────────────────────────────
75
- # Extracts github_issue number from `bd show <id>` output.
76
- # Looks for `github_issue:N` pattern.
199
+ # Extracts the canonical GitHub issue number from `bd show <id> --json`.
77
200
  # Returns the number or empty string (exit 1 if not found).
78
201
  _get_github_issue_number() {
79
202
  local beads_id="${1:-}"
@@ -84,16 +207,37 @@ _get_github_issue_number() {
84
207
 
85
208
  local bd_cmd="${BD_CMD:-bd}"
86
209
  local show_output
87
- show_output="$("$bd_cmd" show "$beads_id" 2>/dev/null)" || {
210
+ show_output="$("$bd_cmd" show "$beads_id" --json 2>/dev/null)" || {
88
211
  _sync_error "Failed to get beads info for $beads_id"
89
212
  return 1
90
213
  }
91
214
 
92
215
  local issue_num
93
- issue_num="$(printf '%s' "$show_output" | grep -oP 'github_issue:\K[0-9]+' | head -1)"
216
+ issue_num="$(printf '%s' "$show_output" | node -e '
217
+ const fs = require("node:fs");
218
+ const input = fs.readFileSync(0, "utf8");
219
+ try {
220
+ const data = JSON.parse(input);
221
+ const issue = Array.isArray(data) ? data[0] : data;
222
+ const value = issue?.github?.number
223
+ ?? issue?.githubNumber
224
+ ?? issue?.issueNumber
225
+ ?? issue?.github_issue
226
+ ?? null;
227
+ if (value != null && value !== "") {
228
+ process.stdout.write(String(value));
229
+ }
230
+ } catch (_err) {
231
+ process.exitCode = 0;
232
+ }
233
+ ')"
94
234
 
95
235
  if [[ -z "$issue_num" ]]; then
96
- _sync_error "No github_issue found for $beads_id"
236
+ issue_num="$(_get_issue_number_from_mapping "$beads_id" 2>/dev/null || true)"
237
+ fi
238
+
239
+ if [[ -z "$issue_num" ]]; then
240
+ _sync_error "No canonical GitHub number found for $beads_id"
97
241
  return 1
98
242
  fi
99
243
 
@@ -111,13 +255,7 @@ _get_issue_title() {
111
255
  show_output="$("$bd_cmd" show "$beads_id" 2>/dev/null)" || return 1
112
256
 
113
257
  local title
114
- # Try "Title: ..." line first
115
- title="$(printf '%s' "$show_output" | grep -oP '^Title:\s*\K.*' | head -1)"
116
-
117
- if [[ -z "$title" ]]; then
118
- # Fallback: extract from summary line "○ <id> · <title> [...]"
119
- title="$(printf '%s' "$show_output" | grep -oP '·\s*\K[^\[]+' | head -1 | sed 's/[[:space:]]*$//')"
120
- fi
258
+ title="$(_extract_issue_title_from_show "$show_output")"
121
259
 
122
260
  if [[ -z "$title" ]]; then
123
261
  title="$beads_id"
@@ -130,7 +268,8 @@ _get_issue_title() {
130
268
 
131
269
  # sync_issue_create <beads-id>
132
270
  # Creates GitHub issue from Beads issue data.
133
- # Stores GitHub issue number back via `bd set-state`.
271
+ # Persists both the canonical mapping file and the legacy github_issue state
272
+ # until downstream hook and verify flows stop reading bd show output.
134
273
  # Returns 0 on success, 1 on failure.
135
274
  sync_issue_create() {
136
275
  local beads_id="${1:-}"
@@ -162,28 +301,21 @@ sync_issue_create() {
162
301
 
163
302
  # Extract issue number from URL (e.g., https://github.com/.../issues/42)
164
303
  local issue_num
165
- issue_num="$(printf '%s' "$gh_output" | grep -oP '/issues/\K[0-9]+' | head -1)"
304
+ issue_num="$(_extract_issue_number_from_url "$gh_output")"
166
305
 
167
306
  if [[ -z "$issue_num" ]]; then
168
307
  _sync_error "Could not extract issue number from: $gh_output"
169
308
  return 1
170
309
  fi
171
310
 
172
- # Store issue number in Beads
173
- "$bd_cmd" set-state "$beads_id" "github_issue=$issue_num" >/dev/null 2>&1 || {
174
- _sync_error "Failed to store github_issue=$issue_num for $beads_id"
311
+ if ! _persist_issue_mapping "$beads_id" "$issue_num"; then
312
+ _sync_error "Failed to persist GitHub link for $beads_id"
175
313
  return 1
176
- }
314
+ fi
177
315
 
178
- # Update beads-mapping.json for verify Check 4
179
- local mapping_file=".github/beads-mapping.json"
180
- if [[ -f "$mapping_file" ]]; then
181
- local updated
182
- updated="$(jq --arg num "$issue_num" --arg id "$beads_id" '.[$num] = $id' "$mapping_file")"
183
- printf '%s\n' "$updated" > "$mapping_file"
184
- else
185
- mkdir -p .github
186
- printf '%s\n' "{\"$issue_num\":\"$beads_id\"}" > "$mapping_file"
316
+ if ! "$bd_cmd" set-state "$beads_id" "github_issue=$issue_num" >/dev/null 2>&1; then
317
+ _sync_error "Failed to store github_issue=$issue_num for $beads_id"
318
+ return 1
187
319
  fi
188
320
 
189
321
  return 0
@@ -75,7 +75,7 @@ _extract_beads_ids() {
75
75
  if [[ -z "$input" ]]; then
76
76
  return 0
77
77
  fi
78
- printf '%s\n' "$input" | grep -oP '(forge-[a-z0-9]+)' || true
78
+ printf '%s\n' "$input" | grep -oP '(forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)' || true
79
79
  }
80
80
 
81
81
  # ── Public API ───────────────────────────────────────────────────────────