forge-workflow 0.0.9 → 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 (102) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/ship.md +2 -2
  4. package/.claude/commands/status.md +4 -4
  5. package/.cline/workflows/dev.md +2 -2
  6. package/.cline/workflows/plan.md +2 -2
  7. package/.cline/workflows/ship.md +2 -2
  8. package/.cline/workflows/status.md +4 -4
  9. package/.codex/skills/dev/SKILL.md +2 -2
  10. package/.codex/skills/plan/SKILL.md +2 -2
  11. package/.codex/skills/ship/SKILL.md +2 -2
  12. package/.codex/skills/status/SKILL.md +4 -4
  13. package/.cursor/commands/dev.md +2 -2
  14. package/.cursor/commands/plan.md +2 -2
  15. package/.cursor/commands/ship.md +2 -2
  16. package/.cursor/commands/status.md +4 -4
  17. package/.github/prompts/dev.prompt.md +2 -2
  18. package/.github/prompts/plan.prompt.md +2 -2
  19. package/.github/prompts/ship.prompt.md +2 -2
  20. package/.github/prompts/status.prompt.md +4 -4
  21. package/.github/workflows/beads-to-github.yml +43 -10
  22. package/.github/workflows/github-to-beads.yml +10 -7
  23. package/.kilocode/workflows/dev.md +2 -2
  24. package/.kilocode/workflows/plan.md +2 -2
  25. package/.kilocode/workflows/ship.md +2 -2
  26. package/.kilocode/workflows/status.md +4 -4
  27. package/.opencode/commands/dev.md +2 -2
  28. package/.opencode/commands/plan.md +2 -2
  29. package/.opencode/commands/ship.md +2 -2
  30. package/.opencode/commands/status.md +4 -4
  31. package/.roo/commands/dev.md +2 -2
  32. package/.roo/commands/plan.md +2 -2
  33. package/.roo/commands/ship.md +2 -2
  34. package/.roo/commands/status.md +4 -4
  35. package/AGENTS.md +1 -0
  36. package/CLAUDE.md +12 -0
  37. package/bin/forge.js +10 -5
  38. package/docs/BEADS_GITHUB_SYNC.md +26 -0
  39. package/docs/TOOLCHAIN.md +130 -148
  40. package/lib/beads-bootstrap.js +225 -0
  41. package/lib/beads-health-check.js +55 -10
  42. package/lib/beads-setup.js +104 -28
  43. package/lib/beads-sync-scaffold.js +11 -6
  44. package/lib/commands/_issue.js +11 -1
  45. package/lib/commands/issues.js +49 -0
  46. package/lib/commands/recommend.js +22 -1
  47. package/lib/commands/setup.js +16 -10
  48. package/lib/commands/status.js +181 -0
  49. package/lib/commands/team.js +11 -1
  50. package/lib/commands/test.js +37 -2
  51. package/lib/commands/validate.js +14 -8
  52. package/lib/commands/worktree.js +27 -54
  53. package/lib/dep-guard/keyword-ripple.js +184 -0
  54. package/lib/detect-worktree.js +9 -10
  55. package/lib/forge-issues.js +326 -0
  56. package/lib/issue-sync/authority.js +100 -0
  57. package/lib/issue-sync/github-pull.js +184 -0
  58. package/lib/issue-sync/import-primitives.js +98 -0
  59. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  60. package/lib/issue-sync/link-store.js +292 -0
  61. package/lib/issue-sync/project-github.js +123 -0
  62. package/lib/issue-sync/reconcile.js +195 -0
  63. package/lib/issue-sync/schema.js +126 -0
  64. package/lib/lefthook-check.js +5 -2
  65. package/lib/project-memory.js +564 -0
  66. package/lib/runtime-health.js +100 -12
  67. package/lib/smart-status/conflicts.js +205 -0
  68. package/lib/smart-status/scoring.js +177 -0
  69. package/lib/status/beads-snapshot.js +102 -0
  70. package/lib/status/presenter.js +65 -0
  71. package/lib/workflow/enforce-stage.js +3 -1
  72. package/lib/workflow/state-manager.js +164 -8
  73. package/package.json +12 -4
  74. package/scripts/beads-context.sh +124 -5
  75. package/scripts/beads-context.test.js +21 -4
  76. package/scripts/beads-migrate-to-dolt.sh +7 -0
  77. package/scripts/beads-upgrade-smoke.sh +263 -0
  78. package/scripts/behavioral-judge.sh +115 -11
  79. package/scripts/benchmark.js +349 -63
  80. package/scripts/dep-guard-analyze.js +52 -17
  81. package/scripts/dep-guard-keyword-ripple.js +29 -0
  82. package/scripts/dep-guard-render-review.js +86 -0
  83. package/scripts/dep-guard.sh +45 -232
  84. package/scripts/forge-team/lib/sync-github.sh +160 -28
  85. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  86. package/scripts/github-beads-sync/index.mjs +122 -98
  87. package/scripts/github-beads-sync/mapping.mjs +54 -0
  88. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  89. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  90. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  91. package/scripts/preflight.sh +181 -0
  92. package/scripts/smart-status-score.js +31 -0
  93. package/scripts/smart-status-sessions.js +51 -0
  94. package/scripts/smart-status.sh +74 -329
  95. package/scripts/sync-agentic-workflow.js +48 -0
  96. package/scripts/test-ci-shard.js +244 -0
  97. package/scripts/test-dashboard.js +188 -52
  98. package/scripts/test-full-suite.js +186 -0
  99. package/scripts/test-profile.js +278 -0
  100. package/scripts/test.js +219 -28
  101. package/scripts/validate.js +143 -0
  102. package/scripts/validate.sh +18 -1
@@ -0,0 +1,292 @@
1
+ function createDefaultCanonicalLink() {
2
+ return {
3
+ forgeIssueId: null,
4
+ github: {
5
+ nodeId: null,
6
+ number: null,
7
+ url: null,
8
+ },
9
+ sources: [],
10
+ diagnostics: [],
11
+ };
12
+ }
13
+
14
+ function cloneValue(value) {
15
+ if (Array.isArray(value)) {
16
+ return value.map(cloneValue);
17
+ }
18
+
19
+ if (value && typeof value === 'object') {
20
+ const copy = {};
21
+
22
+ for (const [key, nestedValue] of Object.entries(value)) {
23
+ copy[key] = cloneValue(nestedValue);
24
+ }
25
+
26
+ return copy;
27
+ }
28
+
29
+ return value;
30
+ }
31
+
32
+ function normalizeGitHubNumber(value) {
33
+ if (value === null || value === undefined || value === '') {
34
+ return null;
35
+ }
36
+
37
+ const parsed = Number(value);
38
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
39
+ }
40
+
41
+ function buildCanonicalLink(input = {}) {
42
+ const link = createDefaultCanonicalLink();
43
+ const github = input.github ?? {};
44
+
45
+ if (input.forgeIssueId) {
46
+ link.forgeIssueId = input.forgeIssueId;
47
+ }
48
+
49
+ if (github.nodeId) {
50
+ link.github.nodeId = github.nodeId;
51
+ }
52
+
53
+ link.github.number = normalizeGitHubNumber(github.number);
54
+
55
+ if (github.url) {
56
+ link.github.url = github.url;
57
+ }
58
+
59
+ if (Array.isArray(input.sources)) {
60
+ link.sources = input.sources.map(cloneValue);
61
+ }
62
+
63
+ if (Array.isArray(input.diagnostics)) {
64
+ link.diagnostics = input.diagnostics.map(cloneValue);
65
+ }
66
+
67
+ return link;
68
+ }
69
+
70
+ function getLinkPriority(link) {
71
+ if (link.github.nodeId) {
72
+ return 0;
73
+ }
74
+
75
+ if (link.github.number !== null) {
76
+ return 1;
77
+ }
78
+
79
+ if (link.forgeIssueId) {
80
+ return 2;
81
+ }
82
+
83
+ return 3;
84
+ }
85
+
86
+ function createLinkStore(initialLinks = []) {
87
+ const store = {
88
+ records: [],
89
+ byForgeIssueId: new Map(),
90
+ byGitHubNodeId: new Map(),
91
+ byGitHubNumber: new Map(),
92
+ };
93
+
94
+ for (const link of initialLinks) {
95
+ upsertCanonicalLink(store, link);
96
+ }
97
+
98
+ return store;
99
+ }
100
+
101
+ function setIndexValue(index, key, record) {
102
+ if (key !== null && key !== undefined && key !== '') {
103
+ index.set(key, record);
104
+ }
105
+ }
106
+
107
+ function indexRecord(store, record) {
108
+ setIndexValue(store.byForgeIssueId, record.forgeIssueId, record);
109
+ setIndexValue(store.byGitHubNodeId, record.github.nodeId, record);
110
+ setIndexValue(store.byGitHubNumber, record.github.number, record);
111
+ }
112
+
113
+ function removeRecord(store, record) {
114
+ store.records = store.records.filter((candidate) => candidate !== record);
115
+
116
+ if (record.forgeIssueId) {
117
+ store.byForgeIssueId.delete(record.forgeIssueId);
118
+ }
119
+
120
+ if (record.github.nodeId) {
121
+ store.byGitHubNodeId.delete(record.github.nodeId);
122
+ }
123
+
124
+ if (record.github.number !== null) {
125
+ store.byGitHubNumber.delete(record.github.number);
126
+ }
127
+ }
128
+
129
+ function mergeUniqueEntries(existingEntries, incomingEntries) {
130
+ const merged = [];
131
+ const seen = new Set();
132
+
133
+ for (const entry of [...existingEntries, ...incomingEntries]) {
134
+ const key = JSON.stringify(entry);
135
+ if (seen.has(key)) {
136
+ continue;
137
+ }
138
+
139
+ seen.add(key);
140
+ merged.push(cloneValue(entry));
141
+ }
142
+
143
+ return merged;
144
+ }
145
+
146
+ function collectMatchedRecords(store, link) {
147
+ const matches = new Map();
148
+
149
+ function registerMatch(record, priority) {
150
+ if (!record) {
151
+ return;
152
+ }
153
+
154
+ const existingPriority = matches.get(record);
155
+ if (existingPriority === undefined || priority < existingPriority) {
156
+ matches.set(record, priority);
157
+ }
158
+ }
159
+
160
+ if (link.forgeIssueId && store.byForgeIssueId.has(link.forgeIssueId)) {
161
+ registerMatch(store.byForgeIssueId.get(link.forgeIssueId), 0);
162
+ }
163
+
164
+ if (link.github.nodeId && store.byGitHubNodeId.has(link.github.nodeId)) {
165
+ registerMatch(store.byGitHubNodeId.get(link.github.nodeId), 1);
166
+ }
167
+
168
+ if (link.github.number !== null && store.byGitHubNumber.has(link.github.number)) {
169
+ registerMatch(store.byGitHubNumber.get(link.github.number), 2);
170
+ }
171
+
172
+ return [...matches.entries()].map(([record, priority]) => ({ record, priority }));
173
+ }
174
+
175
+ function mergeCandidateEntries(candidates, fieldName) {
176
+ let mergedEntries = [];
177
+
178
+ for (const candidate of candidates) {
179
+ mergedEntries = mergeUniqueEntries(mergedEntries, candidate.record[fieldName] ?? []);
180
+ }
181
+
182
+ return mergedEntries;
183
+ }
184
+
185
+ function mergeCanonicalLinkCandidates(candidates) {
186
+ const mergedRecord = createDefaultCanonicalLink();
187
+
188
+ for (const candidate of candidates) {
189
+ const { record } = candidate;
190
+
191
+ if (mergedRecord.forgeIssueId === null && record.forgeIssueId) {
192
+ mergedRecord.forgeIssueId = record.forgeIssueId;
193
+ }
194
+
195
+ if (mergedRecord.github.nodeId === null && record.github.nodeId) {
196
+ mergedRecord.github.nodeId = record.github.nodeId;
197
+ }
198
+
199
+ if (mergedRecord.github.number === null && record.github.number !== null) {
200
+ mergedRecord.github.number = record.github.number;
201
+ }
202
+
203
+ if (mergedRecord.github.url === null && record.github.url) {
204
+ mergedRecord.github.url = record.github.url;
205
+ }
206
+ }
207
+
208
+ mergedRecord.sources = mergeCandidateEntries(candidates, 'sources');
209
+ mergedRecord.diagnostics = mergeCandidateEntries(candidates, 'diagnostics');
210
+
211
+ return mergedRecord;
212
+ }
213
+
214
+ function buildMergeCandidates(store, matchedRecords, incomingRecord) {
215
+ const existingOrder = new Map(store.records.map((record, index) => [record, index]));
216
+ const candidates = matchedRecords.map(({ record, priority }) => ({
217
+ record,
218
+ priority,
219
+ incoming: false,
220
+ order: existingOrder.get(record) ?? Number.MAX_SAFE_INTEGER,
221
+ }));
222
+
223
+ candidates.push({
224
+ record: incomingRecord,
225
+ priority: getLinkPriority(incomingRecord),
226
+ incoming: true,
227
+ order: Number.MAX_SAFE_INTEGER,
228
+ });
229
+
230
+ candidates.sort((left, right) => {
231
+ if (left.priority !== right.priority) {
232
+ return left.priority - right.priority;
233
+ }
234
+
235
+ if (left.incoming !== right.incoming) {
236
+ return left.incoming ? -1 : 1;
237
+ }
238
+
239
+ return left.order - right.order;
240
+ });
241
+
242
+ return candidates;
243
+ }
244
+
245
+ function upsertCanonicalLink(store, input) {
246
+ const incomingRecord = buildCanonicalLink(input);
247
+ const matchedRecords = collectMatchedRecords(store, incomingRecord);
248
+ const mergedRecord = mergeCanonicalLinkCandidates(
249
+ buildMergeCandidates(store, matchedRecords, incomingRecord),
250
+ );
251
+
252
+ for (const { record } of matchedRecords) {
253
+ removeRecord(store, record);
254
+ }
255
+
256
+ store.records.push(mergedRecord);
257
+ indexRecord(store, mergedRecord);
258
+
259
+ return mergedRecord;
260
+ }
261
+
262
+ function resolveCanonicalLink(store, lookup = {}) {
263
+ if (lookup.forgeIssueId && store.byForgeIssueId.has(lookup.forgeIssueId)) {
264
+ return store.byForgeIssueId.get(lookup.forgeIssueId);
265
+ }
266
+
267
+ if (lookup.githubNodeId && store.byGitHubNodeId.has(lookup.githubNodeId)) {
268
+ return store.byGitHubNodeId.get(lookup.githubNodeId);
269
+ }
270
+
271
+ const githubNumber = normalizeGitHubNumber(lookup.githubNumber);
272
+ if (githubNumber !== null && store.byGitHubNumber.has(githubNumber)) {
273
+ return store.byGitHubNumber.get(githubNumber);
274
+ }
275
+
276
+ return null;
277
+ }
278
+
279
+ function listCanonicalLinks(store) {
280
+ return store.records.map(cloneValue);
281
+ }
282
+
283
+ module.exports = {
284
+ buildCanonicalLink,
285
+ createDefaultCanonicalLink,
286
+ createLinkStore,
287
+ getLinkPriority,
288
+ listCanonicalLinks,
289
+ normalizeGitHubNumber,
290
+ resolveCanonicalLink,
291
+ upsertCanonicalLink,
292
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const { isSharedField } = require('./authority.js');
4
+
5
+ const FLAG_TO_SHARED_FIELD = new Map([
6
+ ['--title', 'shared.title'],
7
+ ['--body', 'shared.body'],
8
+ ['--state', 'shared.state'],
9
+ ['--assignee', 'shared.assignees'],
10
+ ['--assignees', 'shared.assignees'],
11
+ ['--label', 'shared.labels'],
12
+ ['--labels', 'shared.labels'],
13
+ ['--milestone', 'shared.milestone'],
14
+ ['--claim', 'shared.assignees'],
15
+ ]);
16
+
17
+ const OPERATIONS_WITH_DEFAULT_SHARED_FIELDS = new Map([
18
+ ['close', ['shared.state']],
19
+ ]);
20
+
21
+ function uniquePush(items, value) {
22
+ if (value && !items.includes(value)) {
23
+ items.push(value);
24
+ }
25
+ }
26
+
27
+ function isHelpInvocation(args = []) {
28
+ return Array.isArray(args) && args.some(arg => arg === '--help' || arg === '-h');
29
+ }
30
+
31
+ function appendDefaultSharedFields(operation, fieldPaths) {
32
+ for (const defaultField of OPERATIONS_WITH_DEFAULT_SHARED_FIELDS.get(operation) || []) {
33
+ if (isSharedField(defaultField)) {
34
+ uniquePush(fieldPaths, defaultField);
35
+ }
36
+ }
37
+ }
38
+
39
+ function inferCreateTitleField(operation, args = []) {
40
+ if (operation !== 'create' || !Array.isArray(args) || args.length === 0) {
41
+ return null;
42
+ }
43
+
44
+ const firstNonFlagArg = args.find(arg => typeof arg === 'string' && !arg.startsWith('-'));
45
+ if (firstNonFlagArg === undefined) {
46
+ return null;
47
+ }
48
+
49
+ return 'shared.title';
50
+ }
51
+
52
+ function collectSharedFieldsFromFlags(args = []) {
53
+ const fieldPaths = [];
54
+
55
+ for (let index = 0; index < args.length; index += 1) {
56
+ const arg = args[index];
57
+ if (typeof arg !== 'string') {
58
+ continue;
59
+ }
60
+
61
+ const flagKey = arg.startsWith('--') ? arg.split('=')[0] : arg;
62
+ const fieldPath = FLAG_TO_SHARED_FIELD.get(flagKey);
63
+ if (!fieldPath || !isSharedField(fieldPath)) {
64
+ continue;
65
+ }
66
+
67
+ uniquePush(fieldPaths, fieldPath);
68
+
69
+ if (flagKey === '--claim') {
70
+ continue;
71
+ }
72
+
73
+ if (flagKey.startsWith('--') && !arg.includes('=')) {
74
+ index += 1;
75
+ }
76
+ }
77
+
78
+ return fieldPaths;
79
+ }
80
+
81
+ function collectSharedFieldPaths(operation, args = []) {
82
+ if (typeof operation !== 'string' || operation.length === 0) {
83
+ return [];
84
+ }
85
+
86
+ if (isHelpInvocation(args)) {
87
+ return [];
88
+ }
89
+
90
+ const fieldPaths = [];
91
+ appendDefaultSharedFields(operation, fieldPaths);
92
+
93
+ if (!Array.isArray(args)) {
94
+ return fieldPaths;
95
+ }
96
+
97
+ uniquePush(fieldPaths, inferCreateTitleField(operation, args));
98
+ for (const fieldPath of collectSharedFieldsFromFlags(args)) {
99
+ uniquePush(fieldPaths, fieldPath);
100
+ }
101
+
102
+ return fieldPaths;
103
+ }
104
+
105
+ function createGitHubProjectionPlan(operation, args = []) {
106
+ const fieldPaths = collectSharedFieldPaths(operation, args);
107
+
108
+ if (fieldPaths.length === 0) {
109
+ return null;
110
+ }
111
+
112
+ return {
113
+ operation,
114
+ args: Array.isArray(args) ? [...args] : [],
115
+ fieldPaths,
116
+ };
117
+ }
118
+
119
+ module.exports = {
120
+ collectSharedFieldPaths,
121
+ createGitHubProjectionPlan,
122
+ isHelpInvocation,
123
+ };
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ const { buildSharedIssueRecord } = require('./schema.js');
4
+ const {
5
+ GITHUB_OWNED_FIELD_PATHS,
6
+ cloneValue,
7
+ } = require('./github-pull.js');
8
+
9
+ const MAX_DRIFT_HISTORY = 50;
10
+
11
+ function readField(record, fieldPath) {
12
+ switch (fieldPath) {
13
+ case 'github.number':
14
+ return record.github.number;
15
+ case 'github.nodeId':
16
+ return record.github.nodeId;
17
+ case 'github.url':
18
+ return record.github.url;
19
+ case 'shared.title':
20
+ return record.shared.title;
21
+ case 'shared.body':
22
+ return record.shared.body;
23
+ case 'shared.state':
24
+ return record.shared.state;
25
+ case 'shared.assignees':
26
+ return record.shared.assignees;
27
+ case 'shared.labels':
28
+ return record.shared.labels;
29
+ case 'shared.milestone':
30
+ return record.shared.milestone;
31
+ case 'sync.remoteUpdatedAt':
32
+ return record.sync.remoteUpdatedAt;
33
+ default:
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function writeField(record, fieldPath, value) {
39
+ switch (fieldPath) {
40
+ case 'github.number':
41
+ record.github.number = value;
42
+ break;
43
+ case 'github.nodeId':
44
+ record.github.nodeId = value;
45
+ break;
46
+ case 'github.url':
47
+ record.github.url = value;
48
+ break;
49
+ case 'shared.title':
50
+ record.shared.title = value;
51
+ break;
52
+ case 'shared.body':
53
+ record.shared.body = value;
54
+ break;
55
+ case 'shared.state':
56
+ record.shared.state = value;
57
+ break;
58
+ case 'shared.assignees':
59
+ record.shared.assignees = value;
60
+ break;
61
+ case 'shared.labels':
62
+ record.shared.labels = value;
63
+ break;
64
+ case 'shared.milestone':
65
+ record.shared.milestone = value;
66
+ break;
67
+ case 'sync.remoteUpdatedAt':
68
+ record.sync.remoteUpdatedAt = value;
69
+ break;
70
+ default:
71
+ break;
72
+ }
73
+ }
74
+
75
+ function isPlainObject(value) {
76
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
77
+ }
78
+
79
+ function deepEqual(left, right) {
80
+ if (left === right) {
81
+ return true;
82
+ }
83
+
84
+ if (Array.isArray(left) && Array.isArray(right)) {
85
+ if (left.length !== right.length) {
86
+ return false;
87
+ }
88
+
89
+ for (let index = 0; index < left.length; index += 1) {
90
+ if (!deepEqual(left[index], right[index])) {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ return true;
96
+ }
97
+
98
+ if (isPlainObject(left) && isPlainObject(right)) {
99
+ const leftKeys = Object.keys(left);
100
+ const rightKeys = Object.keys(right);
101
+
102
+ if (leftKeys.length !== rightKeys.length) {
103
+ return false;
104
+ }
105
+
106
+ for (const key of leftKeys) {
107
+ if (!Object.hasOwn(right, key) || !deepEqual(left[key], right[key])) {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ return true;
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ function createDriftDiagnostic(field, localValue, remoteValue) {
119
+ return {
120
+ type: 'github-shared-drift',
121
+ field,
122
+ localValue: cloneValue(localValue),
123
+ remoteValue: cloneValue(remoteValue),
124
+ };
125
+ }
126
+
127
+ function buildMaterializedIssueSnapshot(record) {
128
+ const snapshot = cloneValue(record);
129
+
130
+ if (snapshot?.cache) {
131
+ snapshot.cache.materializedIssue = null;
132
+ }
133
+
134
+ return snapshot;
135
+ }
136
+
137
+ function mergeDriftDiagnostics(existingDiagnostics = [], newDiagnostics = []) {
138
+ const merged = [];
139
+ const seen = new Set();
140
+
141
+ for (const diagnostic of [...existingDiagnostics, ...newDiagnostics]) {
142
+ const clonedDiagnostic = cloneValue(diagnostic);
143
+ const key = JSON.stringify(clonedDiagnostic);
144
+ if (seen.has(key)) {
145
+ continue;
146
+ }
147
+
148
+ seen.add(key);
149
+ merged.push(clonedDiagnostic);
150
+ }
151
+
152
+ return merged.slice(-MAX_DRIFT_HISTORY);
153
+ }
154
+
155
+ function reconcileSharedIssueRecord(localRecord = {}, remoteSnapshot = {}, options = {}) {
156
+ const existingRecord = buildSharedIssueRecord(localRecord);
157
+ const remoteRecord = buildSharedIssueRecord(remoteSnapshot);
158
+ const reconciledRecord = buildSharedIssueRecord(existingRecord);
159
+ const diagnostics = [];
160
+
161
+ for (const fieldPath of GITHUB_OWNED_FIELD_PATHS) {
162
+ const localValue = readField(existingRecord, fieldPath);
163
+ const remoteValue = readField(remoteRecord, fieldPath);
164
+
165
+ if (!deepEqual(localValue, remoteValue)) {
166
+ diagnostics.push(createDriftDiagnostic(fieldPath, localValue, remoteValue));
167
+ }
168
+
169
+ writeField(reconciledRecord, fieldPath, cloneValue(remoteValue));
170
+ }
171
+
172
+ // Some import/materialization flows need to retain the last local pull
173
+ // watermark instead of overwriting it with the fresh GitHub timestamp.
174
+ if (options.preserveRemoteUpdatedAt === false && existingRecord.sync.remoteUpdatedAt !== null) {
175
+ reconciledRecord.sync.remoteUpdatedAt = existingRecord.sync.remoteUpdatedAt;
176
+ }
177
+
178
+ reconciledRecord.sync.drift = mergeDriftDiagnostics(existingRecord.sync.drift ?? [], diagnostics);
179
+ reconciledRecord.cache.githubSnapshot = cloneValue(remoteSnapshot);
180
+ reconciledRecord.cache.materializedIssue = buildMaterializedIssueSnapshot(reconciledRecord);
181
+
182
+ return {
183
+ record: reconciledRecord,
184
+ diagnostics,
185
+ };
186
+ }
187
+
188
+ module.exports = {
189
+ buildMaterializedIssueSnapshot,
190
+ createDriftDiagnostic,
191
+ deepEqual,
192
+ reconcileSharedIssueRecord,
193
+ readField,
194
+ writeField,
195
+ };