forge-workflow 0.0.8 → 0.0.9

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 (57) hide show
  1. package/.claude/commands/premerge.md +2 -2
  2. package/.claude/commands/review.md +5 -2
  3. package/.claude/commands/ship.md +4 -3
  4. package/.claude/rules/greptile-review-process.md +4 -4
  5. package/.cline/workflows/premerge.md +2 -2
  6. package/.cline/workflows/review.md +5 -2
  7. package/.cline/workflows/ship.md +4 -3
  8. package/.codex/skills/premerge/SKILL.md +2 -2
  9. package/.codex/skills/review/SKILL.md +5 -2
  10. package/.codex/skills/ship/SKILL.md +4 -3
  11. package/.cursor/commands/premerge.md +2 -2
  12. package/.cursor/commands/review.md +5 -2
  13. package/.cursor/commands/ship.md +4 -3
  14. package/.github/prompts/premerge.prompt.md +2 -2
  15. package/.github/prompts/review.prompt.md +5 -2
  16. package/.github/prompts/ship.prompt.md +4 -3
  17. package/.github/workflows/beads-to-github.yml +1 -1
  18. package/.github/workflows/github-to-beads.yml +1 -1
  19. package/.kilocode/workflows/premerge.md +2 -2
  20. package/.kilocode/workflows/review.md +5 -2
  21. package/.kilocode/workflows/ship.md +4 -3
  22. package/.opencode/commands/premerge.md +2 -2
  23. package/.opencode/commands/review.md +5 -2
  24. package/.opencode/commands/ship.md +4 -3
  25. package/.roo/commands/premerge.md +2 -2
  26. package/.roo/commands/review.md +5 -2
  27. package/.roo/commands/ship.md +4 -3
  28. package/AGENTS.md +9 -9
  29. package/README.md +12 -6
  30. package/bin/forge.js +14 -3
  31. package/docs/BEADS_GITHUB_SYNC.md +6 -2
  32. package/docs/EXAMPLES.md +22 -22
  33. package/docs/ROADMAP.md +3 -3
  34. package/docs/TOOLCHAIN.md +60 -52
  35. package/lib/agents/codex.plugin.json +3 -0
  36. package/lib/agents-config.js +18 -12
  37. package/lib/codex-skills.js +54 -1
  38. package/lib/commands/plan.js +5 -2
  39. package/lib/commands/setup.js +231 -17
  40. package/lib/commands/ship.js +188 -5
  41. package/lib/commands/status.js +20 -33
  42. package/lib/commands/test.js +90 -25
  43. package/lib/commands/validate.js +218 -1
  44. package/lib/setup-action-log.js +2 -0
  45. package/lib/setup-summary-renderer.js +15 -11
  46. package/lib/workflow/enforce-stage.js +12 -8
  47. package/lib/workflow/state-manager.js +193 -0
  48. package/package.json +1 -1
  49. package/scripts/dep-guard.sh +11 -1
  50. package/scripts/forge-team/lib/hooks.sh +1 -1
  51. package/scripts/forge-team/lib/verify.sh +1 -1
  52. package/scripts/forge-team/lib/workload.sh +56 -27
  53. package/scripts/forge-team/tests/workload.test.sh +35 -4
  54. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  55. package/scripts/smart-status.sh +10 -1
  56. package/scripts/sync-utils.sh +39 -0
  57. package/scripts/test.js +144 -38
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { secureExecFileSync } = require('../shell-utils.js');
7
+ const { readWorkflowState, serializeWorkflowState, WORKFLOW_STATE_SCHEMA_VERSION, getAllowedTransitionsForWorkflowState } = require('./state.js');
8
+ const { getWorkflowPath, WORKFLOW_CLASSIFICATIONS, normalizeStageId } = require('./stages.js');
9
+
10
+ const WORKFLOW_STATE_FILENAME = '.forge-state.json';
11
+
12
+ function extractWorkflowStateFromComments(comments = '') {
13
+ const matches = String(comments).match(/^WorkflowState:\s*(\{.*\})$/gm);
14
+ if (!matches || matches.length === 0) {
15
+ return null;
16
+ }
17
+
18
+ const latest = matches.at(-1).replace(/^WorkflowState:\s*/, '');
19
+ return readWorkflowState(latest);
20
+ }
21
+
22
+ function readWorkflowStateFromBeads(issueId, options = {}) {
23
+ if (!issueId) {
24
+ return null;
25
+ }
26
+
27
+ const comments = options.comments || secureExecFileSync('bd', ['comments', 'list', issueId], {
28
+ encoding: 'utf8',
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+
32
+ if (!comments) {
33
+ return null;
34
+ }
35
+
36
+ return extractWorkflowStateFromComments(comments);
37
+ }
38
+
39
+ function loadStateFromBeads(options) {
40
+ if (options.comments) {
41
+ const state = extractWorkflowStateFromComments(options.comments);
42
+ if (state) {
43
+ return { state, source: 'beads' };
44
+ }
45
+ }
46
+
47
+ if (options.issueId) {
48
+ const state = readWorkflowStateFromBeads(options.issueId, { comments: options.comments });
49
+ if (state) {
50
+ return { state, source: 'beads' };
51
+ }
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ function loadState(projectRoot, options = {}) {
58
+ if (!projectRoot) {
59
+ const beadsResult = loadStateFromBeads(options);
60
+ return beadsResult || { state: null, source: null };
61
+ }
62
+
63
+ const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
64
+ if (fs.existsSync(statePath)) {
65
+ try {
66
+ const raw = fs.readFileSync(statePath, 'utf8');
67
+ return { state: readWorkflowState(raw), source: 'file' };
68
+ } catch (_parseError) {
69
+ // File is malformed — fall through to Beads fallback
70
+ }
71
+ }
72
+
73
+ const beadsResult = loadStateFromBeads(options);
74
+ if (beadsResult) {
75
+ return beadsResult;
76
+ }
77
+
78
+ return { state: null, source: null };
79
+ }
80
+
81
+ function saveState(projectRoot, state) {
82
+ if (!projectRoot || typeof projectRoot !== 'string') {
83
+ throw new Error('saveState requires a valid projectRoot path');
84
+ }
85
+
86
+ const normalized = serializeWorkflowState(state);
87
+ const json = JSON.stringify(normalized, null, 2);
88
+ const tmpPath = path.join(projectRoot, `${WORKFLOW_STATE_FILENAME}.tmp`);
89
+ const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
90
+
91
+ fs.writeFileSync(tmpPath, json, 'utf8');
92
+ fs.renameSync(tmpPath, statePath);
93
+
94
+ return normalized;
95
+ }
96
+
97
+ function initializeState(projectRoot, classification, firstStage) {
98
+ if (!WORKFLOW_CLASSIFICATIONS.includes(classification)) {
99
+ throw new Error(`Invalid classification: ${classification}. Expected one of: ${WORKFLOW_CLASSIFICATIONS.join(', ')}`);
100
+ }
101
+
102
+ const workflowPath = getWorkflowPath(classification);
103
+ const currentStage = firstStage || workflowPath[0];
104
+
105
+ const state = {
106
+ schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
107
+ currentStage,
108
+ completedStages: [],
109
+ skippedStages: [],
110
+ workflowDecisions: {
111
+ classification,
112
+ reason: 'initialized',
113
+ userOverride: false,
114
+ overrides: [],
115
+ },
116
+ parallelTracks: [],
117
+ };
118
+
119
+ return saveState(projectRoot, state);
120
+ }
121
+
122
+ function transitionStage(projectRoot, toStage, options = {}) {
123
+ const targetStage = normalizeStageId(toStage);
124
+ if (!targetStage) {
125
+ throw new Error(`Invalid target stage: ${toStage}`);
126
+ }
127
+
128
+ const { state: currentState } = loadState(projectRoot, options);
129
+ if (!currentState) {
130
+ throw new Error('No workflow state found. Initialize state first with initializeState().');
131
+ }
132
+
133
+ const previousState = JSON.parse(JSON.stringify(currentState));
134
+ const allowed = getAllowedTransitionsForWorkflowState(currentState);
135
+
136
+ if (!allowed.includes(targetStage)) {
137
+ if (!options.override) {
138
+ throw new Error(
139
+ `Transition from ${currentState.currentStage} to ${targetStage} is not allowed. ` +
140
+ `Allowed transitions: ${allowed.join(', ') || 'none'}. Provide an override to force.`
141
+ );
142
+ }
143
+ }
144
+
145
+ const completedStages = [...currentState.completedStages];
146
+ if (!completedStages.includes(currentState.currentStage)) {
147
+ completedStages.push(currentState.currentStage);
148
+ }
149
+
150
+ const overrides = [...(currentState.workflowDecisions.overrides || [])];
151
+ if (options.override) {
152
+ overrides.push({
153
+ type: options.override.type || 'manual',
154
+ fromStage: currentState.currentStage,
155
+ toStage: targetStage,
156
+ reason: options.override.reason || '',
157
+ actor: options.override.actor || 'unknown',
158
+ userOverride: true,
159
+ recordedAt: new Date().toISOString(),
160
+ });
161
+ }
162
+
163
+ const newStateInput = {
164
+ schemaVersion: currentState.schemaVersion || WORKFLOW_STATE_SCHEMA_VERSION,
165
+ currentStage: targetStage,
166
+ completedStages,
167
+ skippedStages: currentState.skippedStages || [],
168
+ workflowDecisions: {
169
+ ...currentState.workflowDecisions,
170
+ overrides,
171
+ userOverride: overrides.length > 0,
172
+ },
173
+ parallelTracks: currentState.parallelTracks || [],
174
+ };
175
+
176
+ const newState = saveState(projectRoot, newStateInput);
177
+
178
+ return {
179
+ previousState,
180
+ newState,
181
+ transitioned: true,
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ WORKFLOW_STATE_FILENAME,
187
+ extractWorkflowStateFromComments,
188
+ initializeState,
189
+ loadState,
190
+ readWorkflowStateFromBeads,
191
+ saveState,
192
+ transitionStage,
193
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "7-stage TDD workflow for ALL AI coding agents (Claude, Cursor, Cline, OpenCode, Copilot, Kilo Code, Roo Code, Codex)",
5
5
  "bin": {
6
6
  "forge": "bin/forge.js",
@@ -426,7 +426,7 @@ cmd_check_ripple_keyword_v1() {
426
426
 
427
427
  # Extract issue ID (forge-xxx pattern)
428
428
  local cand_id=""
429
- cand_id="$(printf '%s' "$line" | grep -oE 'forge-[a-z0-9]+' | head -1)" || continue
429
+ cand_id="$(printf '%s' "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)" || continue
430
430
  [[ -z "$cand_id" ]] && continue
431
431
 
432
432
  # Skip the source issue itself
@@ -561,6 +561,16 @@ cmd_check_ripple() {
561
561
 
562
562
  local tmp_dir
563
563
  tmp_dir="$(mktemp -d)"
564
+ # On Windows/Git Bash, normalize to a mixed-mode path so both bash file I/O
565
+ # and Node.js resolve to the same physical location (cygpath -m → C:/...).
566
+ # Preserve the original POSIX path if conversion fails or returns empty.
567
+ if command -v cygpath &>/dev/null; then
568
+ local mixed_tmp_dir
569
+ mixed_tmp_dir="$(cygpath -m "$tmp_dir" 2>/dev/null)" || true
570
+ if [[ -n "$mixed_tmp_dir" && -d "$mixed_tmp_dir" ]]; then
571
+ tmp_dir="$mixed_tmp_dir"
572
+ fi
573
+ fi
564
574
  trap 'rm -rf "$tmp_dir"' RETURN
565
575
 
566
576
  printf '%s' "$src_json" > "${tmp_dir}/current.json"
@@ -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
@@ -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 ───────────────────────────────────────────────────────────
@@ -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
@@ -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"
@@ -79,10 +79,12 @@ export function buildSearchArgs(query) {
79
79
  * @returns {string|null}
80
80
  */
81
81
  export function parseCreateOutput(stdout) {
82
- const match = stdout.match(/Created issue:\s+(forge-[\w-]+)/);
82
+ // Match forge-xxx with optional dotted sub-IDs (e.g. forge-m1n8.6, forge-dq8j.1).
83
+ // \w in JS doesn't include '.', so the dotted sub-ID group is explicit.
84
+ const match = stdout.match(/Created issue:\s+(forge-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)/);
83
85
  if (match) return match[1];
84
86
  // Fallback: loose match for any forge-prefixed ID anywhere in output
85
- const fallback = stdout.match(/(forge-[a-z0-9]+)/i);
87
+ const fallback = stdout.match(/(forge-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)/);
86
88
  return fallback ? fallback[1] : null;
87
89
  }
88
90
 
@@ -68,7 +68,9 @@ if ! command -v jq &>/dev/null; then
68
68
  fi
69
69
 
70
70
  # Warn if jq < 1.6 (fromdateiso8601 and sub/2 require 1.6+)
71
- _jq_version="$(jq --version 2>/dev/null | sed 's/jq-//')" || true
71
+ # Strip CR here as well because Windows jq.exe (or a CRLF-emitting wrapper in
72
+ # tests) can affect the version probe before the jq() shim is defined below.
73
+ _jq_version="$(command jq --version 2>/dev/null | tr -d '\r' | sed 's/jq-//')" || true
72
74
  if [ -n "$_jq_version" ]; then
73
75
  _jq_major="${_jq_version%%.*}"
74
76
  _jq_minor="${_jq_version#*.}"; _jq_minor="${_jq_minor%%.*}"
@@ -77,6 +79,13 @@ if [ -n "$_jq_version" ]; then
77
79
  fi
78
80
  fi
79
81
 
82
+ # On Windows/WSL we may be invoking jq.exe, which emits CRLF and breaks
83
+ # numeric comparisons like `[ "$count" -gt 0 ]`. Strip trailing CRs from
84
+ # all jq output after the dependency/version checks above.
85
+ jq() {
86
+ command jq "$@" | tr -d '\r'
87
+ }
88
+
80
89
  # ── Configuration ───────────────────────────────────────────────────────
81
90
 
82
91
  BD="${BD_CMD:-bd}"
@@ -271,16 +271,55 @@ _run_sync() {
271
271
  fi
272
272
  }
273
273
 
274
+ _has_dolt_remote() {
275
+ local remote_name="${1:-origin}"
276
+ local remote_list=""
277
+ local raw_output=""
278
+ local bd_cmd="${BD_CMD:-bd}"
279
+ local remote_status=0
280
+
281
+ if ! command -v "$bd_cmd" >/dev/null 2>&1; then
282
+ return 2
283
+ fi
284
+
285
+ raw_output="$("$bd_cmd" dolt remote list 2>/dev/null)"
286
+ remote_status=$?
287
+ if [[ "$remote_status" -ne 0 ]]; then
288
+ return 2
289
+ fi
290
+ remote_list="$(printf '%s' "$raw_output" | tr -d '\r')"
291
+
292
+ if [[ -z "$remote_list" ]]; then
293
+ return 1
294
+ fi
295
+
296
+ printf '%s\n' "$remote_list" | awk 'NF { print $1 }' | grep -Fqx -- "$remote_name"
297
+ }
298
+
274
299
  # Environment overrides (for testing):
275
300
  # BD_SYNC_CMD — single command to run instead of default pull+push (e.g. "echo mock-sync")
276
301
  # FILE_INDEX_ROOT — root directory for file-index.sh (default: repo_dir)
277
302
  auto_sync() {
278
303
  local repo_dir="${1:-.}"
279
304
  local last_sync_file="$repo_dir/.beads/.last-sync"
305
+ local sync_remote
306
+ sync_remote="$(get_sync_remote "$repo_dir")"
280
307
 
281
308
  # Ensure .beads directory exists
282
309
  mkdir -p "$repo_dir/.beads"
283
310
 
311
+ if _has_dolt_remote "$sync_remote"; then
312
+ :
313
+ else
314
+ local remote_status=$?
315
+ if [[ "$remote_status" -eq 2 ]]; then
316
+ echo "Warning: sync skipped, unable to inspect Beads Dolt remotes (is 'bd' installed and configured?)." >&2
317
+ else
318
+ echo "Warning: sync skipped, Beads Dolt remote '$sync_remote' is not configured (run 'bd dolt remote add $sync_remote <url>')." >&2
319
+ fi
320
+ return 0
321
+ fi
322
+
284
323
  # Run sync — helper function avoids eval while supporting compound default
285
324
  if _run_sync >/dev/null 2>&1; then
286
325
  # Success: record timestamp