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
@@ -10,29 +10,18 @@ import { fileURLToPath } from 'node:url';
10
10
  import { loadConfig } from './config.mjs';
11
11
  import { sanitizeTitle } from './sanitize.mjs';
12
12
  import { mapLabels } from './label-mapper.mjs';
13
+ import { resolveCanonicalBeadsLink, upsertCanonicalBeadsLink } from './mapping.mjs';
13
14
  import { bdCreate as realBdCreate, bdClose as realBdClose, bdShow as realBdShow } from './run-bd.mjs';
14
- import { getBeadsId as realGetBeadsId, setBeadsId as realSetBeadsId } from './mapping.mjs';
15
15
  import { buildComment, parseComment } from './comment.mjs';
16
- import {
17
- findSyncComment as realFindSyncComment,
18
- createOrEditComment as realCreateOrEditComment,
19
- } from './github-api.mjs';
16
+ import { createOrEditComment as realCreateOrEditComment, findSyncComment as realFindSyncComment } from './github-api.mjs';
17
+
18
+ const DEFAULT_MAPPING_PATH = '.github/beads-mapping.json';
20
19
 
21
- /**
22
- * Check if a sender login matches known bot patterns.
23
- * @param {string} login - GitHub sender login
24
- * @returns {boolean}
25
- */
26
20
  function isBot(login) {
27
21
  if (!login) return false;
28
22
  return login.includes('[bot]') || login === 'github-actions';
29
23
  }
30
24
 
31
- /**
32
- * Check if an issue has the skip-beads-sync label.
33
- * @param {Array<{name: string}|string>} labels
34
- * @returns {boolean}
35
- */
36
25
  function hasSkipLabel(labels) {
37
26
  if (!labels) return false;
38
27
  return labels.some((l) => {
@@ -41,6 +30,39 @@ function hasSkipLabel(labels) {
41
30
  });
42
31
  }
43
32
 
33
+ function getGitHubLink(issue = {}) {
34
+ return {
35
+ nodeId: issue.node_id ?? issue.nodeId ?? null,
36
+ number: issue.number ?? null,
37
+ url: issue.html_url ?? null,
38
+ };
39
+ }
40
+
41
+ function createCanonicalLinkStore(mappingPath) {
42
+ return {
43
+ resolveCanonicalLink(lookup) {
44
+ return resolveCanonicalBeadsLink(mappingPath, lookup);
45
+ },
46
+ upsertCanonicalLink(link) {
47
+ return upsertCanonicalBeadsLink(mappingPath, link);
48
+ },
49
+ };
50
+ }
51
+
52
+ function getCanonicalLinkStore(options = {}) {
53
+ const defaultStore = createCanonicalLinkStore(options.mappingPath ?? DEFAULT_MAPPING_PATH);
54
+
55
+ if (
56
+ options.linkStore &&
57
+ typeof options.linkStore.resolveCanonicalLink === 'function' &&
58
+ typeof options.linkStore.upsertCanonicalLink === 'function'
59
+ ) {
60
+ return options.linkStore;
61
+ }
62
+
63
+ return defaultStore;
64
+ }
65
+
44
66
  /**
45
67
  * Handle a GitHub issue "opened" event.
46
68
  *
@@ -52,35 +74,30 @@ function hasSkipLabel(labels) {
52
74
  * @param {string} options.repo - Repository name
53
75
  * @param {object} [options.bd] - Dependency injection for bd functions
54
76
  * @param {object} [options.github] - Dependency injection for github-api functions
55
- * @param {object} [options.mapping] - Dependency injection for mapping functions
77
+ * @param {object} [options.linkStore] - Dependency injection for canonical link store
56
78
  * @param {object} [options.configOverride] - Merge into loaded config (for testing)
57
79
  * @returns {object} Result object
58
80
  */
59
81
  export function handleOpened(event, options = {}) {
60
82
  const {
61
83
  configPath,
62
- mappingPath,
63
84
  owner,
64
85
  repo,
65
86
  bd = {},
66
87
  github = {},
67
- mapping = {},
68
88
  configOverride,
69
89
  } = options;
70
90
 
71
91
  const bdCreate = bd.bdCreate ?? realBdCreate;
72
- const findSyncComment = github.findSyncComment ?? realFindSyncComment;
73
92
  const createOrEditComment = github.createOrEditComment ?? realCreateOrEditComment;
74
- const getBeadsId = mapping.getBeadsId ?? realGetBeadsId;
75
- const setBeadsId = mapping.setBeadsId ?? realSetBeadsId;
93
+ const findSyncComment = github.findSyncComment ?? realFindSyncComment;
94
+ const canonicalLinkStore = getCanonicalLinkStore(options);
76
95
 
77
- // 1. Load config
78
96
  let config = loadConfig(configPath);
79
97
  if (configOverride) {
80
98
  config = { ...config, ...configOverride };
81
99
  }
82
100
 
83
- // 2. Extract event data
84
101
  const issue = event.issue;
85
102
  const issueNumber = issue.number;
86
103
  const rawTitle = issue.title;
@@ -89,23 +106,22 @@ export function handleOpened(event, options = {}) {
89
106
  const htmlUrl = issue.html_url;
90
107
  const body = issue.body ?? '';
91
108
  const authorAssociation = issue.author_association;
109
+ const rawLabelNames = labels.map((l) => (typeof l === 'string' ? l : l.name));
110
+ const { type, priority } = mapLabels(rawLabelNames, config);
111
+ const externalRef = `gh-${issueNumber}`;
92
112
 
93
- // 3. Guard: bot actor
94
113
  if (isBot(event.sender?.login)) {
95
114
  return { skipped: true, reason: 'bot actor' };
96
115
  }
97
116
 
98
- // 4. Guard: skip label
99
117
  if (hasSkipLabel(labels)) {
100
118
  return { skipped: true, reason: 'skip label' };
101
119
  }
102
120
 
103
- // 5. Guard: no-beads body
104
121
  if (body.includes('no-beads')) {
105
122
  return { skipped: true, reason: 'no-beads in body' };
106
123
  }
107
124
 
108
- // 6. Guard: public repo gate
109
125
  if (config.publicRepoGate === 'author_association') {
110
126
  const allowed = config.gateAssociations || [];
111
127
  if (!allowed.includes(authorAssociation)) {
@@ -122,48 +138,54 @@ export function handleOpened(event, options = {}) {
122
138
  }
123
139
  }
124
140
 
125
- // 7. Idempotency — check mapping file first (fast, survives comment deletion)
126
- const existingBeadsId = getBeadsId(mappingPath, issueNumber);
127
- if (existingBeadsId) {
128
- // Mapping exists — ensure the GitHub comment is present (repair if deleted)
129
- const existingComment = findSyncComment(owner, repo, issueNumber);
130
- if (!existingComment) {
131
- const externalRef = `gh-${issueNumber}`;
132
- const commentBody = buildComment(existingBeadsId, issueNumber, { externalRef });
133
- createOrEditComment(owner, repo, issueNumber, commentBody);
134
- }
141
+ const canonicalLink = canonicalLinkStore.resolveCanonicalLink({
142
+ githubNodeId: issue.node_id ?? issue.nodeId ?? null,
143
+ githubNumber: issue.number,
144
+ });
145
+ if (canonicalLink) {
146
+ const commentBody = buildComment(canonicalLink.forgeIssueId, issueNumber, {
147
+ type,
148
+ priority,
149
+ externalRef,
150
+ });
151
+ createOrEditComment(owner, repo, issueNumber, commentBody);
135
152
  return {
136
153
  skipped: true,
137
- reason: 'already synced (mapping file)',
138
- beadsId: existingBeadsId,
154
+ reason: 'already synced (canonical link)',
155
+ beadsId: canonicalLink.forgeIssueId,
139
156
  };
140
157
  }
141
158
 
142
- // 7b. Fallback idempotency check for existing sync comment
143
- const existingComment = findSyncComment(owner, repo, issueNumber);
144
- if (existingComment) {
145
- const parsed = parseComment(existingComment.body);
146
- // Repair mapping file from comment
147
- if (parsed?.beadsId) {
148
- setBeadsId(mappingPath, issueNumber, parsed.beadsId);
149
- }
159
+ const existingSyncComment = parseComment(findSyncComment(owner, repo, issueNumber)?.body);
160
+ if (existingSyncComment?.beadsId) {
161
+ canonicalLinkStore.upsertCanonicalLink({
162
+ forgeIssueId: existingSyncComment.beadsId,
163
+ github: getGitHubLink(issue),
164
+ sources: [
165
+ {
166
+ source: 'syncComment',
167
+ forgeIssueId: existingSyncComment.beadsId,
168
+ githubNumber: issueNumber,
169
+ url: htmlUrl,
170
+ },
171
+ ],
172
+ diagnostics: [],
173
+ });
174
+ const commentBody = buildComment(existingSyncComment.beadsId, issueNumber, {
175
+ type,
176
+ priority,
177
+ externalRef,
178
+ });
179
+ createOrEditComment(owner, repo, issueNumber, commentBody);
150
180
  return {
151
181
  skipped: true,
152
- reason: 'already synced (comment)',
153
- beadsId: parsed?.beadsId ?? null,
182
+ reason: 'already synced (sync comment)',
183
+ beadsId: existingSyncComment.beadsId,
154
184
  };
155
185
  }
156
186
 
157
- // 8. Sanitize title (labels mapped from raw names — sanitization only needed for CLI args)
158
187
  const { sanitized: sanitizedTitle, warnings: titleWarnings } = sanitizeTitle(rawTitle);
159
188
  if (titleWarnings.length) console.warn('sanitize:', titleWarnings);
160
-
161
- // 9. Map labels using raw names (case-insensitive matching in mapLabels handles normalization)
162
- const rawLabelNames = labels.map((l) => (typeof l === 'string' ? l : l.name));
163
- const { type, priority } = mapLabels(rawLabelNames, config);
164
-
165
- // 10. Create beads issue
166
- const externalRef = `gh-${issueNumber}`;
167
189
  const beadsId = bdCreate({
168
190
  title: sanitizedTitle,
169
191
  type,
@@ -174,17 +196,26 @@ export function handleOpened(event, options = {}) {
174
196
  });
175
197
 
176
198
  if (!beadsId) {
177
- return { success: false, reason: 'bd create failed no beads ID returned', issueNumber };
199
+ return { success: false, reason: 'bd create failed - no beads ID returned', issueNumber };
178
200
  }
179
201
 
180
- // 11. Update mapping
181
- setBeadsId(mappingPath, issueNumber, beadsId);
202
+ canonicalLinkStore.upsertCanonicalLink({
203
+ forgeIssueId: beadsId,
204
+ github: getGitHubLink(issue),
205
+ sources: [
206
+ {
207
+ source: 'externalRef',
208
+ forgeIssueId: beadsId,
209
+ githubNumber: issueNumber,
210
+ url: htmlUrl,
211
+ },
212
+ ],
213
+ diagnostics: [],
214
+ });
182
215
 
183
- // 12. Build and post comment
184
216
  const commentBody = buildComment(beadsId, issueNumber, { type, priority, externalRef });
185
217
  createOrEditComment(owner, repo, issueNumber, commentBody);
186
218
 
187
- // 13. Return success
188
219
  return { success: true, beadsId, issueNumber };
189
220
  }
190
221
 
@@ -198,65 +229,66 @@ export function handleOpened(event, options = {}) {
198
229
  * @param {string} options.repo - Repository name
199
230
  * @param {object} [options.bd] - Dependency injection for bd functions
200
231
  * @param {object} [options.github] - Dependency injection for github-api functions
201
- * @param {object} [options.mapping] - Dependency injection for mapping functions
232
+ * @param {object} [options.linkStore] - Dependency injection for canonical link store
202
233
  * @returns {object} Result object
203
234
  */
204
235
  export function handleClosed(event, options = {}) {
205
236
  const {
206
- mappingPath,
207
- owner,
208
- repo,
209
237
  bd = {},
210
- github = {},
211
- mapping = {},
212
238
  } = options;
213
239
 
214
240
  const bdClose = bd.bdClose ?? realBdClose;
215
241
  const bdShow = bd.bdShow ?? realBdShow;
216
- const getBeadsId = mapping.getBeadsId ?? realGetBeadsId;
217
- const findSyncComment = github.findSyncComment ?? realFindSyncComment;
242
+ const findSyncComment = options.github?.findSyncComment ?? realFindSyncComment;
243
+ const canonicalLinkStore = getCanonicalLinkStore(options);
218
244
 
219
- // 1. Extract issue number, labels, and state reason
220
- const issueNumber = event.issue.number;
221
- const labels = event.issue.labels || [];
222
- const stateReason = event.issue.state_reason;
245
+ const issue = event.issue;
246
+ const issueNumber = issue.number;
247
+ const labels = issue.labels || [];
248
+ const stateReason = issue.state_reason;
223
249
 
224
- // 2. Guard: bot actor
225
250
  if (isBot(event.sender?.login)) {
226
251
  return { skipped: true, reason: 'bot actor' };
227
252
  }
228
253
 
229
- // 3. Guard: skip label
230
254
  if (hasSkipLabel(labels)) {
231
255
  return { skipped: true, reason: 'skip label' };
232
256
  }
233
257
 
234
- // 4. Guard: skip "not planned" closures (only close on "completed")
235
258
  if (stateReason && stateReason !== 'completed') {
236
259
  return { skipped: true, reason: `closed as ${stateReason}` };
237
260
  }
238
261
 
239
- // 5. Read mapping
240
- let beadsId = getBeadsId(mappingPath, issueNumber);
262
+ const canonicalLink = canonicalLinkStore.resolveCanonicalLink({
263
+ githubNodeId: issue.node_id ?? issue.nodeId ?? null,
264
+ githubNumber: issue.number,
265
+ });
266
+ let beadsId = canonicalLink?.forgeIssueId ?? null;
241
267
 
242
- // 6. Fallback: find via sync comment
243
268
  if (!beadsId) {
244
- const comment = findSyncComment(owner, repo, issueNumber);
245
- if (comment) {
246
- const parsed = parseComment(comment.body);
247
- // Verify the comment's tagged issue number matches current issue (prevents cross-issue close)
248
- if (parsed && parsed.issueNumber === issueNumber) {
249
- beadsId = parsed.beadsId;
250
- }
269
+ const syncCommentLink = parseComment(findSyncComment(options.owner, options.repo, issueNumber)?.body);
270
+ if (syncCommentLink?.beadsId && syncCommentLink.issueNumber === issueNumber) {
271
+ canonicalLinkStore.upsertCanonicalLink({
272
+ forgeIssueId: syncCommentLink.beadsId,
273
+ github: getGitHubLink(issue),
274
+ sources: [
275
+ {
276
+ source: 'syncComment',
277
+ forgeIssueId: syncCommentLink.beadsId,
278
+ githubNumber: issueNumber,
279
+ url: issue.html_url ?? null,
280
+ },
281
+ ],
282
+ diagnostics: [],
283
+ });
284
+ beadsId = syncCommentLink.beadsId;
251
285
  }
252
286
  }
253
287
 
254
- // 7. No beads link found
255
288
  if (!beadsId) {
256
289
  return { skipped: true, reason: 'no beads link found' };
257
290
  }
258
291
 
259
- // 8. Check if already closed
260
292
  const status = bdShow(beadsId);
261
293
  if (status === null) {
262
294
  return { skipped: true, reason: 'could not determine beads status' };
@@ -265,16 +297,11 @@ export function handleClosed(event, options = {}) {
265
297
  return { skipped: true, reason: 'already closed' };
266
298
  }
267
299
 
268
- // 9. Close beads issue
269
300
  bdClose(beadsId, `Closed via GitHub issue #${issueNumber}`);
270
301
 
271
- // 10. Return success
272
302
  return { success: true, beadsId, issueNumber };
273
303
  }
274
304
 
275
- // ---------------------------------------------------------------------------
276
- // CLI entry point
277
- // ---------------------------------------------------------------------------
278
305
  const __filename = fileURLToPath(import.meta.url);
279
306
  if (process.argv[1] === __filename) {
280
307
  const action = process.argv[2];
@@ -291,12 +318,11 @@ if (process.argv[1] === __filename) {
291
318
 
292
319
  const options = {
293
320
  configPath: process.env.BEADS_SYNC_CONFIG || undefined,
294
- mappingPath: process.env.BEADS_SYNC_MAPPING || '.github/beads-mapping.json',
321
+ mappingPath: process.env.BEADS_SYNC_MAPPING || DEFAULT_MAPPING_PATH,
295
322
  owner,
296
323
  repo,
297
324
  };
298
325
 
299
- // Validate action explicitly — only allow known actions
300
326
  const VALID_ACTIONS = ['opened', 'closed'];
301
327
  if (!VALID_ACTIONS.includes(action)) {
302
328
  console.error(`Unknown action: "${action}". Expected one of: ${VALID_ACTIONS.join(', ')}`);
@@ -309,13 +335,11 @@ if (process.argv[1] === __filename) {
309
335
  .then((result) => {
310
336
  console.log(JSON.stringify(result, null, 2));
311
337
 
312
- // Exit non-zero on logical failure (e.g., bdCreate returned null)
313
338
  if (result.success === false) {
314
339
  console.error(`Sync failed: ${result.reason || 'unknown'}`);
315
340
  process.exit(1);
316
341
  }
317
342
 
318
- // Write to GITHUB_OUTPUT if available
319
343
  const outputPath = process.env.GITHUB_OUTPUT;
320
344
  if (outputPath) {
321
345
  for (const [key, value] of Object.entries(result)) {
@@ -9,6 +9,11 @@
9
9
 
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { createLinkStore, resolveCanonicalLink, upsertCanonicalLink } = require('../../lib/issue-sync/link-store.js');
16
+ const { bridgeLegacyLinkHints } = require('../../lib/issue-sync/legacy-link-bridge.js');
12
17
 
13
18
  /**
14
19
  * Reads the mapping file and returns parsed JSON.
@@ -76,3 +81,52 @@ export function setBeadsId(mappingPath, issueNumber, beadsId) {
76
81
  data[key] = beadsId;
77
82
  writeMapping(mappingPath, data);
78
83
  }
84
+
85
+ /**
86
+ * Loads a canonical link store seeded from the legacy mapping file plus any
87
+ * optional migration hints.
88
+ *
89
+ * @param {string} mappingPath - Path to the legacy mapping file
90
+ * @param {object} [legacyHints] - Additional migration hints to bridge
91
+ * @returns {ReturnType<typeof createLinkStore>}
92
+ */
93
+ export function loadCanonicalLinkStore(mappingPath, legacyHints = {}) {
94
+ const store = createLinkStore();
95
+ const mapping = readMapping(mappingPath);
96
+ bridgeLegacyLinkHints({ ...legacyHints, mapping }, { store });
97
+ return store;
98
+ }
99
+
100
+ /**
101
+ * Resolves the canonical link record for a GitHub lookup using the legacy
102
+ * mapping file only as a bridge input.
103
+ *
104
+ * @param {string} mappingPath - Path to the legacy mapping file
105
+ * @param {object} lookup - Canonical lookup values
106
+ * @param {object} [legacyHints] - Additional migration hints to bridge
107
+ * @returns {object|null}
108
+ */
109
+ export function resolveCanonicalBeadsLink(mappingPath, lookup = {}, legacyHints = {}) {
110
+ const store = loadCanonicalLinkStore(mappingPath, legacyHints);
111
+ return resolveCanonicalLink(store, lookup);
112
+ }
113
+
114
+ /**
115
+ * Upserts a canonical link and mirrors it into the legacy mapping file for
116
+ * compatibility during migration.
117
+ *
118
+ * @param {string} mappingPath - Path to the legacy mapping file
119
+ * @param {object} link - Canonical link record
120
+ * @param {object} [legacyHints] - Additional migration hints to bridge
121
+ * @returns {object}
122
+ */
123
+ export function upsertCanonicalBeadsLink(mappingPath, link, legacyHints = {}) {
124
+ const store = loadCanonicalLinkStore(mappingPath, legacyHints);
125
+ const canonical = upsertCanonicalLink(store, link);
126
+
127
+ if (canonical?.github?.number != null && canonical.forgeIssueId) {
128
+ setBeadsId(mappingPath, canonical.github.number, canonical.forgeIssueId);
129
+ }
130
+
131
+ return canonical;
132
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * CLI entry point for Beads → GitHub reverse sync.
4
- * Usage: node reverse-sync-cli.mjs <old-jsonl-path> <new-jsonl-path>
4
+ * Usage: node reverse-sync-cli.mjs <old-snapshot-jsonl-path> <new-snapshot-jsonl-path>
5
5
  *
6
6
  * @module scripts/github-beads-sync/reverse-sync-cli
7
7
  */
@@ -13,7 +13,7 @@ const oldPath = process.argv[2];
13
13
  const newPath = process.argv[3];
14
14
 
15
15
  if (!oldPath || !newPath) {
16
- console.error('Usage: node reverse-sync-cli.mjs <old-jsonl-path> <new-jsonl-path>');
16
+ console.error('Usage: node reverse-sync-cli.mjs <old-snapshot-jsonl-path> <new-snapshot-jsonl-path>');
17
17
  process.exit(1);
18
18
  }
19
19
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Reverse sync: Beads → GitHub.
3
- * When a Beads issue is closed (via git push updating .beads/),
3
+ * When a Beads issue is closed (via git push updating exported issue snapshots),
4
4
  * close the linked GitHub issue.
5
5
  *
6
6
  * @module scripts/github-beads-sync/reverse-sync
@@ -28,13 +28,34 @@ function parseJsonlLines(lines) {
28
28
  return results;
29
29
  }
30
30
 
31
+ function extractCanonicalGitHubLink(issue = {}) {
32
+ const github = issue.github ?? {};
33
+ if (typeof github.url === 'string' && github.url.length > 0) {
34
+ const match = github.url.match(
35
+ /https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/,
36
+ );
37
+ if (match) {
38
+ return {
39
+ owner: match[1],
40
+ repo: match[2],
41
+ issueNumber: parseInt(match[3], 10),
42
+ };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function extractPreferredGitHubLink(issue = {}) {
49
+ return extractCanonicalGitHubLink(issue) ?? extractGitHubUrl(issue.description);
50
+ }
51
+
31
52
  /**
32
53
  * Detect Beads issues that transitioned to "closed" status.
33
54
  * Only detects transitions: issue must exist in oldLines as non-closed,
34
55
  * and appear in newLines as "closed".
35
56
  *
36
- * @param {string[]} oldLines - JSONL lines from previous .beads/issues.jsonl
37
- * @param {string[]} newLines - JSONL lines from current .beads/issues.jsonl
57
+ * @param {string[]} oldLines - JSONL lines from a previous exported issue snapshot
58
+ * @param {string[]} newLines - JSONL lines from a current exported issue snapshot
38
59
  * @returns {Array<{id: string, description: string}>} Issues that transitioned to closed
39
60
  */
40
61
  export function detectClosedIssues(oldLines, newLines) {
@@ -93,11 +114,11 @@ export function closeGitHubIssue(owner, repo, issueNumber) {
93
114
 
94
115
  /**
95
116
  * Handle Beads-to-GitHub reverse sync.
96
- * Detects issues that transitioned to closed in .beads/issues.jsonl
117
+ * Detects issues that transitioned to closed in exported issue snapshot JSONL
97
118
  * and closes the linked GitHub issues.
98
119
  *
99
- * @param {string} oldContent - Previous .beads/issues.jsonl content
100
- * @param {string} newContent - Current .beads/issues.jsonl content
120
+ * @param {string} oldContent - Previous exported issue snapshot content
121
+ * @param {string} newContent - Current exported issue snapshot content
101
122
  * @param {object} [deps] - Dependency injection
102
123
  * @param {Function} [deps.closeGitHubIssue] - Override for testing
103
124
  * @returns {{closed: Array, skipped: Array, errors: Array}}
@@ -109,13 +130,16 @@ export function handleBeadsClosed(oldContent, newContent, deps = {}) {
109
130
  const newLines = newContent.split('\n');
110
131
 
111
132
  const transitions = detectClosedIssues(oldLines, newLines);
133
+ const latestIssues = new Map(
134
+ parseJsonlLines(newLines).map((issue) => [issue.id, issue]),
135
+ );
112
136
 
113
137
  const closed = [];
114
138
  const skipped = [];
115
139
  const errors = [];
116
140
 
117
141
  for (const issue of transitions) {
118
- const parsed = extractGitHubUrl(issue.description);
142
+ const parsed = extractPreferredGitHubLink(latestIssues.get(issue.id) ?? issue);
119
143
  if (!parsed) {
120
144
  skipped.push({ beadsId: issue.id, reason: 'no GitHub URL' });
121
145
  continue;
@@ -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