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.
- package/.claude/commands/premerge.md +2 -2
- package/.claude/commands/review.md +5 -2
- package/.claude/commands/ship.md +4 -3
- package/.claude/rules/greptile-review-process.md +4 -4
- package/.cline/workflows/premerge.md +2 -2
- package/.cline/workflows/review.md +5 -2
- package/.cline/workflows/ship.md +4 -3
- package/.codex/skills/premerge/SKILL.md +2 -2
- package/.codex/skills/review/SKILL.md +5 -2
- package/.codex/skills/ship/SKILL.md +4 -3
- package/.cursor/commands/premerge.md +2 -2
- package/.cursor/commands/review.md +5 -2
- package/.cursor/commands/ship.md +4 -3
- package/.github/prompts/premerge.prompt.md +2 -2
- package/.github/prompts/review.prompt.md +5 -2
- package/.github/prompts/ship.prompt.md +4 -3
- package/.github/workflows/beads-to-github.yml +1 -1
- package/.github/workflows/github-to-beads.yml +1 -1
- package/.kilocode/workflows/premerge.md +2 -2
- package/.kilocode/workflows/review.md +5 -2
- package/.kilocode/workflows/ship.md +4 -3
- package/.opencode/commands/premerge.md +2 -2
- package/.opencode/commands/review.md +5 -2
- package/.opencode/commands/ship.md +4 -3
- package/.roo/commands/premerge.md +2 -2
- package/.roo/commands/review.md +5 -2
- package/.roo/commands/ship.md +4 -3
- package/AGENTS.md +9 -9
- package/README.md +12 -6
- package/bin/forge.js +14 -3
- package/docs/BEADS_GITHUB_SYNC.md +6 -2
- package/docs/EXAMPLES.md +22 -22
- package/docs/ROADMAP.md +3 -3
- package/docs/TOOLCHAIN.md +60 -52
- package/lib/agents/codex.plugin.json +3 -0
- package/lib/agents-config.js +18 -12
- package/lib/codex-skills.js +54 -1
- package/lib/commands/plan.js +5 -2
- package/lib/commands/setup.js +231 -17
- package/lib/commands/ship.js +188 -5
- package/lib/commands/status.js +20 -33
- package/lib/commands/test.js +90 -25
- package/lib/commands/validate.js +218 -1
- package/lib/setup-action-log.js +2 -0
- package/lib/setup-summary-renderer.js +15 -11
- package/lib/workflow/enforce-stage.js +12 -8
- package/lib/workflow/state-manager.js +193 -0
- package/package.json +1 -1
- package/scripts/dep-guard.sh +11 -1
- package/scripts/forge-team/lib/hooks.sh +1 -1
- package/scripts/forge-team/lib/verify.sh +1 -1
- package/scripts/forge-team/lib/workload.sh +56 -27
- package/scripts/forge-team/tests/workload.test.sh +35 -4
- package/scripts/github-beads-sync/run-bd.mjs +4 -2
- package/scripts/smart-status.sh +10 -1
- package/scripts/sync-utils.sh +39 -0
- 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
package/scripts/dep-guard.sh
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
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 "$(
|
|
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
|
-
|
|
167
|
+
local arg="${1%$'\r'}"
|
|
168
|
+
case "$arg" in
|
|
145
169
|
--developer=*)
|
|
146
|
-
filter_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: ${
|
|
187
|
+
_workload_error "Unsupported format: ${arg#--format=}"
|
|
164
188
|
return 1
|
|
165
189
|
;;
|
|
166
190
|
*)
|
|
167
|
-
_workload_error "Unknown argument: $
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
264
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
first=false
|
|
289
|
+
local first_issue="true"
|
|
290
|
+
if [[ "$first_dev" == "true" ]]; then
|
|
291
|
+
first_dev="false"
|
|
272
292
|
else
|
|
273
|
-
|
|
293
|
+
printf ','
|
|
274
294
|
fi
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" |
|
|
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" |
|
|
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" |
|
|
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
|
-
|
|
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-
|
|
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
|
|
package/scripts/smart-status.sh
CHANGED
|
@@ -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
|
-
|
|
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}"
|
package/scripts/sync-utils.sh
CHANGED
|
@@ -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
|