forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5

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 (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -1,155 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
-
6
- # ── Setup temp dir for test isolation ────────────────────────────────────
7
- TEST_TMP="$(mktemp -d)"
8
- trap 'rm -rf "$TEST_TMP"' EXIT
9
-
10
- # ── Create mock forge ─────────────────────────────────────────────────────
11
- # Emits Kernel-shaped JSON ({"data":{"issues":[...]}}) for `issue list`.
12
- mock_dir="$(mktemp -d)"
13
-
14
- cat > "$mock_dir/forge" << 'MOCK'
15
- #!/usr/bin/env bash
16
- args="$*"
17
- emit() { printf '{"data":{"issues":%s}}\n' "$1"; }
18
-
19
- # forge-aaa BLOCKS forge-ccc (kernel: blocker carries the dependency edge), so
20
- # forge-ccc is the blocked one — its blocked_by resolves to forge-aaa via reverse scan.
21
- aaa='{"id":"forge-aaa","title":"Feature A","status":"in_progress","assignee":"devone","updated_at":"2026-03-27T10:00:00Z","dependencies":["forge-ccc"]}'
22
- bbb='{"id":"forge-bbb","title":"Feature B","status":"open","assignee":"devtwo","updated_at":"2026-03-24T10:00:00Z","dependencies":[]}'
23
- ccc='{"id":"forge-ccc","title":"Feature C","status":"in_progress","assignee":"devone","updated_at":"2026-03-27T09:00:00Z","dependencies":[]}'
24
-
25
- if [[ "$args" == *"issue list"* ]]; then
26
- if [[ -n "${FORGE_MOCK_EMPTY:-}" ]]; then
27
- emit "[]"
28
- elif [[ -n "${FORGE_MOCK_SINGLE:-}" ]]; then
29
- if [[ "$args" == *"--status=in_progress"* ]]; then emit "[$aaa]"; else emit "[]"; fi
30
- else
31
- if [[ "$args" == *"--status=open"* ]]; then emit "[$bbb]"
32
- elif [[ "$args" == *"--status=in_progress"* ]]; then emit "[$aaa,$ccc]"
33
- else emit "[]"; fi
34
- fi
35
- exit 0
36
- fi
37
- exit 0
38
- MOCK
39
- chmod +x "$mock_dir/forge"
40
-
41
- export FORGE_CMD="$mock_dir/forge"
42
-
43
- # Source the library under test
44
- source "$SCRIPT_DIR/lib/dashboard.sh"
45
-
46
- PASS=0
47
- FAIL=0
48
-
49
- assert_eq() {
50
- local label="$1" expected="$2" actual="$3"
51
- if [[ "$actual" == "$expected" ]]; then
52
- PASS=$((PASS + 1)); echo " PASS: $label"
53
- else
54
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected '$expected', got '$actual')"
55
- fi
56
- }
57
-
58
- assert_contains() {
59
- local label="$1" expected="$2" actual="$3"
60
- if [[ "$actual" == *"$expected"* ]]; then
61
- PASS=$((PASS + 1)); echo " PASS: $label"
62
- else
63
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected to contain '$expected', got '$actual')"
64
- fi
65
- }
66
-
67
- assert_not_contains() {
68
- local label="$1" unexpected="$2" actual="$3"
69
- if [[ "$actual" != *"$unexpected"* ]]; then
70
- PASS=$((PASS + 1)); echo " PASS: $label"
71
- else
72
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected NOT to contain '$unexpected')"
73
- fi
74
- }
75
-
76
- assert_exit() {
77
- local label="$1" expected="$2" actual="$3"
78
- if [[ "$actual" -eq "$expected" ]]; then
79
- PASS=$((PASS + 1)); echo " PASS: $label"
80
- else
81
- FAIL=$((FAIL + 1)); echo " FAIL: $label (expected exit $expected, got $actual)"
82
- fi
83
- }
84
-
85
- # ── Test 1: Full dashboard with multiple developers ──────────────────────
86
- echo "── Test 1: Full dashboard with multiple developers ──"
87
-
88
- # Override _now to a fixed time: 2026-03-27T12:00:00Z = 1774836000
89
- export FORGE_NOW=1774836000
90
-
91
- rc=0
92
- output="$(cmd_dashboard 2>&1)" || rc=$?
93
- assert_exit "dashboard exits 0" 0 "$rc"
94
- assert_contains "shows Team Dashboard header" "Team Dashboard" "$output"
95
- assert_contains "shows devone stats" "devone" "$output"
96
- assert_contains "shows devtwo stats" "devtwo" "$output"
97
- assert_contains "shows total summary" "Total:" "$output"
98
-
99
- # ── Test 2: Stale issue flagged (forge-bbb >48h old) ────────────────────
100
- echo ""
101
- echo "── Test 2: Stale issue flagged ──"
102
-
103
- # forge-bbb updated 2026-03-24T10:00:00Z, now is 2026-03-27T12:00:00Z => ~74h
104
- assert_contains "stale section shown" "Stale assignments" "$output"
105
- assert_contains "forge-bbb flagged as stale" "forge-bbb" "$output"
106
- assert_contains "stale issue shows devtwo" "devtwo" "$output"
107
-
108
- # ── Test 3: Blocked issue detected (forge-ccc depends on forge-aaa) ─────
109
- echo ""
110
- echo "── Test 3: Blocked issue detected ──"
111
-
112
- assert_contains "blocked section shown" "Blocked issues" "$output"
113
- assert_contains "forge-ccc flagged as blocked" "forge-ccc" "$output"
114
- assert_contains "blocked shows dependency" "forge-aaa" "$output"
115
-
116
- # ── Test 4: No issues → "No active issues" ──────────────────────────────
117
- echo ""
118
- echo "── Test 4: No issues → clear message ──"
119
-
120
- export FORGE_MOCK_EMPTY=1
121
- rc=0
122
- output_empty="$(cmd_dashboard 2>&1)" || rc=$?
123
- assert_exit "empty dashboard exits 0" 0 "$rc"
124
- assert_contains "shows no active issues" "No active issues" "$output_empty"
125
- unset FORGE_MOCK_EMPTY
126
-
127
- # ── Test 5: Single developer → works correctly ──────────────────────────
128
- echo ""
129
- echo "── Test 5: Single developer ──"
130
-
131
- export FORGE_MOCK_SINGLE=1
132
- rc=0
133
- output_single="$(cmd_dashboard 2>&1)" || rc=$?
134
- assert_exit "single dev dashboard exits 0" 0 "$rc"
135
- assert_contains "shows devone" "devone" "$output_single"
136
- assert_not_contains "no devtwo in single mode" "devtwo" "$output_single"
137
- unset FORGE_MOCK_SINGLE
138
-
139
- # ── Test 6: --format=json → valid JSON ──────────────────────────────────
140
- echo ""
141
- echo "── Test 6: --format=json → valid JSON ──"
142
-
143
- rc=0
144
- output_json="$(cmd_dashboard --format=json 2>&1)" || rc=$?
145
- assert_exit "json format exits 0" 0 "$rc"
146
- # Check it starts with { and contains expected keys
147
- assert_contains "json has developers key" "developers" "$output_json"
148
- assert_contains "json has total key" "total" "$output_json"
149
- assert_contains "json has stale key" "stale" "$output_json"
150
- assert_contains "json has blocked key" "blocked" "$output_json"
151
-
152
- # ── Results ──────────────────────────────────────────────────────────────
153
- echo ""
154
- echo "Results: $PASS passed, $FAIL failed"
155
- [[ $FAIL -eq 0 ]] && exit 0 || exit 1
@@ -1,503 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { execFileSync } from 'node:child_process';
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import process from 'node:process';
7
- import { pathToFileURL } from 'node:url';
8
-
9
- const BACKUP_FILES = [
10
- 'issues.jsonl',
11
- 'labels.jsonl',
12
- 'dependencies.jsonl',
13
- 'comments.jsonl',
14
- 'events.jsonl',
15
- 'config.jsonl',
16
- ];
17
-
18
- function sanitizePrefix(value) {
19
- return value
20
- .trim()
21
- .toLowerCase()
22
- .replaceAll(/[^a-z0-9-]/g, '-')
23
- .replaceAll(/-{2,}/g, '-')
24
- .replace(/^-/, '')
25
- .replace(/-$/, '');
26
- }
27
-
28
- function readJsonl(filePath) {
29
- return fs
30
- .readFileSync(filePath, 'utf8')
31
- .split(/\r?\n/)
32
- .filter(Boolean)
33
- .map((line) => JSON.parse(line));
34
- }
35
-
36
- function ensureBackupShape(legacyBackupDir) {
37
- if (!fs.existsSync(legacyBackupDir) || !fs.statSync(legacyBackupDir).isDirectory()) {
38
- throw new Error(`Legacy backup directory not found: ${legacyBackupDir}`);
39
- }
40
-
41
- for (const file of BACKUP_FILES) {
42
- const filePath = path.join(legacyBackupDir, file);
43
- if (!fs.existsSync(filePath)) {
44
- throw new Error(`Missing backup file: ${filePath}`);
45
- }
46
- }
47
- }
48
-
49
- function loadBackupData(backupDir) {
50
- ensureBackupShape(backupDir);
51
- return {
52
- issues: readJsonl(path.join(backupDir, 'issues.jsonl')),
53
- labels: readJsonl(path.join(backupDir, 'labels.jsonl')),
54
- dependencies: readJsonl(path.join(backupDir, 'dependencies.jsonl')),
55
- comments: readJsonl(path.join(backupDir, 'comments.jsonl')),
56
- events: readJsonl(path.join(backupDir, 'events.jsonl')),
57
- config: readJsonl(path.join(backupDir, 'config.jsonl')),
58
- };
59
- }
60
-
61
- function setDifference(left, right) {
62
- const rightSet = new Set(right);
63
- return left.filter((item) => !rightSet.has(item));
64
- }
65
-
66
- function normalizeRecord(value) {
67
- if (Array.isArray(value)) {
68
- return value.map((entry) => normalizeRecord(entry));
69
- }
70
- if (value && typeof value === 'object') {
71
- return Object.fromEntries(
72
- Object.keys(value)
73
- .sort()
74
- .map((key) => [key, normalizeRecord(value[key])]),
75
- );
76
- }
77
- return value;
78
- }
79
-
80
- function stableSerializeRecord(record) {
81
- return JSON.stringify(normalizeRecord(record));
82
- }
83
-
84
- function arraysEqual(left, right) {
85
- return JSON.stringify(left) === JSON.stringify(right);
86
- }
87
-
88
- function collectParity(data) {
89
- return {
90
- counts: {
91
- issues: data.issues.length,
92
- labels: data.labels.length,
93
- dependencies: data.dependencies.length,
94
- comments: data.comments.length,
95
- events: data.events.length,
96
- config: data.config.length,
97
- },
98
- issueIds: data.issues.map((issue) => issue.id).sort(),
99
- dependencyEdges: data.dependencies
100
- .map((dep) => `${dep.issue_id} ${dep.type} ${dep.depends_on_id}`)
101
- .sort(),
102
- commentIds: data.comments.map((comment) => comment.id).sort(),
103
- configKeys: data.config.map((entry) => entry.key).sort(),
104
- issueRecords: data.issues.map((issue) => stableSerializeRecord(issue)).sort(),
105
- labelRecords: data.labels.map((label) => stableSerializeRecord(label)).sort(),
106
- dependencyRecords: data.dependencies
107
- .map((dependency) => stableSerializeRecord(dependency))
108
- .sort(),
109
- commentRecords: data.comments.map((comment) => stableSerializeRecord(comment)).sort(),
110
- eventRecords: data.events.map((event) => stableSerializeRecord(event)).sort(),
111
- configRecords: data.config.map((entry) => stableSerializeRecord(entry)).sort(),
112
- };
113
- }
114
-
115
- function summarizeParityMismatch(parity) {
116
- const parts = [];
117
- if (parity.missingIssueIds.length > 0 || parity.extraIssueIds.length > 0) {
118
- parts.push('issue ids differ');
119
- }
120
- if (
121
- parity.missingDependencyEdges.length > 0 ||
122
- parity.extraDependencyEdges.length > 0
123
- ) {
124
- parts.push('dependency edges differ');
125
- }
126
- if (parity.missingCommentIds.length > 0 || parity.extraCommentIds.length > 0) {
127
- parts.push('comment ids differ');
128
- }
129
- if (parity.missingConfigKeys.length > 0 || parity.extraConfigKeys.length > 0) {
130
- parts.push('config keys differ');
131
- }
132
- const recordMismatchMessages = {
133
- issues: 'issue records differ',
134
- labels: 'label records differ',
135
- dependencies: 'dependency records differ',
136
- comments: 'comment records differ',
137
- events: 'event records differ',
138
- config: 'config records differ',
139
- };
140
- for (const kind of parity.recordMismatches || []) {
141
- const message = recordMismatchMessages[kind];
142
- if (message && !parts.includes(message)) {
143
- parts.push(message);
144
- }
145
- }
146
- if (parts.length === 0) {
147
- parts.push('record counts differ');
148
- }
149
- return parts.join('; ');
150
- }
151
-
152
- export async function verifyMigrationParity({ legacyBackupDir, exportDir }) {
153
- const source = collectParity(loadBackupData(legacyBackupDir));
154
- const exported = collectParity(loadBackupData(exportDir));
155
- const recordMismatches = Object.entries({
156
- issues: arraysEqual(source.issueRecords, exported.issueRecords),
157
- labels: arraysEqual(source.labelRecords, exported.labelRecords),
158
- dependencies: arraysEqual(source.dependencyRecords, exported.dependencyRecords),
159
- comments: arraysEqual(source.commentRecords, exported.commentRecords),
160
- events: arraysEqual(source.eventRecords, exported.eventRecords),
161
- config: arraysEqual(source.configRecords, exported.configRecords),
162
- })
163
- .filter(([, match]) => !match)
164
- .map(([kind]) => kind);
165
-
166
- const parity = {
167
- ok:
168
- arraysEqual(source.counts, exported.counts) &&
169
- arraysEqual(source.issueIds, exported.issueIds) &&
170
- arraysEqual(source.dependencyEdges, exported.dependencyEdges) &&
171
- arraysEqual(source.commentIds, exported.commentIds) &&
172
- arraysEqual(source.configKeys, exported.configKeys) &&
173
- recordMismatches.length === 0,
174
- counts: source.counts,
175
- issueIds: source.issueIds,
176
- dependencyEdges: source.dependencyEdges,
177
- commentIds: source.commentIds,
178
- configKeys: source.configKeys,
179
- recordMismatches,
180
- missingIssueIds: setDifference(source.issueIds, exported.issueIds),
181
- extraIssueIds: setDifference(exported.issueIds, source.issueIds),
182
- missingDependencyEdges: setDifference(
183
- source.dependencyEdges,
184
- exported.dependencyEdges,
185
- ),
186
- extraDependencyEdges: setDifference(
187
- exported.dependencyEdges,
188
- source.dependencyEdges,
189
- ),
190
- missingCommentIds: setDifference(source.commentIds, exported.commentIds),
191
- extraCommentIds: setDifference(exported.commentIds, source.commentIds),
192
- missingConfigKeys: setDifference(source.configKeys, exported.configKeys),
193
- extraConfigKeys: setDifference(exported.configKeys, source.configKeys),
194
- };
195
-
196
- return parity;
197
- }
198
-
199
- function snapshotCurrentBeads(projectRoot, snapshotRoot, now = new Date()) {
200
- const liveBeadsDir = path.join(projectRoot, '.beads');
201
- if (!fs.existsSync(liveBeadsDir)) {
202
- throw new Error(`Beads directory not found: ${liveBeadsDir}`);
203
- }
204
-
205
- const timestamp = now.toISOString().replaceAll(':', '-');
206
- const snapshotDir = path.join(snapshotRoot, timestamp);
207
- const snapshotBeadsDir = path.join(snapshotDir, 'current-beads');
208
- fs.mkdirSync(snapshotDir, { recursive: true });
209
- fs.cpSync(liveBeadsDir, snapshotBeadsDir, { recursive: true });
210
- return snapshotDir;
211
- }
212
-
213
- function materializeLegacyBackupDir({ legacyBackupDir, snapshotDir }) {
214
- const snapshotBeadsDir = path.join(snapshotDir, 'current-beads');
215
- const synthesizedBackupDir = path.join(snapshotDir, 'legacy-backup');
216
- fs.mkdirSync(synthesizedBackupDir, { recursive: true });
217
-
218
- for (const file of BACKUP_FILES) {
219
- const backupCandidate = path.join(legacyBackupDir, file);
220
- const liveCandidate = path.join(snapshotBeadsDir, file);
221
- const sourcePath = fs.existsSync(liveCandidate) ? liveCandidate : backupCandidate;
222
- if (!fs.existsSync(sourcePath)) {
223
- throw new Error(`Missing legacy backup file: ${file}`);
224
- }
225
- fs.copyFileSync(sourcePath, path.join(synthesizedBackupDir, file));
226
- }
227
-
228
- return synthesizedBackupDir;
229
- }
230
-
231
- function applyMigratedBeadsState({ projectRoot, migratedDir }) {
232
- const liveBeadsDir = path.join(projectRoot, '.beads');
233
- const migratedBeadsDir = path.join(migratedDir, '.beads');
234
- const stagedBeadsDir = path.join(projectRoot, `.beads.next-${Date.now()}`);
235
-
236
- if (!fs.existsSync(migratedBeadsDir) || !fs.statSync(migratedBeadsDir).isDirectory()) {
237
- throw new Error(`Migrated Beads directory not found: ${migratedBeadsDir}`);
238
- }
239
-
240
- fs.rmSync(stagedBeadsDir, { recursive: true, force: true });
241
- fs.cpSync(migratedBeadsDir, stagedBeadsDir, { recursive: true });
242
- fs.rmSync(liveBeadsDir, { recursive: true, force: true });
243
- fs.renameSync(stagedBeadsDir, liveBeadsDir);
244
-
245
- return {
246
- liveBeadsDir,
247
- migratedBeadsDir,
248
- };
249
- }
250
-
251
- function defaultCliPaths(projectRoot) {
252
- return {
253
- legacyBackupDir: path.join(projectRoot, '.beads', 'backup'),
254
- snapshotRoot: path.join(projectRoot, '.beads-migration-snapshots'),
255
- migratedDir: path.join(projectRoot, '.beads-migrated'),
256
- exportDir: path.join(projectRoot, '.beads-migrated-export'),
257
- };
258
- }
259
-
260
- export async function rollbackLegacyBeadsMigration({ projectRoot, snapshotDir }) {
261
- const liveBeadsDir = path.join(projectRoot, '.beads');
262
- const snapshotBeadsDir = path.join(snapshotDir, 'current-beads');
263
-
264
- if (!fs.existsSync(snapshotBeadsDir)) {
265
- throw new Error(`Snapshot payload not found: ${snapshotBeadsDir}`);
266
- }
267
-
268
- fs.rmSync(liveBeadsDir, { recursive: true, force: true });
269
- fs.cpSync(snapshotBeadsDir, liveBeadsDir, { recursive: true });
270
-
271
- return {
272
- restored: true,
273
- snapshotDir,
274
- liveBeadsDir,
275
- };
276
- }
277
-
278
- function resolveBdCommand() {
279
- return process.env.BD_CMD || 'bd';
280
- }
281
-
282
- function inferPrefix(projectRoot) {
283
- const configPath = path.join(projectRoot, '.beads', 'config.yaml');
284
- if (fs.existsSync(configPath)) {
285
- const match = /^issue-prefix:\s*(.+)$/m.exec(fs.readFileSync(configPath, 'utf8'));
286
- if (match?.[1]) {
287
- return sanitizePrefix(match[1]);
288
- }
289
- }
290
- return sanitizePrefix(path.basename(projectRoot) || 'beads');
291
- }
292
-
293
- export function defaultImportBackup({
294
- projectRoot,
295
- sourceDir,
296
- migratedDir,
297
- exportDir,
298
- }) {
299
- const bd = resolveBdCommand();
300
- fs.mkdirSync(migratedDir, { recursive: true });
301
- fs.rmSync(exportDir, { recursive: true, force: true });
302
-
303
- const prefix = inferPrefix(projectRoot);
304
- const execOpts = {
305
- cwd: migratedDir,
306
- encoding: 'utf8',
307
- env: {
308
- ...process.env,
309
- BEADS_DIR: path.join(migratedDir, '.beads'),
310
- },
311
- };
312
-
313
- execFileSync(
314
- bd,
315
- ['init', '--force', '--prefix', prefix, '--skip-hooks', '--skip-agents'],
316
- execOpts,
317
- );
318
- execFileSync(bd, ['backup', 'restore', sourceDir], execOpts);
319
- execFileSync(bd, ['backup', '--force'], execOpts);
320
-
321
- const exportedBackupDir = path.join(migratedDir, '.beads', 'backup');
322
- ensureBackupShape(exportedBackupDir);
323
- fs.cpSync(exportedBackupDir, exportDir, { recursive: true });
324
-
325
- return {
326
- strategy: 'bd-backup-restore',
327
- prefix,
328
- };
329
- }
330
-
331
- function writeManifest(manifestPath, payload) {
332
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
333
- fs.writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
334
- }
335
-
336
- export async function runLegacyBeadsMigration(options) {
337
- const {
338
- projectRoot,
339
- legacyBackupDir,
340
- snapshotRoot,
341
- migratedDir,
342
- exportDir,
343
- importBackup = defaultImportBackup,
344
- now = new Date(),
345
- } = options;
346
-
347
- if (!projectRoot || !legacyBackupDir || !snapshotRoot || !migratedDir || !exportDir) {
348
- throw new Error('projectRoot, legacyBackupDir, snapshotRoot, migratedDir, and exportDir are required');
349
- }
350
-
351
- const manifestPath = path.join(migratedDir, 'migration-manifest.json');
352
- if (fs.existsSync(manifestPath)) {
353
- return {
354
- status: 'skipped',
355
- reason: 'already migrated',
356
- migratedDir,
357
- };
358
- }
359
-
360
- const snapshotDir = snapshotCurrentBeads(projectRoot, snapshotRoot, now);
361
- const resolvedLegacyBackupDir = materializeLegacyBackupDir({
362
- legacyBackupDir,
363
- snapshotDir,
364
- });
365
- let rolledBack = false;
366
-
367
- try {
368
- const importResult = await importBackup({
369
- projectRoot,
370
- sourceDir: resolvedLegacyBackupDir,
371
- migratedDir,
372
- exportDir,
373
- snapshotDir,
374
- });
375
-
376
- const parity = await verifyMigrationParity({
377
- legacyBackupDir: resolvedLegacyBackupDir,
378
- exportDir,
379
- });
380
-
381
- if (!parity.ok) {
382
- await rollbackLegacyBeadsMigration({ projectRoot, snapshotDir });
383
- rolledBack = true;
384
-
385
- const error = new Error(
386
- `Parity verification failed: ${summarizeParityMismatch(parity)}`,
387
- );
388
- error.parity = parity;
389
- throw error;
390
- }
391
-
392
- const appliedState = applyMigratedBeadsState({
393
- projectRoot,
394
- migratedDir,
395
- });
396
-
397
- writeManifest(manifestPath, {
398
- status: 'migrated',
399
- createdAt: now.toISOString(),
400
- snapshotDir,
401
- legacyBackupDir: resolvedLegacyBackupDir,
402
- parity,
403
- appliedState,
404
- importResult: importResult || null,
405
- });
406
-
407
- return {
408
- status: 'migrated',
409
- snapshotDir,
410
- migratedDir,
411
- exportDir,
412
- legacyBackupDir: resolvedLegacyBackupDir,
413
- appliedState,
414
- parity,
415
- importResult: importResult || null,
416
- };
417
- } catch (error) {
418
- if (!rolledBack) {
419
- await rollbackLegacyBeadsMigration({ projectRoot, snapshotDir });
420
- }
421
- throw error;
422
- }
423
- }
424
-
425
- export function parseCliArgs(argv, cwd = process.cwd()) {
426
- const options = {
427
- projectRoot: cwd,
428
- ...defaultCliPaths(cwd),
429
- };
430
- const explicitPathFlags = new Set();
431
-
432
- for (let i = 0; i < argv.length; i += 1) {
433
- const arg = argv[i];
434
- switch (arg) {
435
- case '--project-root':
436
- options.projectRoot = path.resolve(argv[++i]);
437
- for (const [key, value] of Object.entries(defaultCliPaths(options.projectRoot))) {
438
- if (!explicitPathFlags.has(key)) {
439
- options[key] = value;
440
- }
441
- }
442
- break;
443
- case '--legacy-backup-dir':
444
- options.legacyBackupDir = path.resolve(argv[++i]);
445
- explicitPathFlags.add('legacyBackupDir');
446
- break;
447
- case '--snapshot-root':
448
- options.snapshotRoot = path.resolve(argv[++i]);
449
- explicitPathFlags.add('snapshotRoot');
450
- break;
451
- case '--migrated-dir':
452
- options.migratedDir = path.resolve(argv[++i]);
453
- explicitPathFlags.add('migratedDir');
454
- break;
455
- case '--export-dir':
456
- options.exportDir = path.resolve(argv[++i]);
457
- explicitPathFlags.add('exportDir');
458
- break;
459
- case '-h':
460
- case '--help':
461
- options.help = true;
462
- break;
463
- default:
464
- throw new Error(`Unknown argument: ${arg}`);
465
- }
466
- }
467
-
468
- return options;
469
- }
470
-
471
- function printUsage() {
472
- console.log(`Usage: beads-migrate-to-dolt [options]
473
-
474
- Options:
475
- --project-root <path> Project root containing .beads/
476
- --legacy-backup-dir <path> Legacy JSONL backup directory
477
- --snapshot-root <path> Where rollback snapshots are stored
478
- --migrated-dir <path> Working directory for migrated Beads state
479
- --export-dir <path> Export directory used for parity verification
480
- -h, --help Show this help message
481
- `);
482
- }
483
-
484
- async function main() {
485
- const options = parseCliArgs(process.argv.slice(2));
486
- if (options.help) {
487
- printUsage();
488
- return;
489
- }
490
-
491
- const result = await runLegacyBeadsMigration(options);
492
- console.log(JSON.stringify(result, null, 2));
493
- }
494
-
495
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
496
- main().catch((error) => {
497
- console.error(error.message);
498
- if (error.parity) {
499
- console.error(JSON.stringify(error.parity, null, 2));
500
- }
501
- process.exitCode = 1;
502
- });
503
- }