openplanr 1.2.8 → 1.3.0

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 (145) hide show
  1. package/README.md +7 -3
  2. package/dist/agents/task-parser.d.ts.map +1 -1
  3. package/dist/agents/task-parser.js +8 -34
  4. package/dist/agents/task-parser.js.map +1 -1
  5. package/dist/ai/prompts/system-prompts.d.ts +2 -2
  6. package/dist/ai/prompts/system-prompts.d.ts.map +1 -1
  7. package/dist/ai/prompts/system-prompts.js +7 -7
  8. package/dist/ai/schemas/ai-response-schemas.js +1 -1
  9. package/dist/ai/schemas/ai-response-schemas.js.map +1 -1
  10. package/dist/ai/types.d.ts.map +1 -1
  11. package/dist/ai/types.js +2 -0
  12. package/dist/ai/types.js.map +1 -1
  13. package/dist/cli/commands/backlog.d.ts +12 -0
  14. package/dist/cli/commands/backlog.d.ts.map +1 -1
  15. package/dist/cli/commands/backlog.js +88 -2
  16. package/dist/cli/commands/backlog.js.map +1 -1
  17. package/dist/cli/commands/config.d.ts.map +1 -1
  18. package/dist/cli/commands/config.js +8 -2
  19. package/dist/cli/commands/config.js.map +1 -1
  20. package/dist/cli/commands/linear.d.ts +8 -0
  21. package/dist/cli/commands/linear.d.ts.map +1 -0
  22. package/dist/cli/commands/linear.js +550 -0
  23. package/dist/cli/commands/linear.js.map +1 -0
  24. package/dist/cli/commands/quick.d.ts +17 -0
  25. package/dist/cli/commands/quick.d.ts.map +1 -1
  26. package/dist/cli/commands/quick.js +31 -15
  27. package/dist/cli/commands/quick.js.map +1 -1
  28. package/dist/cli/commands/revise.d.ts +9 -8
  29. package/dist/cli/commands/revise.d.ts.map +1 -1
  30. package/dist/cli/commands/revise.js +93 -25
  31. package/dist/cli/commands/revise.js.map +1 -1
  32. package/dist/cli/index.js +2 -0
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/models/schema.d.ts +43 -0
  35. package/dist/models/schema.d.ts.map +1 -1
  36. package/dist/models/schema.js +49 -0
  37. package/dist/models/schema.js.map +1 -1
  38. package/dist/models/types.d.ts +179 -3
  39. package/dist/models/types.d.ts.map +1 -1
  40. package/dist/services/artifact-gathering.d.ts +4 -0
  41. package/dist/services/artifact-gathering.d.ts.map +1 -1
  42. package/dist/services/artifact-gathering.js +1 -1
  43. package/dist/services/artifact-gathering.js.map +1 -1
  44. package/dist/services/artifact-service.d.ts +12 -1
  45. package/dist/services/artifact-service.d.ts.map +1 -1
  46. package/dist/services/artifact-service.js +49 -6
  47. package/dist/services/artifact-service.js.map +1 -1
  48. package/dist/services/atomic-write-service.d.ts +2 -2
  49. package/dist/services/atomic-write-service.js +2 -2
  50. package/dist/services/audit-log-service.d.ts +3 -6
  51. package/dist/services/audit-log-service.d.ts.map +1 -1
  52. package/dist/services/audit-log-service.js +4 -7
  53. package/dist/services/audit-log-service.js.map +1 -1
  54. package/dist/services/cascade-service.d.ts +2 -2
  55. package/dist/services/cascade-service.js +3 -3
  56. package/dist/services/cascade-service.js.map +1 -1
  57. package/dist/services/credentials-service.js +2 -2
  58. package/dist/services/credentials-service.js.map +1 -1
  59. package/dist/services/diff-service.d.ts +1 -1
  60. package/dist/services/diff-service.js +1 -1
  61. package/dist/services/evidence-verifier.d.ts +1 -1
  62. package/dist/services/evidence-verifier.d.ts.map +1 -1
  63. package/dist/services/evidence-verifier.js +5 -2
  64. package/dist/services/evidence-verifier.js.map +1 -1
  65. package/dist/services/git-service.d.ts +4 -4
  66. package/dist/services/git-service.js +4 -4
  67. package/dist/services/graph-integrity.d.ts +2 -3
  68. package/dist/services/graph-integrity.d.ts.map +1 -1
  69. package/dist/services/graph-integrity.js +2 -3
  70. package/dist/services/graph-integrity.js.map +1 -1
  71. package/dist/services/linear/body-formatters.d.ts +69 -0
  72. package/dist/services/linear/body-formatters.d.ts.map +1 -0
  73. package/dist/services/linear/body-formatters.js +183 -0
  74. package/dist/services/linear/body-formatters.js.map +1 -0
  75. package/dist/services/linear/constants.d.ts +61 -0
  76. package/dist/services/linear/constants.d.ts.map +1 -0
  77. package/dist/services/linear/constants.js +84 -0
  78. package/dist/services/linear/constants.js.map +1 -0
  79. package/dist/services/linear/errors.d.ts +14 -0
  80. package/dist/services/linear/errors.d.ts.map +1 -0
  81. package/dist/services/linear/errors.js +106 -0
  82. package/dist/services/linear/errors.js.map +1 -0
  83. package/dist/services/linear/estimate-resolver.d.ts +50 -0
  84. package/dist/services/linear/estimate-resolver.d.ts.map +1 -0
  85. package/dist/services/linear/estimate-resolver.js +82 -0
  86. package/dist/services/linear/estimate-resolver.js.map +1 -0
  87. package/dist/services/linear/plan-builders.d.ts +64 -0
  88. package/dist/services/linear/plan-builders.d.ts.map +1 -0
  89. package/dist/services/linear/plan-builders.js +237 -0
  90. package/dist/services/linear/plan-builders.js.map +1 -0
  91. package/dist/services/linear/scope-loaders.d.ts +79 -0
  92. package/dist/services/linear/scope-loaders.d.ts.map +1 -0
  93. package/dist/services/linear/scope-loaders.js +227 -0
  94. package/dist/services/linear/scope-loaders.js.map +1 -0
  95. package/dist/services/linear/strategy-context.d.ts +66 -0
  96. package/dist/services/linear/strategy-context.d.ts.map +1 -0
  97. package/dist/services/linear/strategy-context.js +121 -0
  98. package/dist/services/linear/strategy-context.js.map +1 -0
  99. package/dist/services/linear-mapping-service.d.ts +11 -0
  100. package/dist/services/linear-mapping-service.d.ts.map +1 -0
  101. package/dist/services/linear-mapping-service.js +220 -0
  102. package/dist/services/linear-mapping-service.js.map +1 -0
  103. package/dist/services/linear-pull-service.d.ts +137 -0
  104. package/dist/services/linear-pull-service.d.ts.map +1 -0
  105. package/dist/services/linear-pull-service.js +720 -0
  106. package/dist/services/linear-pull-service.js.map +1 -0
  107. package/dist/services/linear-push-service.d.ts +86 -0
  108. package/dist/services/linear-push-service.d.ts.map +1 -0
  109. package/dist/services/linear-push-service.js +956 -0
  110. package/dist/services/linear-push-service.js.map +1 -0
  111. package/dist/services/linear-service.d.ts +122 -0
  112. package/dist/services/linear-service.d.ts.map +1 -0
  113. package/dist/services/linear-service.js +361 -0
  114. package/dist/services/linear-service.js.map +1 -0
  115. package/dist/services/prompt-service.d.ts +19 -0
  116. package/dist/services/prompt-service.d.ts.map +1 -1
  117. package/dist/services/prompt-service.js +64 -0
  118. package/dist/services/prompt-service.js.map +1 -1
  119. package/dist/services/revise-apply-service.d.ts +55 -0
  120. package/dist/services/revise-apply-service.d.ts.map +1 -0
  121. package/dist/services/revise-apply-service.js +255 -0
  122. package/dist/services/revise-apply-service.js.map +1 -0
  123. package/dist/services/revise-cache-service.d.ts +1 -1
  124. package/dist/services/revise-cache-service.js +1 -1
  125. package/dist/services/revise-plan-service.d.ts +38 -0
  126. package/dist/services/revise-plan-service.d.ts.map +1 -0
  127. package/dist/services/revise-plan-service.js +151 -0
  128. package/dist/services/revise-plan-service.js.map +1 -0
  129. package/dist/services/revise-service.d.ts +18 -11
  130. package/dist/services/revise-service.d.ts.map +1 -1
  131. package/dist/services/revise-service.js +57 -12
  132. package/dist/services/revise-service.js.map +1 -1
  133. package/dist/services/template-sections.d.ts +1 -1
  134. package/dist/services/template-sections.js +1 -1
  135. package/dist/templates/backlog/backlog-item.md.hbs +3 -0
  136. package/dist/templates/quick/quick-task.md.hbs +6 -0
  137. package/dist/utils/diff.d.ts +22 -1
  138. package/dist/utils/diff.d.ts.map +1 -1
  139. package/dist/utils/diff.js +136 -1
  140. package/dist/utils/diff.js.map +1 -1
  141. package/dist/utils/markdown.d.ts +23 -0
  142. package/dist/utils/markdown.d.ts.map +1 -1
  143. package/dist/utils/markdown.js +79 -0
  144. package/dist/utils/markdown.js.map +1 -1
  145. package/package.json +3 -2
@@ -0,0 +1,720 @@
1
+ /**
2
+ * Linear → OpenPlanr pull-direction sync.
3
+ *
4
+ * Two concerns consolidated here because both are strictly pull and share
5
+ * the same client lifecycle and auth surface:
6
+ *
7
+ * 1. **Workflow-status sync**: for Features and Stories with a stored
8
+ * `linearIssueId`, fetch the current Linear workflow state name and
9
+ * write OpenPlanr `status` frontmatter when mapped.
10
+ *
11
+ * 2. **Task checkbox sync**: bidirectional 3-way merge between local
12
+ * TASK markdown and Linear TaskList issue description bodies.
13
+ * Pull-side lives here; push-side lives in `linear-push-service.ts`.
14
+ *
15
+ * Keeping them in one module reduces call-site noise for
16
+ * `planr linear sync` and gives the next reader one file to understand
17
+ * everything that pulls state from Linear.
18
+ */
19
+ import { parseTaskMarkdown } from '../agents/task-parser.js';
20
+ import { isVerbose, logger } from '../utils/logger.js';
21
+ import { applyTaskCheckboxStateMap, parseTaskCheckboxLines, parseTaskCheckboxReconciled, serializeTaskCheckboxReconciled, } from '../utils/markdown.js';
22
+ import { listArtifacts, readArtifact, readArtifactRaw, updateArtifact, updateArtifactFields, } from './artifact-service.js';
23
+ import { isNonInteractive } from './interactive-state.js';
24
+ import { ensureAutoStateIdMap, ensureTeamEstimationType, formatTaskCheckboxBody, getAutoStateIdMap, resolveBacklogStateIdForPush, resolveTaskStateIdForPush, } from './linear-push-service.js';
25
+ import { fetchLinearIssueStateNames, getLinearIssueDescription, isLikelyLinearIssueId, isLikelyLinearWorkflowStateId, updateLinearIssue, } from './linear-service.js';
26
+ import { promptSelect } from './prompt-service.js';
27
+ // ---------------------------------------------------------------------------
28
+ // Workflow-status sync
29
+ // ---------------------------------------------------------------------------
30
+ function asTaskStatus(s) {
31
+ if (s === 'pending' || s === 'in-progress' || s === 'done')
32
+ return s;
33
+ return 'pending';
34
+ }
35
+ function normalizeStateKey(name) {
36
+ return name.trim().toLowerCase();
37
+ }
38
+ /**
39
+ * Default Linear workflow state name → OpenPlanr `TaskStatus` (case-insensitive keys).
40
+ * User `linear.statusMap` overrides/extends these.
41
+ */
42
+ const DEFAULT_LINEAR_STATE_TO_OP = [
43
+ ['backlog', 'pending'],
44
+ ['triage', 'pending'],
45
+ ['unstarted', 'pending'],
46
+ ['todo', 'pending'],
47
+ ['canceled', 'done'],
48
+ ['cancelled', 'done'],
49
+ ['done', 'done'],
50
+ ['completed', 'done'],
51
+ ['in progress', 'in-progress'],
52
+ ['in development', 'in-progress'],
53
+ ['in review', 'in-progress'],
54
+ ];
55
+ export function buildNameToStatusMap(user) {
56
+ const m = new Map();
57
+ for (const [k, v] of DEFAULT_LINEAR_STATE_TO_OP) {
58
+ m.set(normalizeStateKey(k), v);
59
+ }
60
+ if (user) {
61
+ for (const [linearName, raw] of Object.entries(user)) {
62
+ if (isLikelyLinearWorkflowStateId(raw))
63
+ continue;
64
+ if (raw === 'pending' || raw === 'in-progress' || raw === 'done') {
65
+ m.set(normalizeStateKey(linearName), raw);
66
+ }
67
+ }
68
+ }
69
+ return m;
70
+ }
71
+ export function mapLinearNameToTaskStatus(stateName, byName) {
72
+ return byName.get(normalizeStateKey(stateName));
73
+ }
74
+ /**
75
+ * Linear state name → BacklogStatus. Backlog items use a different vocabulary
76
+ * from tasks/stories/features: any "in flight" Linear state (Todo, In Progress,
77
+ * In Review) maps to `open`, while Linear "done" states (Done, Completed,
78
+ * Canceled) map to `closed`. We deliberately never emit `promoted` from the
79
+ * pull path — that state implies a local target pointer we don't have from
80
+ * Linear. User `linear.statusMap` can override defaults.
81
+ */
82
+ const DEFAULT_LINEAR_STATE_TO_BACKLOG = [
83
+ ['backlog', 'open'],
84
+ ['triage', 'open'],
85
+ ['unstarted', 'open'],
86
+ ['todo', 'open'],
87
+ ['in progress', 'open'],
88
+ ['in development', 'open'],
89
+ ['in review', 'open'],
90
+ ['canceled', 'closed'],
91
+ ['cancelled', 'closed'],
92
+ ['done', 'closed'],
93
+ ['completed', 'closed'],
94
+ ];
95
+ export function buildNameToBacklogStatusMap(user) {
96
+ const m = new Map();
97
+ for (const [k, v] of DEFAULT_LINEAR_STATE_TO_BACKLOG) {
98
+ m.set(normalizeStateKey(k), v);
99
+ }
100
+ if (user) {
101
+ for (const [linearName, raw] of Object.entries(user)) {
102
+ if (isLikelyLinearWorkflowStateId(raw))
103
+ continue;
104
+ if (raw === 'open' || raw === 'closed' || raw === 'promoted') {
105
+ m.set(normalizeStateKey(linearName), raw);
106
+ }
107
+ }
108
+ }
109
+ return m;
110
+ }
111
+ export function mapLinearNameToBacklogStatus(stateName, byName) {
112
+ return byName.get(normalizeStateKey(stateName));
113
+ }
114
+ /**
115
+ * Pure three-way merge decision for a single artifact's workflow status.
116
+ *
117
+ * Mirrors `resolveTaskCheckboxFinalStates` but on a scalar. Returns
118
+ * `side='unchanged'` when local and remote already agree (counter path),
119
+ * `side='linear'` when the remote changed and should be pulled, `side='local'`
120
+ * when the local changed and should be pushed back to Linear. True
121
+ * conflicts (both diverged from base, or no base and they disagree) are
122
+ * resolved per `strategy`.
123
+ */
124
+ export function resolveStatusFinalState(c, strategy) {
125
+ const { base, local, remote } = c;
126
+ // Fast path: nothing to do.
127
+ if (local === remote) {
128
+ return { final: local, side: 'unchanged', conflictDecisions: 0, isTrueConflict: false };
129
+ }
130
+ // Only one side diverged from base — propagate its change.
131
+ if (base !== undefined) {
132
+ if (base === local && local !== remote) {
133
+ // Linear changed since last sync → pull.
134
+ return { final: remote, side: 'linear', conflictDecisions: 0, isTrueConflict: false };
135
+ }
136
+ if (base === remote && local !== remote) {
137
+ // Local changed since last sync → push.
138
+ return { final: local, side: 'local', conflictDecisions: 0, isTrueConflict: false };
139
+ }
140
+ }
141
+ // True conflict: both changed, or no base and they disagree.
142
+ // Strategy dictates which wins; we always record a conflict decision so
143
+ // the summary + audit log reflect reality.
144
+ if (strategy === 'local') {
145
+ return { final: local, side: 'local', conflictDecisions: 1, isTrueConflict: true };
146
+ }
147
+ if (strategy === 'linear') {
148
+ return { final: remote, side: 'linear', conflictDecisions: 1, isTrueConflict: true };
149
+ }
150
+ // strategy === 'prompt': caller resolves interactively after seeing the
151
+ // conflict. Report the true-conflict marker so the sync loop can dispatch
152
+ // to promptSelect and increment the counter.
153
+ return { final: local, side: 'local', conflictDecisions: 1, isTrueConflict: true };
154
+ }
155
+ export async function syncLinearStatusIntoArtifacts(projectDir, config, client, options) {
156
+ const dryRun = options?.dryRun === true;
157
+ const strategy = options?.onConflict ?? 'prompt';
158
+ const summary = {
159
+ updated: 0,
160
+ pushedToLinear: 0,
161
+ unchanged: 0,
162
+ conflictDecisions: 0,
163
+ unmapped: 0,
164
+ skippedNoId: 0,
165
+ missingFromApi: 0,
166
+ pushFailures: 0,
167
+ };
168
+ const teamId = config.linear?.teamId;
169
+ if (!teamId) {
170
+ // Without a team id we can't push back. Fall back to pull-only behavior
171
+ // — the rest of the function handles `side: 'local'` as best-effort
172
+ // (skipping the API call when teamId is missing).
173
+ logger.debug('linear sync: no linear.teamId configured; skipping push-back path');
174
+ }
175
+ else {
176
+ // Prime the auto-derived state-id map + estimation-type cache the same
177
+ // way `runLinearPush` does, so conflict resolutions that flip to `local`
178
+ // can call `updateLinearIssue(stateId)` without a separate fetch.
179
+ await ensureAutoStateIdMap(client, teamId);
180
+ await ensureTeamEstimationType(client, teamId);
181
+ }
182
+ const byNameTask = buildNameToStatusMap(config.linear?.statusMap);
183
+ const byNameBacklog = buildNameToBacklogStatusMap(config.linear?.statusMap);
184
+ const tracked = [];
185
+ for (const type of ['feature', 'story', 'quick', 'backlog']) {
186
+ const list = await listArtifacts(projectDir, config, type);
187
+ for (const row of list) {
188
+ const art = await readArtifact(projectDir, config, type, row.id);
189
+ if (!art)
190
+ continue;
191
+ const linearId = art.data.linearIssueId;
192
+ if (!linearId) {
193
+ logger.debug(`linear sync: skip ${row.id} (no linearIssueId in frontmatter)`);
194
+ summary.skippedNoId++;
195
+ continue;
196
+ }
197
+ // Same validation as checkbox sync: don't feed malformed ids to the API.
198
+ if (!isLikelyLinearIssueId(linearId)) {
199
+ logger.warn(`${type} ${row.id}: linearIssueId "${linearId}" is not a valid Linear id (expected uuid or \`ENG-42\` identifier). Skipping status sync — re-run \`planr linear push\` to repair.`);
200
+ summary.skippedNoId++;
201
+ continue;
202
+ }
203
+ // For backlog items we keep the raw status so the `promoted` guard and
204
+ // `open/closed/promoted` comparisons stay honest. For the other types,
205
+ // the task-status coercion preserves existing behavior.
206
+ const localStatus = type === 'backlog' ? String(art.data.status ?? 'open') : asTaskStatus(art.data.status);
207
+ const rawBase = art.data.linearStatusReconciled;
208
+ const baseStatus = typeof rawBase === 'string' && rawBase.trim().length > 0 ? rawBase.trim() : undefined;
209
+ tracked.push({ type, id: row.id, linearIssueId: linearId, localStatus, baseStatus });
210
+ }
211
+ }
212
+ if (tracked.length === 0) {
213
+ return summary;
214
+ }
215
+ const ids = tracked.map((t) => t.linearIssueId);
216
+ const fromLinear = await fetchLinearIssueStateNames(client, ids);
217
+ const autoResolvedConflicts = [];
218
+ for (const t of tracked) {
219
+ const stateName = fromLinear.get(t.linearIssueId);
220
+ if (stateName === undefined) {
221
+ logger.warn(`linear sync: issue ${t.linearIssueId} (${t.type} ${t.id}) not returned by Linear (deleted or no access) — left unchanged.`);
222
+ summary.missingFromApi++;
223
+ continue;
224
+ }
225
+ const mapped = t.type === 'backlog'
226
+ ? mapLinearNameToBacklogStatus(stateName, byNameBacklog)
227
+ : mapLinearNameToTaskStatus(stateName, byNameTask);
228
+ if (mapped === undefined) {
229
+ logger.warn(`linear sync: unmapped Linear state "${stateName}" for ${t.type} ${t.id} — left unchanged.`);
230
+ summary.unmapped++;
231
+ continue;
232
+ }
233
+ // `promoted` is a local-only BL state (the BL was promoted to a QT/story
234
+ // with a target pointer recorded in the BL body). Linear has no equivalent
235
+ // signal, so pulling `Done` back would destroy the `promoted → target`
236
+ // linkage. Treat as unchanged.
237
+ if (t.type === 'backlog' && t.localStatus === 'promoted') {
238
+ if (isVerbose()) {
239
+ logger.debug(`linear sync: backlog ${t.id} kept as "promoted" (local-only state)`);
240
+ }
241
+ summary.unchanged++;
242
+ continue;
243
+ }
244
+ // Three-way merge: local vs remote vs base (`linearStatusReconciled`).
245
+ const decision = resolveStatusFinalState({ base: t.baseStatus, local: t.localStatus, remote: mapped }, strategy);
246
+ if (decision.side === 'unchanged') {
247
+ if (isVerbose()) {
248
+ logger.debug(`linear sync: ${t.type} ${t.id} unchanged (${mapped})`);
249
+ }
250
+ summary.unchanged++;
251
+ continue;
252
+ }
253
+ // For true conflicts under `prompt`, ask the user. The pure helper
254
+ // returned a placeholder; `promptSelect` picks the real winner here.
255
+ let resolvedSide = decision.side;
256
+ let resolvedFinal = decision.final;
257
+ if (decision.isTrueConflict) {
258
+ summary.conflictDecisions++;
259
+ const label = `${t.type} ${t.id}`;
260
+ if (strategy === 'prompt' && !isNonInteractive()) {
261
+ const picked = await promptSelect(`${label}: status conflict (base=${t.baseStatus ?? '—'} local=${t.localStatus} remote=${mapped}). Use which side?`, [
262
+ { name: 'Local file', value: 'local' },
263
+ { name: 'Linear', value: 'linear' },
264
+ ], 'linear');
265
+ resolvedSide = picked;
266
+ resolvedFinal = picked === 'local' ? t.localStatus : mapped;
267
+ }
268
+ else if (strategy === 'prompt' && isNonInteractive()) {
269
+ logger.dim(` [auto] ${label} status conflict (base=${t.baseStatus ?? '—'} local=${t.localStatus} remote=${mapped}): using Linear (set --on-conflict local|linear to override)`);
270
+ resolvedSide = 'linear';
271
+ resolvedFinal = mapped;
272
+ autoResolvedConflicts.push({
273
+ kind: t.type,
274
+ id: t.id,
275
+ base: t.baseStatus,
276
+ local: t.localStatus,
277
+ remote: mapped,
278
+ chosen: 'linear',
279
+ timestamp: new Date().toISOString(),
280
+ });
281
+ }
282
+ }
283
+ // Apply the resolution.
284
+ if (resolvedSide === 'linear') {
285
+ // Pull: write local frontmatter to match Linear.
286
+ if (!dryRun) {
287
+ await updateArtifactFields(projectDir, config, t.type, t.id, {
288
+ status: resolvedFinal,
289
+ linearStatusReconciled: resolvedFinal,
290
+ linearStatusSyncedAt: new Date().toISOString(),
291
+ });
292
+ }
293
+ if (isVerbose()) {
294
+ logger.debug(`linear sync: ${t.type} ${t.id} pulled Linear → local: ${t.localStatus} → ${resolvedFinal} (Linear: "${stateName}")`);
295
+ }
296
+ summary.updated++;
297
+ }
298
+ else {
299
+ // Push: write Linear's state to match local.
300
+ if (!teamId) {
301
+ logger.warn(`linear sync: ${t.type} ${t.id} has local change (${t.localStatus}) but no linear.teamId configured — skipping push-back.`);
302
+ summary.pushFailures++;
303
+ continue;
304
+ }
305
+ const stateId = t.type === 'backlog'
306
+ ? resolveBacklogStateIdForPush(config, resolvedFinal, getAutoStateIdMap(client))
307
+ : resolveTaskStateIdForPush(config, resolvedFinal, getAutoStateIdMap(client));
308
+ if (!stateId) {
309
+ logger.warn(`linear sync: ${t.type} ${t.id} local status "${resolvedFinal}" has no matching Linear state (check linear.pushStateIds or team workflow states). Skipping push-back.`);
310
+ summary.pushFailures++;
311
+ continue;
312
+ }
313
+ if (!dryRun) {
314
+ try {
315
+ await updateLinearIssue(client, t.linearIssueId, { stateId });
316
+ await updateArtifactFields(projectDir, config, t.type, t.id, {
317
+ linearStatusReconciled: resolvedFinal,
318
+ linearStatusSyncedAt: new Date().toISOString(),
319
+ });
320
+ }
321
+ catch (err) {
322
+ logger.warn(`linear sync: push-back failed for ${t.type} ${t.id} (${err.message}) — local frontmatter unchanged, will retry on next sync.`);
323
+ summary.pushFailures++;
324
+ continue;
325
+ }
326
+ }
327
+ if (isVerbose()) {
328
+ logger.debug(`linear sync: ${t.type} ${t.id} pushed local → Linear: ${resolvedFinal} (was Linear: "${stateName}")`);
329
+ }
330
+ summary.pushedToLinear++;
331
+ }
332
+ }
333
+ if (!dryRun && autoResolvedConflicts.length > 0) {
334
+ await appendStatusSyncConflictAudit(projectDir, autoResolvedConflicts);
335
+ }
336
+ return summary;
337
+ }
338
+ async function appendStatusSyncConflictAudit(projectDir, entries) {
339
+ const { appendFile, mkdir } = await import('node:fs/promises');
340
+ const path = await import('node:path');
341
+ const { existsSync } = await import('node:fs');
342
+ const today = new Date().toISOString().slice(0, 10);
343
+ const dir = path.join(projectDir, '.planr', 'reports');
344
+ await mkdir(dir, { recursive: true });
345
+ const file = path.join(dir, `linear-sync-conflicts-${today}.md`);
346
+ const isNew = !existsSync(file);
347
+ const fmt = (v) => (v === undefined ? '—' : v);
348
+ const rows = entries
349
+ .map((e) => `| ${e.timestamp} | status | ${e.kind} ${e.id} | ${fmt(e.base)} | ${e.local} | ${e.remote} | ${e.chosen} |`)
350
+ .join('\n');
351
+ const header = isNew
352
+ ? `# Linear sync conflict audit — ${today}\n\n> Auto-resolved conflicts from non-interactive \`planr linear sync\` runs. Each row is one artifact where local and Linear disagreed and the default resolution was picked without human confirmation.\n\n| timestamp | kind | artifact | base | local | remote | chosen |\n| --- | --- | --- | --- | --- | --- | --- |\n`
353
+ : '';
354
+ await appendFile(file, `${header}${rows}\n`, 'utf-8');
355
+ logger.dim(`Recorded ${entries.length} auto-resolved status conflict(s) to ${path.relative(projectDir, file)}`);
356
+ }
357
+ export function formatLinearStatusSyncLine(s) {
358
+ return `${s.updated} pulled from Linear, ${s.pushedToLinear} pushed to Linear, ${s.unchanged} unchanged, ${s.conflictDecisions} conflict(s), ${s.unmapped} unmapped, ${s.skippedNoId} skipped (no linearIssueId), ${s.missingFromApi} not returned by API${s.pushFailures > 0 ? `, ${s.pushFailures} push failure(s)` : ''}`;
359
+ }
360
+ function toDoneMap(parsed) {
361
+ return new Map(parsed.map((t) => [t.id, t.done]));
362
+ }
363
+ /**
364
+ * Rebuild a `ParsedSubtask` list in document order: local file order, then any ids only in remote, then apply `final` done flags.
365
+ */
366
+ export function mergeByIdForFormat(local, remote, final) {
367
+ const fromLocal = new Map(local.map((t) => [t.id, t]));
368
+ const out = [];
369
+ const used = new Set();
370
+ for (const t of local) {
371
+ if (!final.has(t.id) || used.has(t.id))
372
+ continue;
373
+ const d = final.get(t.id);
374
+ if (d === undefined)
375
+ continue;
376
+ out.push({ ...t, done: d });
377
+ used.add(t.id);
378
+ }
379
+ for (const t of remote) {
380
+ if (used.has(t.id) || !final.has(t.id))
381
+ continue;
382
+ const d = final.get(t.id);
383
+ if (d === undefined)
384
+ continue;
385
+ out.push({ ...(fromLocal.get(t.id) ?? t), done: d });
386
+ used.add(t.id);
387
+ }
388
+ return out;
389
+ }
390
+ /** Merged issue body: return text for an artifact’s section, or the whole body when a single file owns the issue. */
391
+ export function extractTaskSectionFromMergedDescription(merged, taskFileId, siblingFileCount) {
392
+ const token = `## ${taskFileId}`;
393
+ if (siblingFileCount === 1) {
394
+ if (!merged.includes('## ')) {
395
+ return merged.trim();
396
+ }
397
+ if (merged.includes(token)) {
398
+ return extractBlockAfterH2(merged, taskFileId);
399
+ }
400
+ return merged.trim();
401
+ }
402
+ if (merged.includes(token)) {
403
+ return extractBlockAfterH2(merged, taskFileId);
404
+ }
405
+ if (merged.includes('## ')) {
406
+ return '';
407
+ }
408
+ return merged.trim();
409
+ }
410
+ function extractBlockAfterH2(merged, taskFileId) {
411
+ const token = `## ${taskFileId}`;
412
+ const idx = merged.indexOf(token);
413
+ if (idx === -1) {
414
+ return merged.trim();
415
+ }
416
+ const after = merged.slice(idx + token.length).replace(/^\n+/, '');
417
+ const nextH2 = after.search(/^## /m);
418
+ return (nextH2 === -1 ? after : after.slice(0, nextH2)).trim();
419
+ }
420
+ export function replaceTaskSectionInMergedDescription(merged, taskFileId, newSectionBody) {
421
+ if (!merged.includes('## ')) {
422
+ return newSectionBody.trim();
423
+ }
424
+ const token = `## ${taskFileId}`;
425
+ const idx = merged.indexOf(token);
426
+ if (idx === -1) {
427
+ return newSectionBody.trim();
428
+ }
429
+ const before = merged.slice(0, idx);
430
+ const afterHeader = merged.slice(idx + token.length);
431
+ const nextH2 = afterHeader.search(/^## /m);
432
+ const tail = nextH2 === -1 ? '' : afterHeader.slice(nextH2);
433
+ return `${before}${token}\n\n${newSectionBody.trim()}\n\n${tail}`.replace(/\n\n\n+/g, '\n\n');
434
+ }
435
+ /**
436
+ * Three-way merge for checkbox `id -> done` and presence. A key is **absent** in a version when the task line is not in that side’s parse.
437
+ * Exported for unit tests.
438
+ */
439
+ export async function resolveTaskCheckboxFinalStates(local, remote, base, strategy, label, onAutoResolve) {
440
+ const ids = new Set([...local.keys(), ...remote.keys(), ...base.keys()]);
441
+ const final = new Map();
442
+ let conflictDecisions = 0;
443
+ for (const id of ids) {
444
+ const lh = local.has(id);
445
+ const rh = remote.has(id);
446
+ const bh = base.has(id);
447
+ const l = lh ? local.get(id) : undefined;
448
+ const r = rh ? remote.get(id) : undefined;
449
+ const b = bh ? base.get(id) : undefined;
450
+ if (lh && rh && l === r) {
451
+ if (l !== undefined) {
452
+ final.set(id, l);
453
+ }
454
+ continue;
455
+ }
456
+ if (!lh && !rh) {
457
+ continue;
458
+ }
459
+ if (b === l && l !== r) {
460
+ if (r !== undefined) {
461
+ final.set(id, r);
462
+ }
463
+ continue;
464
+ }
465
+ if (b === r && l !== r) {
466
+ if (l !== undefined) {
467
+ final.set(id, l);
468
+ }
469
+ continue;
470
+ }
471
+ if (bh && lh && !rh && l !== b && l !== undefined) {
472
+ final.set(id, l);
473
+ continue;
474
+ }
475
+ if (bh && rh && !lh && r !== b && r !== undefined) {
476
+ final.set(id, r);
477
+ continue;
478
+ }
479
+ if (!bh && l !== undefined && r === undefined) {
480
+ final.set(id, l);
481
+ continue;
482
+ }
483
+ if (!bh && r !== undefined && l === undefined) {
484
+ final.set(id, r);
485
+ continue;
486
+ }
487
+ const choice = await pickConflict(strategy, { id, base: b, local: l, remote: r }, label);
488
+ if (l !== undefined && r !== undefined) {
489
+ final.set(id, choice === 'local' ? l : r);
490
+ }
491
+ else if (l !== undefined) {
492
+ final.set(id, l);
493
+ }
494
+ else if (r !== undefined) {
495
+ final.set(id, r);
496
+ }
497
+ conflictDecisions++;
498
+ // Record non-interactive auto-resolutions for the audit log. We
499
+ // only record when strategy was 'prompt' AND we're non-interactive (i.e.
500
+ // the default was picked without human input); explicit `--on-conflict
501
+ // local|linear` choices are user intent, not silent defaults.
502
+ if (onAutoResolve && strategy === 'prompt' && isNonInteractive()) {
503
+ onAutoResolve({
504
+ label,
505
+ id,
506
+ base: b,
507
+ local: l,
508
+ remote: r,
509
+ chosen: choice,
510
+ timestamp: new Date().toISOString(),
511
+ });
512
+ }
513
+ }
514
+ return { final, conflictDecisions };
515
+ }
516
+ async function pickConflict(strategy, c, label) {
517
+ if (strategy === 'local') {
518
+ if (c.local === undefined)
519
+ return 'linear';
520
+ return 'local';
521
+ }
522
+ if (strategy === 'linear') {
523
+ if (c.remote === undefined)
524
+ return 'local';
525
+ return 'linear';
526
+ }
527
+ if (isNonInteractive()) {
528
+ logger.dim(` [auto] ${label} task ${c.id} conflict: using Linear (set --on-conflict local|linear)`);
529
+ return c.remote !== undefined ? 'linear' : 'local';
530
+ }
531
+ const def = c.remote !== undefined ? 'linear' : 'local';
532
+ return promptSelect(`${label}: checkbox conflict on ${c.id} (base=${String(c.base)} local=${String(c.local)} remote=${String(c.remote)}). Use which side?`, [
533
+ { name: 'Local file', value: 'local' },
534
+ { name: 'Linear', value: 'linear' },
535
+ ], def);
536
+ }
537
+ const TASK_CHECKBOX = /^(\s*)- \[(x| )]\s+\*{0,2}(\d+\.\d+)\*{0,2}\s+(.+)$/;
538
+ /** Drop checkbox lines for ids that should be absent, apply done states, append new lines for ids in `rebuilt` that are still missing. */
539
+ export function applyCheckboxMergeToLocalBody(body, final, rebuilt) {
540
+ const lines = [];
541
+ for (const line of body.split('\n')) {
542
+ const m = line.match(TASK_CHECKBOX);
543
+ if (m) {
544
+ const id = m[3];
545
+ if (!final.has(id)) {
546
+ continue;
547
+ }
548
+ }
549
+ lines.push(line);
550
+ }
551
+ let out = lines.join('\n');
552
+ out = applyTaskCheckboxStateMap(out, final);
553
+ const present = new Set(parseTaskCheckboxLines(out).map((t) => t.id));
554
+ const toAdd = rebuilt.filter((t) => final.has(t.id) && !present.has(t.id));
555
+ if (toAdd.length === 0) {
556
+ return out;
557
+ }
558
+ const block = formatTaskCheckboxBody(toAdd);
559
+ if (!block) {
560
+ return out;
561
+ }
562
+ const trimmed = out.trimEnd();
563
+ return `${trimmed ? `${trimmed}\n\n` : ''}${block}\n`;
564
+ }
565
+ /**
566
+ * For each `task` artifact with `linearIssueId`, load the shared Linear description (once per issue id),
567
+ * reconcile checkboxes with the local file using three-way merge, then write back local and/or Linear.
568
+ */
569
+ export async function runLinearTaskCheckboxSync(projectDir, config, client, opts = {}) {
570
+ const onConflict = opts.onConflict ?? 'prompt';
571
+ const dryRun = opts.dryRun === true;
572
+ const summary = {
573
+ filesProcessed: 0,
574
+ filesUpdatedLocal: 0,
575
+ linearIssuesUpdated: 0,
576
+ conflictDecisions: 0,
577
+ skippedNoIssue: 0,
578
+ skippedStaleId: 0,
579
+ };
580
+ // Collect non-interactive auto-resolutions so we can audit them to
581
+ // disk after the run (CI-friendly — logger.dim lines don't survive long).
582
+ const autoResolvedConflicts = [];
583
+ const allTasks = await listArtifacts(projectDir, config, 'task');
584
+ const byIssue = new Map();
585
+ for (const t of allTasks) {
586
+ const a = await readArtifact(projectDir, config, 'task', t.id);
587
+ const issueId = a?.data.linearIssueId;
588
+ if (!issueId) {
589
+ continue;
590
+ }
591
+ // Catch both known corruption modes before calling the Linear API:
592
+ // (a) a workflow state UUID accidentally stored in the issue-id slot, and
593
+ // (b) any value that doesn't match a valid Linear issue form (UUID or `ENG-42`).
594
+ if (isLikelyLinearWorkflowStateId(issueId)) {
595
+ summary.skippedStaleId++;
596
+ logger.warn(`Task ${t.id}: linearIssueId "${issueId}" looks like a workflow state uuid, not an issue id. Re-run \`planr linear push\` to repair.`);
597
+ continue;
598
+ }
599
+ if (!isLikelyLinearIssueId(issueId)) {
600
+ summary.skippedStaleId++;
601
+ logger.warn(`Task ${t.id}: linearIssueId "${issueId}" is not a valid Linear issue id (expected uuid or \`ENG-42\` identifier). Re-run \`planr linear push\` to repair.`);
602
+ continue;
603
+ }
604
+ if (!byIssue.has(issueId)) {
605
+ byIssue.set(issueId, []);
606
+ }
607
+ const group = byIssue.get(issueId);
608
+ if (group) {
609
+ group.push(t.id);
610
+ }
611
+ }
612
+ for (const [issueId, taskIds] of byIssue) {
613
+ let merged = await getLinearIssueDescription(client, issueId);
614
+ const sortedFiles = [...taskIds].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
615
+ const siblingFileCount = sortedFiles.length;
616
+ let issueDirty = false;
617
+ for (const taskFileId of sortedFiles) {
618
+ summary.filesProcessed++;
619
+ const raw = await readArtifactRaw(projectDir, config, 'task', taskFileId);
620
+ if (!raw) {
621
+ continue;
622
+ }
623
+ const data = (await readArtifact(projectDir, config, 'task', taskFileId))?.data;
624
+ const li = data?.linearIssueId ?? issueId;
625
+ if (!li) {
626
+ summary.skippedNoIssue++;
627
+ continue;
628
+ }
629
+ const open = raw.indexOf('---');
630
+ const close = raw.indexOf('\n---', open + 3);
631
+ if (open === -1 || close === -1) {
632
+ continue;
633
+ }
634
+ const body = raw.slice(close + 4);
635
+ const localParsed = parseTaskMarkdown(body);
636
+ const localMap = toDoneMap(localParsed);
637
+ const section = extractTaskSectionFromMergedDescription(merged, taskFileId, siblingFileCount);
638
+ const remoteParsed = parseTaskMarkdown(section);
639
+ const remoteMap = toDoneMap(remoteParsed);
640
+ const baseStr = data?.linearChecklistReconciled ?? undefined;
641
+ const baseMap = parseTaskCheckboxReconciled(baseStr);
642
+ // A baseline was persisted but parses to ~nothing: it's likely
643
+ // corrupted (hand-edited frontmatter, truncated write, format drift).
644
+ // Warn because without a reliable base the 3-way merge degrades
645
+ // silently to a 2-way merge, losing the "last agreed" reference.
646
+ if (typeof baseStr === 'string' && baseStr.trim().length > 0) {
647
+ const expected = Math.max(localMap.size, remoteMap.size);
648
+ if (expected > 0 && baseMap.size * 2 < expected) {
649
+ logger.warn(`Task ${taskFileId}: linearChecklistReconciled looks corrupted (${baseMap.size} parsed vs ${expected} expected). Re-run \`planr linear push\` to restore the reconciliation baseline.`);
650
+ }
651
+ }
652
+ const { final, conflictDecisions: cd } = await resolveTaskCheckboxFinalStates(localMap, remoteMap, baseMap, onConflict, taskFileId, (entry) => autoResolvedConflicts.push(entry));
653
+ summary.conflictDecisions += cd;
654
+ const rebuilt = mergeByIdForFormat(localParsed, remoteParsed, final);
655
+ const newSection = formatTaskCheckboxBody(rebuilt);
656
+ const newBody = applyCheckboxMergeToLocalBody(body, final, rebuilt);
657
+ const merged2 = replaceTaskSectionInMergedDescription(merged, taskFileId, newSection);
658
+ if (merged2 !== merged) {
659
+ merged = merged2;
660
+ issueDirty = true;
661
+ }
662
+ if (newBody !== body) {
663
+ if (!dryRun) {
664
+ const newRaw = raw.slice(0, close + 4) + newBody;
665
+ await updateArtifact(projectDir, config, 'task', taskFileId, newRaw);
666
+ }
667
+ summary.filesUpdatedLocal++;
668
+ }
669
+ const newRecon = serializeTaskCheckboxReconciled(final);
670
+ if (newRecon !== (baseStr ?? '')) {
671
+ if (!dryRun) {
672
+ await updateArtifactFields(projectDir, config, 'task', taskFileId, {
673
+ linearChecklistReconciled: newRecon,
674
+ linearTaskChecklistSyncedAt: new Date().toISOString(),
675
+ });
676
+ }
677
+ }
678
+ }
679
+ if (issueDirty) {
680
+ if (!dryRun) {
681
+ await updateLinearIssue(client, issueId, { description: merged });
682
+ }
683
+ summary.linearIssuesUpdated++;
684
+ }
685
+ }
686
+ // Persist any non-interactive auto-resolutions to a Markdown audit log.
687
+ // Matches the filename convention used by revise's audit-log-service so users
688
+ // find it in the same `.planr/reports/` directory they already look at.
689
+ // Never mutates anything in dry-run mode.
690
+ if (!dryRun && autoResolvedConflicts.length > 0) {
691
+ await appendSyncConflictAudit(projectDir, autoResolvedConflicts);
692
+ }
693
+ return summary;
694
+ }
695
+ /**
696
+ * Append a Markdown audit entry for non-interactive conflict auto-resolutions
697
+ * (M4). File is created on first write per day at
698
+ * `.planr/reports/linear-sync-conflicts-<YYYY-MM-DD>.md`. Appends preserve
699
+ * prior entries across multiple `planr linear sync` runs on the same day.
700
+ */
701
+ async function appendSyncConflictAudit(projectDir, entries) {
702
+ const { appendFile, mkdir } = await import('node:fs/promises');
703
+ const path = await import('node:path');
704
+ const { existsSync } = await import('node:fs');
705
+ const today = new Date().toISOString().slice(0, 10);
706
+ const dir = path.join(projectDir, '.planr', 'reports');
707
+ await mkdir(dir, { recursive: true });
708
+ const file = path.join(dir, `linear-sync-conflicts-${today}.md`);
709
+ const isNew = !existsSync(file);
710
+ const fmtBool = (v) => (v === undefined ? '—' : String(v));
711
+ const rows = entries
712
+ .map((e) => `| ${e.timestamp} | ${e.label} | ${e.id} | ${fmtBool(e.base)} | ${fmtBool(e.local)} | ${fmtBool(e.remote)} | ${e.chosen} |`)
713
+ .join('\n');
714
+ const header = isNew
715
+ ? `# Linear sync conflict audit — ${today}\n\n> Auto-resolved conflicts from non-interactive \`planr linear sync\` runs. Each row is one checkbox where local and Linear disagreed and the default resolution was picked without human confirmation.\n\n| timestamp | task file | task id | base | local | remote | chosen |\n| --- | --- | --- | --- | --- | --- | --- |\n`
716
+ : '';
717
+ await appendFile(file, `${header}${rows}\n`, 'utf-8');
718
+ logger.dim(`Recorded ${entries.length} auto-resolved conflict(s) to ${path.relative(projectDir, file)}`);
719
+ }
720
+ //# sourceMappingURL=linear-pull-service.js.map