openplanr 1.2.7 → 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 (164) hide show
  1. package/README.md +41 -4
  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/prompt-builder.d.ts +48 -0
  6. package/dist/ai/prompts/prompt-builder.d.ts.map +1 -1
  7. package/dist/ai/prompts/prompt-builder.js +57 -1
  8. package/dist/ai/prompts/prompt-builder.js.map +1 -1
  9. package/dist/ai/prompts/system-prompts.d.ts +24 -1
  10. package/dist/ai/prompts/system-prompts.d.ts.map +1 -1
  11. package/dist/ai/prompts/system-prompts.js +104 -6
  12. package/dist/ai/prompts/system-prompts.js.map +1 -1
  13. package/dist/ai/schemas/ai-response-schemas.d.ts +68 -0
  14. package/dist/ai/schemas/ai-response-schemas.d.ts.map +1 -1
  15. package/dist/ai/schemas/ai-response-schemas.js +81 -0
  16. package/dist/ai/schemas/ai-response-schemas.js.map +1 -1
  17. package/dist/ai/types.d.ts +2 -0
  18. package/dist/ai/types.d.ts.map +1 -1
  19. package/dist/ai/types.js +4 -0
  20. package/dist/ai/types.js.map +1 -1
  21. package/dist/cli/commands/backlog.d.ts +12 -0
  22. package/dist/cli/commands/backlog.d.ts.map +1 -1
  23. package/dist/cli/commands/backlog.js +88 -2
  24. package/dist/cli/commands/backlog.js.map +1 -1
  25. package/dist/cli/commands/config.d.ts.map +1 -1
  26. package/dist/cli/commands/config.js +8 -2
  27. package/dist/cli/commands/config.js.map +1 -1
  28. package/dist/cli/commands/linear.d.ts +8 -0
  29. package/dist/cli/commands/linear.d.ts.map +1 -0
  30. package/dist/cli/commands/linear.js +550 -0
  31. package/dist/cli/commands/linear.js.map +1 -0
  32. package/dist/cli/commands/quick.d.ts +17 -0
  33. package/dist/cli/commands/quick.d.ts.map +1 -1
  34. package/dist/cli/commands/quick.js +31 -15
  35. package/dist/cli/commands/quick.js.map +1 -1
  36. package/dist/cli/commands/revise.d.ts +24 -0
  37. package/dist/cli/commands/revise.d.ts.map +1 -0
  38. package/dist/cli/commands/revise.js +570 -0
  39. package/dist/cli/commands/revise.js.map +1 -0
  40. package/dist/cli/index.js +4 -0
  41. package/dist/cli/index.js.map +1 -1
  42. package/dist/models/schema.d.ts +43 -0
  43. package/dist/models/schema.d.ts.map +1 -1
  44. package/dist/models/schema.js +49 -0
  45. package/dist/models/schema.js.map +1 -1
  46. package/dist/models/types.d.ts +296 -0
  47. package/dist/models/types.d.ts.map +1 -1
  48. package/dist/services/artifact-gathering.d.ts +4 -0
  49. package/dist/services/artifact-gathering.d.ts.map +1 -1
  50. package/dist/services/artifact-gathering.js +1 -1
  51. package/dist/services/artifact-gathering.js.map +1 -1
  52. package/dist/services/artifact-service.d.ts +12 -1
  53. package/dist/services/artifact-service.d.ts.map +1 -1
  54. package/dist/services/artifact-service.js +49 -6
  55. package/dist/services/artifact-service.js.map +1 -1
  56. package/dist/services/atomic-write-service.d.ts +41 -0
  57. package/dist/services/atomic-write-service.d.ts.map +1 -0
  58. package/dist/services/atomic-write-service.js +87 -0
  59. package/dist/services/atomic-write-service.js.map +1 -0
  60. package/dist/services/audit-log-service.d.ts +47 -0
  61. package/dist/services/audit-log-service.d.ts.map +1 -0
  62. package/dist/services/audit-log-service.js +210 -0
  63. package/dist/services/audit-log-service.js.map +1 -0
  64. package/dist/services/cascade-service.d.ts +62 -0
  65. package/dist/services/cascade-service.d.ts.map +1 -0
  66. package/dist/services/cascade-service.js +189 -0
  67. package/dist/services/cascade-service.js.map +1 -0
  68. package/dist/services/credentials-service.js +2 -2
  69. package/dist/services/credentials-service.js.map +1 -1
  70. package/dist/services/diff-service.d.ts +18 -0
  71. package/dist/services/diff-service.d.ts.map +1 -0
  72. package/dist/services/diff-service.js +35 -0
  73. package/dist/services/diff-service.js.map +1 -0
  74. package/dist/services/evidence-verifier.d.ts +71 -0
  75. package/dist/services/evidence-verifier.d.ts.map +1 -0
  76. package/dist/services/evidence-verifier.js +174 -0
  77. package/dist/services/evidence-verifier.js.map +1 -0
  78. package/dist/services/git-service.d.ts +60 -0
  79. package/dist/services/git-service.d.ts.map +1 -0
  80. package/dist/services/git-service.js +137 -0
  81. package/dist/services/git-service.js.map +1 -0
  82. package/dist/services/graph-integrity.d.ts +35 -0
  83. package/dist/services/graph-integrity.d.ts.map +1 -0
  84. package/dist/services/graph-integrity.js +53 -0
  85. package/dist/services/graph-integrity.js.map +1 -0
  86. package/dist/services/linear/body-formatters.d.ts +69 -0
  87. package/dist/services/linear/body-formatters.d.ts.map +1 -0
  88. package/dist/services/linear/body-formatters.js +183 -0
  89. package/dist/services/linear/body-formatters.js.map +1 -0
  90. package/dist/services/linear/constants.d.ts +61 -0
  91. package/dist/services/linear/constants.d.ts.map +1 -0
  92. package/dist/services/linear/constants.js +84 -0
  93. package/dist/services/linear/constants.js.map +1 -0
  94. package/dist/services/linear/errors.d.ts +14 -0
  95. package/dist/services/linear/errors.d.ts.map +1 -0
  96. package/dist/services/linear/errors.js +106 -0
  97. package/dist/services/linear/errors.js.map +1 -0
  98. package/dist/services/linear/estimate-resolver.d.ts +50 -0
  99. package/dist/services/linear/estimate-resolver.d.ts.map +1 -0
  100. package/dist/services/linear/estimate-resolver.js +82 -0
  101. package/dist/services/linear/estimate-resolver.js.map +1 -0
  102. package/dist/services/linear/plan-builders.d.ts +64 -0
  103. package/dist/services/linear/plan-builders.d.ts.map +1 -0
  104. package/dist/services/linear/plan-builders.js +237 -0
  105. package/dist/services/linear/plan-builders.js.map +1 -0
  106. package/dist/services/linear/scope-loaders.d.ts +79 -0
  107. package/dist/services/linear/scope-loaders.d.ts.map +1 -0
  108. package/dist/services/linear/scope-loaders.js +227 -0
  109. package/dist/services/linear/scope-loaders.js.map +1 -0
  110. package/dist/services/linear/strategy-context.d.ts +66 -0
  111. package/dist/services/linear/strategy-context.d.ts.map +1 -0
  112. package/dist/services/linear/strategy-context.js +121 -0
  113. package/dist/services/linear/strategy-context.js.map +1 -0
  114. package/dist/services/linear-mapping-service.d.ts +11 -0
  115. package/dist/services/linear-mapping-service.d.ts.map +1 -0
  116. package/dist/services/linear-mapping-service.js +220 -0
  117. package/dist/services/linear-mapping-service.js.map +1 -0
  118. package/dist/services/linear-pull-service.d.ts +137 -0
  119. package/dist/services/linear-pull-service.d.ts.map +1 -0
  120. package/dist/services/linear-pull-service.js +720 -0
  121. package/dist/services/linear-pull-service.js.map +1 -0
  122. package/dist/services/linear-push-service.d.ts +86 -0
  123. package/dist/services/linear-push-service.d.ts.map +1 -0
  124. package/dist/services/linear-push-service.js +956 -0
  125. package/dist/services/linear-push-service.js.map +1 -0
  126. package/dist/services/linear-service.d.ts +122 -0
  127. package/dist/services/linear-service.d.ts.map +1 -0
  128. package/dist/services/linear-service.js +361 -0
  129. package/dist/services/linear-service.js.map +1 -0
  130. package/dist/services/prompt-service.d.ts +37 -0
  131. package/dist/services/prompt-service.d.ts.map +1 -1
  132. package/dist/services/prompt-service.js +111 -0
  133. package/dist/services/prompt-service.js.map +1 -1
  134. package/dist/services/revise-apply-service.d.ts +55 -0
  135. package/dist/services/revise-apply-service.d.ts.map +1 -0
  136. package/dist/services/revise-apply-service.js +255 -0
  137. package/dist/services/revise-apply-service.js.map +1 -0
  138. package/dist/services/revise-cache-service.d.ts +46 -0
  139. package/dist/services/revise-cache-service.d.ts.map +1 -0
  140. package/dist/services/revise-cache-service.js +88 -0
  141. package/dist/services/revise-cache-service.js.map +1 -0
  142. package/dist/services/revise-plan-service.d.ts +38 -0
  143. package/dist/services/revise-plan-service.d.ts.map +1 -0
  144. package/dist/services/revise-plan-service.js +151 -0
  145. package/dist/services/revise-plan-service.js.map +1 -0
  146. package/dist/services/revise-service.d.ts +115 -0
  147. package/dist/services/revise-service.d.ts.map +1 -0
  148. package/dist/services/revise-service.js +294 -0
  149. package/dist/services/revise-service.js.map +1 -0
  150. package/dist/services/template-sections.d.ts +28 -0
  151. package/dist/services/template-sections.d.ts.map +1 -0
  152. package/dist/services/template-sections.js +55 -0
  153. package/dist/services/template-sections.js.map +1 -0
  154. package/dist/templates/backlog/backlog-item.md.hbs +3 -0
  155. package/dist/templates/quick/quick-task.md.hbs +6 -0
  156. package/dist/utils/diff.d.ts +47 -0
  157. package/dist/utils/diff.d.ts.map +1 -0
  158. package/dist/utils/diff.js +278 -0
  159. package/dist/utils/diff.js.map +1 -0
  160. package/dist/utils/markdown.d.ts +23 -0
  161. package/dist/utils/markdown.d.ts.map +1 -1
  162. package/dist/utils/markdown.js +79 -0
  163. package/dist/utils/markdown.js.map +1 -1
  164. package/package.json +3 -2
@@ -0,0 +1,956 @@
1
+ /**
2
+ * `planr linear push` — map Epic → Linear Project, Feature → top-level
3
+ * project issue, Story and TaskList → sub-issues of the feature issue.
4
+ */
5
+ import { logger } from '../utils/logger.js';
6
+ import { findGherkinContent } from './artifact-gathering.js';
7
+ import { findArtifactTypeById, listArtifacts, readArtifact, updateArtifactFields, } from './artifact-service.js';
8
+ import { buildBacklogItemBody, buildEpicProjectDescription, buildFeatureIssueBody, buildMergedTaskListBody, buildStandaloneArtifactBody, buildStoryIssueBody, formatTaskCheckboxBody, toOptionalString, } from './linear/body-formatters.js';
9
+ import { resolveEstimateForPush } from './linear/estimate-resolver.js';
10
+ import { buildLinearPushPlan, getLinkedEpicId, } from './linear/plan-builders.js';
11
+ import { loadForBacklogItem, loadForFeature, loadForQuickTask, loadForStory, loadForTaskFile, loadLinearPushScope, } from './linear/scope-loaders.js';
12
+ import { contextFromMappedEpic, createTypeLabelCache, mergeLabelIds, readExistingLabelIds, } from './linear/strategy-context.js';
13
+ import { createLinearIssue, createLinearProject, createProjectMilestone, ensureIssueLabel, fetchTeamIssueEstimationType, fetchTeamWorkflowStates, isLikelyLinearIssueId, isLikelyLinearWorkflowStateId, updateLinearIssue, updateLinearProject, } from './linear-service.js';
14
+ export { buildBacklogItemBody, buildEpicProjectDescription, buildFeatureIssueBody, buildLinearPushPlan, buildMergedTaskListBody, buildStoryIssueBody, formatTaskCheckboxBody, loadForBacklogItem, loadForFeature, loadForQuickTask, loadForStory, loadForTaskFile, loadLinearPushScope, };
15
+ /**
16
+ * Decide whether a stored `linearIssueId` frontmatter value should be
17
+ * trusted for an update call, or treated as stale/corrupted so we fall
18
+ * through to the create path instead. Logs a warning either way so the user
19
+ * can spot the repair.
20
+ */
21
+ function isUsableLinearIssueId(value, artifactLabel) {
22
+ if (!value)
23
+ return false;
24
+ if (!isLikelyLinearIssueId(value)) {
25
+ logger.warn(`${artifactLabel}: stored linearIssueId "${value}" is not a valid Linear id (expected uuid or \`ENG-42\` identifier). Falling through to the create path — re-push will write a fresh, valid id.`);
26
+ return false;
27
+ }
28
+ return true;
29
+ }
30
+ // StrategyContext is defined in `./linear/strategy-context.ts` and imported above.
31
+ function sortByArtifactId(a, b) {
32
+ return a.id.localeCompare(b.id, undefined, { numeric: true });
33
+ }
34
+ const STATUS_ALIASES = {
35
+ completed: 'done',
36
+ cancelled: 'done',
37
+ canceled: 'done',
38
+ todo: 'pending',
39
+ };
40
+ function asTaskStatus(s) {
41
+ if (s === 'pending' || s === 'in-progress' || s === 'done')
42
+ return s;
43
+ if (typeof s === 'string') {
44
+ const alias = STATUS_ALIASES[s.toLowerCase()];
45
+ if (alias)
46
+ return alias;
47
+ }
48
+ return 'pending';
49
+ }
50
+ /**
51
+ * Derive a default status→stateId map from a team's workflow states. Used
52
+ * when the user hasn't configured `linear.pushStateIds` — lets `planr linear
53
+ * push` set workflow state out of the box.
54
+ *
55
+ * We pick the first state of each canonical Linear type so a team with
56
+ * multiple "unstarted" lanes (Todo + Backlog) or multiple "completed" lanes
57
+ * (Done + Released) gets a sensible default. Users who need different
58
+ * routing can override via `linear.pushStateIds` (which takes precedence).
59
+ */
60
+ export function buildAutoPushStateIdMap(states) {
61
+ const firstByType = {};
62
+ for (const s of states) {
63
+ if (!firstByType[s.type])
64
+ firstByType[s.type] = s.id;
65
+ }
66
+ const out = {};
67
+ // Task vocabulary (feature/story/quick/task — via asTaskStatus normalization).
68
+ const pendingStateId = firstByType.unstarted ?? firstByType.backlog;
69
+ if (pendingStateId)
70
+ out.pending = pendingStateId;
71
+ if (firstByType.started)
72
+ out['in-progress'] = firstByType.started;
73
+ const doneStateId = firstByType.completed ?? firstByType.canceled;
74
+ if (doneStateId)
75
+ out.done = doneStateId;
76
+ // Backlog vocabulary — separate so BL push doesn't accidentally inherit
77
+ // task-shape defaults if a user has only `in-progress` mapped etc.
78
+ const openStateId = firstByType.backlog ?? firstByType.unstarted;
79
+ if (openStateId)
80
+ out.open = openStateId;
81
+ const closedStateId = firstByType.completed ?? firstByType.canceled;
82
+ if (closedStateId)
83
+ out.closed = closedStateId;
84
+ return out;
85
+ }
86
+ /**
87
+ * OpenPlanr status → Linear `stateId` for feature/story/quick/task push.
88
+ *
89
+ * Precedence: user config (`linear.pushStateIds` > `linear.statusMap` with
90
+ * uuid values) > auto-derived team map. Common aliases (`completed` →
91
+ * `done`, `todo` → `pending`, …) are normalized before lookup so hand-edited
92
+ * frontmatter using Linear-native vocabulary keeps working.
93
+ */
94
+ export function resolveTaskStateIdForPush(config, status, autoMap) {
95
+ if (!status)
96
+ return undefined;
97
+ const s = asTaskStatus(status);
98
+ const push = config.linear?.pushStateIds;
99
+ if (push) {
100
+ const v = push[s] ?? push[status];
101
+ if (v)
102
+ return v;
103
+ }
104
+ const m = config.linear?.statusMap;
105
+ if (m) {
106
+ const v = m[s] ?? m[status];
107
+ if (v && isLikelyLinearWorkflowStateId(v))
108
+ return v;
109
+ }
110
+ if (autoMap) {
111
+ const v = autoMap[s] ?? autoMap[status];
112
+ if (v)
113
+ return v;
114
+ }
115
+ return undefined;
116
+ }
117
+ /**
118
+ * OpenPlanr status → Linear `stateId` for backlog push.
119
+ *
120
+ * BL uses `open | closed | promoted`, which don't map onto Linear's workflow
121
+ * vocabulary. We look up the raw key in `pushStateIds` → `statusMap` →
122
+ * auto-derived team map. No coercion into task vocabulary.
123
+ */
124
+ export function resolveBacklogStateIdForPush(config, status, autoMap) {
125
+ if (!status)
126
+ return undefined;
127
+ const push = config.linear?.pushStateIds;
128
+ if (push?.[status])
129
+ return push[status];
130
+ const m = config.linear?.statusMap;
131
+ const raw = m?.[status];
132
+ if (raw && isLikelyLinearWorkflowStateId(raw))
133
+ return raw;
134
+ if (autoMap?.[status])
135
+ return autoMap[status];
136
+ return undefined;
137
+ }
138
+ /**
139
+ * Back-compat alias: the original name used by feature/story call sites.
140
+ * New code should prefer `resolveTaskStateIdForPush` for clarity.
141
+ */
142
+ const resolveStateIdForPush = resolveTaskStateIdForPush;
143
+ /**
144
+ * Per-client cache for the auto-derived status→stateId map. Populated once
145
+ * per push run by `ensureAutoStateIdMap` and read by every resolver call
146
+ * site. Scoped to the LinearClient instance so tests that construct fresh
147
+ * clients get fresh caches; production CLI creates one client per command
148
+ * invocation so the map is effectively per-run.
149
+ *
150
+ * Using a WeakMap (instead of threading the value through every pushOne*
151
+ * signature) keeps the surface area minimal and matches the existing
152
+ * `ensureIssueLabel` cache pattern at the StrategyContext layer.
153
+ */
154
+ const autoStateIdMapCache = new WeakMap();
155
+ /**
156
+ * Per-client cache for the team's issue estimation type (fibonacci / linear /
157
+ * exponential / tShirt / notUsed). Populated once per push run alongside the
158
+ * state-id map; read by every resolver call site that wants to set an
159
+ * estimate on the Linear issue.
160
+ */
161
+ const teamEstimationTypeCache = new WeakMap();
162
+ /**
163
+ * Per-client latch so the "t-shirt scale — estimates skipped" warning fires
164
+ * exactly once per push run, no matter how many artifacts are in the scope.
165
+ */
166
+ const tShirtWarningLatch = new WeakSet();
167
+ /**
168
+ * Populate the per-client auto-map. Called once at the top of
169
+ * `runLinearPush` — a single extra API round-trip buys zero-config status
170
+ * sync. Failures are swallowed to keep push working: if Linear rejects the
171
+ * team-states query, the map stays empty and status updates become opt-in
172
+ * via `linear.pushStateIds` as before.
173
+ */
174
+ export async function ensureAutoStateIdMap(client, teamId) {
175
+ if (autoStateIdMapCache.has(client))
176
+ return;
177
+ try {
178
+ const states = await fetchTeamWorkflowStates(client, teamId);
179
+ autoStateIdMapCache.set(client, buildAutoPushStateIdMap(states));
180
+ }
181
+ catch (err) {
182
+ logger.debug('linear push: could not auto-derive pushStateIds from team workflow states', err);
183
+ autoStateIdMapCache.set(client, {});
184
+ }
185
+ }
186
+ /**
187
+ * Populate the per-client estimation-type cache. Failures degrade to
188
+ * `'notUsed'` so estimate is simply omitted rather than blocking the push.
189
+ */
190
+ export async function ensureTeamEstimationType(client, teamId) {
191
+ if (teamEstimationTypeCache.has(client))
192
+ return;
193
+ try {
194
+ const scale = await fetchTeamIssueEstimationType(client, teamId);
195
+ teamEstimationTypeCache.set(client, scale);
196
+ }
197
+ catch (err) {
198
+ logger.debug('linear push: could not fetch team estimation type', err);
199
+ teamEstimationTypeCache.set(client, 'notUsed');
200
+ }
201
+ }
202
+ export function getAutoStateIdMap(client) {
203
+ if (!client)
204
+ return undefined;
205
+ return autoStateIdMapCache.get(client);
206
+ }
207
+ function getTeamEstimationType(client) {
208
+ if (!client)
209
+ return undefined;
210
+ return teamEstimationTypeCache.get(client);
211
+ }
212
+ /**
213
+ * Resolve an artifact's estimate for push against the team's scale. Emits
214
+ * debug logs for snap transformations and a single-shot warning for t-shirt
215
+ * teams; centralizes call-site boilerplate so every push path (FEAT / US /
216
+ * QT / BL) can be one `buildEstimateInput(...)` call.
217
+ */
218
+ function buildEstimateInput(client, frontmatter, artifactId) {
219
+ const scale = getTeamEstimationType(client);
220
+ const resolution = resolveEstimateForPush(frontmatter, scale);
221
+ if (resolution.kind === 'mapped') {
222
+ if (resolution.snapped) {
223
+ logger.debug(`linear push: ${artifactId} estimate snapped ${resolution.originalValue} → ${resolution.estimate} (scale=${scale})`);
224
+ }
225
+ return { resolution, fieldPatch: { estimate: resolution.estimate } };
226
+ }
227
+ if (resolution.kind === 'skip-t-shirt' && !tShirtWarningLatch.has(client)) {
228
+ tShirtWarningLatch.add(client);
229
+ logger.warn('linear push: team uses t-shirt estimation scale — skipping estimate field on all artifacts (no reliable numeric → XS/S/M/L/XL mapping). Configure `linear.pushStateIds` with explicit values to override.');
230
+ }
231
+ if (resolution.kind === 'skip-invalid-value') {
232
+ logger.debug(`linear push: ${artifactId} has unparseable estimate "${String(resolution.rawValue)}" — skipping field`);
233
+ }
234
+ return { resolution, fieldPatch: {} };
235
+ }
236
+ // Plan builders live in `./linear/plan-builders.ts` and are re-exported above.
237
+ // ---------------------------------------------------------------------------
238
+ // Per-feature / per-story / per-tasklist push primitives.
239
+ // Shared between epic-scope pushes (which loop over features) and granular
240
+ // scope pushes (which push a single feature / story / task subtree).
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * Push one feature issue and its descendants (stories + merged tasklist) under
244
+ * an already-resolved Linear project. Returns the feature's Linear issue id,
245
+ * or `null` when `updateOnly` is set and the feature has no prior linear link
246
+ * (caller decides whether to propagate the skip).
247
+ *
248
+ * Strategy propagation: when `strategyCtx.strategy === 'milestone-of'` the
249
+ * feature issue gets `projectMilestoneId` set; when `'label-on'` the epic's
250
+ * label is merged into the issue's labelIds (existing labels preserved).
251
+ */
252
+ async function pushOneFeatureAndDescendants(projectDir, config, client, sf, strategyCtx, typeLabelCache, teamId, updateOnly) {
253
+ const f = sf.data;
254
+ const featureTitle = f.title.trim();
255
+ const featureBody = buildFeatureIssueBody(f);
256
+ const stateF = resolveStateIdForPush(config, f.status, getAutoStateIdMap(client));
257
+ const estimatePatch = buildEstimateInput(client, sf.frontmatter, f.id).fieldPatch;
258
+ const { projectId } = strategyCtx;
259
+ const typeLabelId = await typeLabelCache('feature');
260
+ let featureIssueId;
261
+ if (isUsableLinearIssueId(f.linearIssueId, `Feature ${f.id}`)) {
262
+ const existingLabels = await readExistingLabelIds(client, f.linearIssueId);
263
+ const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
264
+ const u = await updateLinearIssue(client, f.linearIssueId, {
265
+ title: featureTitle,
266
+ description: featureBody,
267
+ projectId,
268
+ teamId,
269
+ // Linear rejects `stateId: null` on update (InvalidInput). Omit when
270
+ // unmapped so the issue keeps its current state.
271
+ ...(stateF ? { stateId: stateF } : {}),
272
+ ...estimatePatch,
273
+ projectMilestoneId: strategyCtx.milestoneId ?? null,
274
+ labelIds,
275
+ });
276
+ featureIssueId = u.id;
277
+ const fmUpdate = {
278
+ linearIssueId: u.id,
279
+ linearIssueIdentifier: u.identifier,
280
+ linearIssueUrl: u.url,
281
+ linearLabelIds: labelIds,
282
+ };
283
+ if (strategyCtx.milestoneId)
284
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
285
+ await updateArtifactFields(projectDir, config, 'feature', f.id, fmUpdate);
286
+ }
287
+ else {
288
+ if (updateOnly) {
289
+ logger.warn(`Update-only: skipping feature ${f.id} (no linearIssueId) — not creating it; stories and tasks under this feature are skipped.`);
290
+ return null;
291
+ }
292
+ const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
293
+ const c = await createLinearIssue(client, {
294
+ teamId,
295
+ projectId,
296
+ title: featureTitle,
297
+ description: featureBody,
298
+ ...(stateF ? { stateId: stateF } : {}),
299
+ ...estimatePatch,
300
+ ...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
301
+ labelIds: initialLabelIds,
302
+ });
303
+ featureIssueId = c.id;
304
+ const fmUpdate = {
305
+ linearIssueId: c.id,
306
+ linearIssueIdentifier: c.identifier,
307
+ linearIssueUrl: c.url,
308
+ linearLabelIds: initialLabelIds,
309
+ };
310
+ if (strategyCtx.milestoneId)
311
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
312
+ await updateArtifactFields(projectDir, config, 'feature', f.id, fmUpdate);
313
+ }
314
+ for (const st of sf.stories) {
315
+ await pushOneStoryUnderFeature(projectDir, config, client, st, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
316
+ }
317
+ await pushOneTaskListForFeature(projectDir, config, client, sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
318
+ return featureIssueId;
319
+ }
320
+ /**
321
+ * Create or update one story sub-issue under a resolved feature Linear parent.
322
+ * Inherits milestone/label attributes from the containing epic via `strategyCtx`.
323
+ */
324
+ async function pushOneStoryUnderFeature(projectDir, config, client, scopedStory, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly) {
325
+ const s = scopedStory.data;
326
+ const storyTitle = s.title.trim();
327
+ // Load the story's Gherkin scenarios from `<storyId>-gherkin.feature` if
328
+ // it exists. OpenPlanr's convention stores real acceptance criteria as
329
+ // Gherkin in a sibling file; without this, stories followed-by-convention
330
+ // pushed empty bodies to Linear.
331
+ const gherkinContent = await findGherkinContent(projectDir, config, s.id);
332
+ const storyBody = buildStoryIssueBody(s, gherkinContent);
333
+ const stateS = resolveStateIdForPush(config, s.status, getAutoStateIdMap(client));
334
+ const estimatePatch = buildEstimateInput(client, scopedStory.frontmatter, s.id).fieldPatch;
335
+ const { projectId } = strategyCtx;
336
+ const typeLabelId = await typeLabelCache('story');
337
+ if (isUsableLinearIssueId(s.linearIssueId, `Story ${s.id}`)) {
338
+ const existingLabels = await readExistingLabelIds(client, s.linearIssueId);
339
+ const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
340
+ const u = await updateLinearIssue(client, s.linearIssueId, {
341
+ title: storyTitle,
342
+ description: storyBody,
343
+ projectId,
344
+ teamId,
345
+ parentId: featureIssueId,
346
+ // Linear rejects `stateId: null` on update (InvalidInput). Omit when
347
+ // unmapped so the issue keeps its current state.
348
+ ...(stateS ? { stateId: stateS } : {}),
349
+ ...estimatePatch,
350
+ projectMilestoneId: strategyCtx.milestoneId ?? null,
351
+ labelIds,
352
+ });
353
+ const fmUpdate = {
354
+ linearIssueId: u.id,
355
+ linearIssueIdentifier: u.identifier,
356
+ linearIssueUrl: u.url,
357
+ linearParentIssueId: featureIssueId,
358
+ linearLabelIds: labelIds,
359
+ };
360
+ if (strategyCtx.milestoneId)
361
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
362
+ await updateArtifactFields(projectDir, config, 'story', s.id, fmUpdate);
363
+ return;
364
+ }
365
+ if (updateOnly) {
366
+ logger.warn(`Update-only: skipping story ${s.id} (no linearIssueId).`);
367
+ return;
368
+ }
369
+ const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
370
+ const c = await createLinearIssue(client, {
371
+ teamId,
372
+ projectId,
373
+ parentId: featureIssueId,
374
+ title: storyTitle,
375
+ description: storyBody,
376
+ ...(stateS ? { stateId: stateS } : {}),
377
+ ...estimatePatch,
378
+ ...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
379
+ labelIds: initialLabelIds,
380
+ });
381
+ const fmUpdate = {
382
+ linearIssueId: c.id,
383
+ linearIssueIdentifier: c.identifier,
384
+ linearIssueUrl: c.url,
385
+ linearParentIssueId: featureIssueId,
386
+ linearLabelIds: initialLabelIds,
387
+ };
388
+ if (strategyCtx.milestoneId)
389
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
390
+ await updateArtifactFields(projectDir, config, 'story', s.id, fmUpdate);
391
+ }
392
+ /**
393
+ * Create or update the single "Tasks for <feature>" sub-issue that aggregates
394
+ * all task-file checkboxes for a feature. Merges all task files sharing this
395
+ * feature into one body. Returns without writing when there is nothing to
396
+ * push and no existing linear issue to keep in sync.
397
+ */
398
+ async function pushOneTaskListForFeature(projectDir, config, client, sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly) {
399
+ // Note: estimate sync is intentionally not applied here. A single Linear
400
+ // TaskList issue aggregates `sf.taskFiles` — multiple TASK-*.md files —
401
+ // so a 1:1 numeric estimate mapping doesn't apply. Aggregation rules
402
+ // (sum? max? per-file-section?) are their own concern.
403
+ const f = sf.data;
404
+ const { projectId } = strategyCtx;
405
+ const mergedBody = await buildMergedTaskListBody(projectDir, config, f.id, sf.taskFiles);
406
+ const issueFromFiles = await Promise.all(sf.taskFiles.map(async (tf) => {
407
+ const a = await readArtifact(projectDir, config, 'task', tf.id);
408
+ return toOptionalString(a?.data.linearIssueId);
409
+ }));
410
+ const rawExistingTaskIssueId = issueFromFiles.find(Boolean);
411
+ const existingTaskIssueId = isUsableLinearIssueId(rawExistingTaskIssueId, `TaskList under ${f.id}`)
412
+ ? rawExistingTaskIssueId
413
+ : undefined;
414
+ if (!mergedBody.trim() && !existingTaskIssueId) {
415
+ return;
416
+ }
417
+ const title = sf.taskFiles.length > 1
418
+ ? `Tasks: ${f.title} (${sf.taskFiles.length} files)`
419
+ : `Tasks: ${f.title}`;
420
+ const typeLabelId = await typeLabelCache('task');
421
+ if (existingTaskIssueId) {
422
+ const existingLabels = await readExistingLabelIds(client, existingTaskIssueId);
423
+ const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
424
+ const u = await updateLinearIssue(client, existingTaskIssueId, {
425
+ title,
426
+ description: mergedBody || '_No open tasks in OpenPlanr task file(s)._',
427
+ projectId,
428
+ teamId,
429
+ parentId: featureIssueId,
430
+ projectMilestoneId: strategyCtx.milestoneId ?? null,
431
+ labelIds,
432
+ });
433
+ const synced = new Date().toISOString();
434
+ for (const tf of sf.taskFiles) {
435
+ const fmUpdate = {
436
+ linearIssueId: u.id,
437
+ linearIssueIdentifier: u.identifier,
438
+ linearIssueUrl: u.url,
439
+ linearParentIssueId: featureIssueId,
440
+ linearTaskChecklistSyncedAt: synced,
441
+ linearLabelIds: labelIds,
442
+ };
443
+ if (strategyCtx.milestoneId)
444
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
445
+ await updateArtifactFields(projectDir, config, 'task', tf.id, fmUpdate);
446
+ }
447
+ return;
448
+ }
449
+ if (updateOnly) {
450
+ logger.warn(`Update-only: skipping task list issue for feature ${f.id} (no existing linearIssueId on task files).`);
451
+ return;
452
+ }
453
+ const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
454
+ const c = await createLinearIssue(client, {
455
+ teamId,
456
+ projectId,
457
+ parentId: featureIssueId,
458
+ title,
459
+ description: mergedBody,
460
+ ...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
461
+ labelIds: initialLabelIds,
462
+ });
463
+ const synced = new Date().toISOString();
464
+ for (const tf of sf.taskFiles) {
465
+ const fmUpdate = {
466
+ linearIssueId: c.id,
467
+ linearIssueIdentifier: c.identifier,
468
+ linearIssueUrl: c.url,
469
+ linearParentIssueId: featureIssueId,
470
+ linearTaskChecklistSyncedAt: synced,
471
+ linearLabelIds: initialLabelIds,
472
+ };
473
+ if (strategyCtx.milestoneId)
474
+ fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
475
+ await updateArtifactFields(projectDir, config, 'task', tf.id, fmUpdate);
476
+ }
477
+ }
478
+ /**
479
+ * Epic-scope push: resolves the mapping strategy (first-time choice persisted,
480
+ * subsequent runs read from frontmatter), creates/updates the Linear container
481
+ * (project + optional milestone or label), and cascades through every feature.
482
+ */
483
+ async function pushEpicScope(projectDir, config, client, epicId, updateOnly, teamId, leadId, override) {
484
+ const scope = await loadLinearPushScope(projectDir, config, epicId);
485
+ if (!scope) {
486
+ throw new Error(`Epic not found: ${epicId}`);
487
+ }
488
+ const { epic, features } = scope;
489
+ const plan = await buildLinearPushPlan(projectDir, config, epicId, { updateOnly });
490
+ if (!plan)
491
+ return null;
492
+ // Resolve strategy: stored on epic > override > config default > 'project'.
493
+ const stored = epic.linearMappingStrategy;
494
+ const chosen = stored ?? override?.strategy ?? config.linear?.defaultEpicStrategy ?? 'project';
495
+ // Re-strategizing is out of scope for this release — refuse to silently
496
+ // migrate an epic to a different mapping.
497
+ if (stored && override?.strategy && override.strategy !== stored) {
498
+ throw new Error(`Epic ${epic.id} is already mapped as '${stored}'. Re-strategizing to '${override.strategy}' is not supported in this release. Use \`planr linear unlink ${epic.id}\` + re-push once that command arrives.`);
499
+ }
500
+ if (updateOnly && !epic.linearProjectId) {
501
+ throw new Error('Cannot use --update-only: this epic has no `linearProjectId` in frontmatter. Run `planr linear push` without --update-only once to create the Linear project.');
502
+ }
503
+ const epicName = epic.title.trim() || epic.id;
504
+ const projectDescription = buildEpicProjectDescription(epic);
505
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
506
+ let strategyCtx;
507
+ if (chosen === 'project') {
508
+ let projectId;
509
+ if (epic.linearProjectId) {
510
+ const updated = await updateLinearProject(client, epic.linearProjectId, {
511
+ name: epicName,
512
+ description: projectDescription,
513
+ leadId: leadId ?? null,
514
+ });
515
+ projectId = updated.id;
516
+ await updateArtifactFields(projectDir, config, 'epic', epic.id, {
517
+ linearProjectId: updated.id,
518
+ linearProjectIdentifier: updated.identifier,
519
+ linearProjectUrl: updated.url,
520
+ linearMappingStrategy: chosen,
521
+ });
522
+ }
523
+ else {
524
+ const created = await createLinearProject(client, {
525
+ name: epicName,
526
+ teamIds: [teamId],
527
+ description: projectDescription,
528
+ leadId: leadId ?? null,
529
+ });
530
+ projectId = created.id;
531
+ await updateArtifactFields(projectDir, config, 'epic', epic.id, {
532
+ linearProjectId: created.id,
533
+ linearProjectIdentifier: created.identifier,
534
+ linearProjectUrl: created.url,
535
+ linearMappingStrategy: chosen,
536
+ });
537
+ }
538
+ strategyCtx = { strategy: 'project', projectId };
539
+ }
540
+ else if (chosen === 'milestone-of') {
541
+ const targetProjectId = epic.linearProjectId ?? override?.targetProjectId;
542
+ if (!targetProjectId) {
543
+ throw new Error(`milestone-of strategy requires a Linear project to attach into. Re-run with \`--as milestone-of:<projectId>\`.`);
544
+ }
545
+ let milestoneId = epic.linearMilestoneId;
546
+ if (!milestoneId) {
547
+ const m = await createProjectMilestone(client, {
548
+ projectId: targetProjectId,
549
+ name: epicName,
550
+ description: projectDescription,
551
+ });
552
+ milestoneId = m.id;
553
+ }
554
+ await updateArtifactFields(projectDir, config, 'epic', epic.id, {
555
+ linearProjectId: targetProjectId,
556
+ linearMilestoneId: milestoneId,
557
+ linearMappingStrategy: chosen,
558
+ });
559
+ strategyCtx = { strategy: 'milestone-of', projectId: targetProjectId, milestoneId };
560
+ }
561
+ else {
562
+ // label-on
563
+ const targetProjectId = epic.linearProjectId ?? override?.targetProjectId;
564
+ if (!targetProjectId) {
565
+ throw new Error(`label-on strategy requires a Linear project to attach into. Re-run with \`--as label-on:<projectId>\`.`);
566
+ }
567
+ const label = await ensureIssueLabel(client, {
568
+ teamId,
569
+ name: epicName,
570
+ description: `OpenPlanr epic ${epic.id} (auto-created by \`planr linear push\`).`,
571
+ });
572
+ await updateArtifactFields(projectDir, config, 'epic', epic.id, {
573
+ linearProjectId: targetProjectId,
574
+ linearLabelId: label.id,
575
+ linearMappingStrategy: chosen,
576
+ });
577
+ strategyCtx = { strategy: 'label-on', projectId: targetProjectId, labelId: label.id };
578
+ }
579
+ for (const sf of features) {
580
+ await pushOneFeatureAndDescendants(projectDir, config, client, sf, strategyCtx, typeLabelCache, teamId, updateOnly);
581
+ }
582
+ // Cascade to any QT / BL artifacts explicitly linked via `epicId: <this epic>`.
583
+ // Unlinked QT/BL stay in their standalone project; only opt-in children are
584
+ // pulled into the epic's Linear container.
585
+ const quicks = await listArtifacts(projectDir, config, 'quick');
586
+ for (const q of quicks.sort(sortByArtifactId)) {
587
+ const art = await readArtifact(projectDir, config, 'quick', q.id);
588
+ if (!art || getLinkedEpicId(art.data) !== epic.id)
589
+ continue;
590
+ try {
591
+ const qt = await loadForQuickTask(projectDir, config, q.id);
592
+ if (!qt)
593
+ continue;
594
+ await pushOneQuickTaskWithContext(projectDir, config, client, qt, strategyCtx, typeLabelCache, teamId, updateOnly);
595
+ }
596
+ catch (err) {
597
+ // Keep the cascade going — a single malformed QT shouldn't abort the
598
+ // whole epic push. Surface the reason so the operator can fix it.
599
+ logger.warn(`Skipping quick task ${q.id} in epic cascade: ${err instanceof Error ? err.message : String(err)}`);
600
+ }
601
+ }
602
+ const backlogs = await listArtifacts(projectDir, config, 'backlog');
603
+ for (const b of backlogs.sort(sortByArtifactId)) {
604
+ const art = await readArtifact(projectDir, config, 'backlog', b.id);
605
+ if (!art || getLinkedEpicId(art.data) !== epic.id)
606
+ continue;
607
+ try {
608
+ const bl = await loadForBacklogItem(projectDir, config, b.id);
609
+ if (!bl)
610
+ continue;
611
+ await pushOneBacklogItemWithContext(projectDir, config, client, bl, strategyCtx, typeLabelCache, teamId, updateOnly);
612
+ }
613
+ catch (err) {
614
+ logger.warn(`Skipping backlog item ${b.id} in epic cascade: ${err instanceof Error ? err.message : String(err)}`);
615
+ }
616
+ }
617
+ return plan;
618
+ }
619
+ /**
620
+ * Feature-scope push: exactly one feature issue + its stories + its tasklist.
621
+ * Requires the parent epic's Linear project to already exist (or `pushParents`).
622
+ */
623
+ async function pushFeatureScope(projectDir, config, client, featureId, options, teamId, leadId) {
624
+ const ctx = await loadForFeature(projectDir, config, featureId);
625
+ if (!ctx) {
626
+ throw new Error(`Feature not found or has no \`epicId\`: ${featureId}`);
627
+ }
628
+ const updateOnly = options.updateOnly === true;
629
+ if (!ctx.epic.linearProjectId) {
630
+ if (options.pushParents) {
631
+ logger.info(`Parent epic ${ctx.epic.id} is not in Linear yet — pushing the full epic first (--push-parents).`);
632
+ return pushEpicScope(projectDir, config, client, ctx.epic.id, updateOnly, teamId, leadId, options.strategyOverride);
633
+ }
634
+ throw new Error(`Parent epic ${ctx.epic.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.epic.id}\` first, or re-run with \`--push-parents\`.`);
635
+ }
636
+ const strategyCtx = contextFromMappedEpic(ctx.epic, config);
637
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
638
+ await pushOneFeatureAndDescendants(projectDir, config, client, ctx.sf, strategyCtx, typeLabelCache, teamId, updateOnly);
639
+ return buildLinearPushPlan(projectDir, config, featureId, { updateOnly });
640
+ }
641
+ /**
642
+ * Story-scope push: one story sub-issue under an already-mapped feature.
643
+ */
644
+ async function pushStoryScope(projectDir, config, client, storyId, options, teamId, leadId) {
645
+ const ctx = await loadForStory(projectDir, config, storyId);
646
+ if (!ctx) {
647
+ throw new Error(`Story not found or has no \`featureId\`: ${storyId}`);
648
+ }
649
+ const updateOnly = options.updateOnly === true;
650
+ if (!isUsableLinearIssueId(ctx.sf.data.linearIssueId, `Feature ${ctx.sf.data.id}`)) {
651
+ if (options.pushParents) {
652
+ logger.info(`Parent feature ${ctx.sf.data.id} is not in Linear yet — pushing the feature subtree first (--push-parents).`);
653
+ return pushFeatureScope(projectDir, config, client, ctx.sf.data.id, { ...options, pushParents: true }, teamId, leadId);
654
+ }
655
+ throw new Error(`Parent feature ${ctx.sf.data.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.sf.data.id}\` first, or re-run with \`--push-parents\`.`);
656
+ }
657
+ // Ensure parent epic also has a Linear project — required for the story's `projectId`.
658
+ if (!ctx.epic.linearProjectId) {
659
+ throw new Error(`Parent epic ${ctx.epic.id} has no \`linearProjectId\`. Run \`planr linear push ${ctx.epic.id}\` first.`);
660
+ }
661
+ const strategyCtx = contextFromMappedEpic(ctx.epic, config);
662
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
663
+ const featureIssueId = ctx.sf.data.linearIssueId;
664
+ await pushOneStoryUnderFeature(projectDir, config, client, ctx.story, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
665
+ return buildLinearPushPlan(projectDir, config, storyId, { updateOnly });
666
+ }
667
+ /**
668
+ * Task-file-scope push: update the single "Tasks for <feature>" sub-issue, merging
669
+ * checkbox bodies across all task files under the same feature (matches epic-scope
670
+ * behavior — one Linear sub-issue per feature regardless of how many task files exist).
671
+ */
672
+ async function pushTaskFileScope(projectDir, config, client, taskId, options, teamId, leadId) {
673
+ const ctx = await loadForTaskFile(projectDir, config, taskId);
674
+ if (!ctx) {
675
+ throw new Error(`Task file not found or has no \`featureId\`: ${taskId}`);
676
+ }
677
+ const updateOnly = options.updateOnly === true;
678
+ if (!isUsableLinearIssueId(ctx.sf.data.linearIssueId, `Feature ${ctx.sf.data.id}`)) {
679
+ if (options.pushParents) {
680
+ logger.info(`Parent feature ${ctx.sf.data.id} is not in Linear yet — pushing the feature subtree first (--push-parents).`);
681
+ return pushFeatureScope(projectDir, config, client, ctx.sf.data.id, { ...options, pushParents: true }, teamId, leadId);
682
+ }
683
+ throw new Error(`Parent feature ${ctx.sf.data.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.sf.data.id}\` first, or re-run with \`--push-parents\`.`);
684
+ }
685
+ if (!ctx.epic.linearProjectId) {
686
+ throw new Error(`Parent epic ${ctx.epic.id} has no \`linearProjectId\`. Run \`planr linear push ${ctx.epic.id}\` first.`);
687
+ }
688
+ const strategyCtx = contextFromMappedEpic(ctx.epic, config);
689
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
690
+ const featureIssueId = ctx.sf.data.linearIssueId;
691
+ await pushOneTaskListForFeature(projectDir, config, client, ctx.sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
692
+ return buildLinearPushPlan(projectDir, config, taskId, { updateOnly });
693
+ }
694
+ /**
695
+ * Resolve the Linear container for a QT / BL push:
696
+ * 1. If the artifact has a linked epic (`epicId` / `parentEpic`) and that epic
697
+ * is already mapped in Linear → reuse the epic's StrategyContext so the
698
+ * issue inherits project + milestone / label propagation.
699
+ * 2. If the epic is linked but not yet mapped — cascade to `pushEpicScope`
700
+ * when `--push-parents` is set, otherwise error with a clear pointer.
701
+ * 3. No linked epic → fall back to `config.linear.standaloneProjectId`.
702
+ * 4. Still missing — actionable error (interactive setup or manual config edit).
703
+ *
704
+ * Returns `null` when the caller already pushed a cascaded ancestor (so the
705
+ * caller should short-circuit its own push).
706
+ */
707
+ async function resolveQuickOrBacklogContext(projectDir, config, client, artifactKind, artifactId, frontmatter, options, teamId, leadId) {
708
+ const linkedEpicId = getLinkedEpicId(frontmatter);
709
+ if (linkedEpicId) {
710
+ const epicArt = await readArtifact(projectDir, config, 'epic', linkedEpicId);
711
+ if (!epicArt) {
712
+ throw new Error(`${artifactKind === 'quick' ? 'Quick task' : 'Backlog item'} ${artifactId} declares epicId "${linkedEpicId}" but no such epic exists locally. Fix the frontmatter or create the epic first.`);
713
+ }
714
+ const epicScope = await loadLinearPushScope(projectDir, config, linkedEpicId);
715
+ if (!epicScope) {
716
+ throw new Error(`Failed to load epic ${linkedEpicId} for push context.`);
717
+ }
718
+ if (!epicScope.epic.linearProjectId) {
719
+ if (options.pushParents) {
720
+ logger.info(`Parent epic ${linkedEpicId} is not in Linear yet — pushing the full epic first (--push-parents).`);
721
+ await pushEpicScope(projectDir, config, client, linkedEpicId, options.updateOnly === true, teamId, leadId, options.strategyOverride);
722
+ // The cascade re-pushes every linked QT/BL, including this one.
723
+ return { kind: 'cascaded' };
724
+ }
725
+ throw new Error(`${artifactId} is linked to epic ${linkedEpicId}, which has not been pushed to Linear yet. Run \`planr linear push ${linkedEpicId}\` first, or re-run with \`--push-parents\`.`);
726
+ }
727
+ return { kind: 'resolved', ctx: contextFromMappedEpic(epicScope.epic, config) };
728
+ }
729
+ // No linked epic — fall back to the standalone project.
730
+ const standaloneId = config.linear?.standaloneProjectId;
731
+ if (!standaloneId) {
732
+ throw new Error(`No Linear container resolved for ${artifactId}: no \`epicId\` on the artifact and no \`linear.standaloneProjectId\` configured. Either add \`epicId: "EPIC-XXX"\` to the frontmatter (and push that epic first), or run \`planr linear push ${artifactId}\` interactively once to pick a standalone project, or set \`linear.standaloneProjectId\` in \`.planr/config.json\`.`);
733
+ }
734
+ return { kind: 'resolved', ctx: { strategy: 'project', projectId: standaloneId } };
735
+ }
736
+ /**
737
+ * Internal worker: create-or-update one quick-task Linear issue using a
738
+ * pre-resolved StrategyContext. Used by both the QT-scope entry point and
739
+ * the epic-cascade path (which already has the context in hand).
740
+ */
741
+ async function pushOneQuickTaskWithContext(projectDir, config, client, qt, ctx, typeLabelCache, teamId, updateOnly) {
742
+ // Push the full markdown body (minus frontmatter + top-level title heading)
743
+ // so prose content and checkbox lists both land in Linear verbatim.
744
+ const body = buildStandaloneArtifactBody(qt.raw, qt.id);
745
+ const title = qt.title.trim();
746
+ const typeLabelId = await typeLabelCache('quick');
747
+ const stateId = resolveTaskStateIdForPush(config, toOptionalString(qt.frontmatter.status), getAutoStateIdMap(client));
748
+ const estimatePatch = buildEstimateInput(client, qt.frontmatter, qt.id).fieldPatch;
749
+ const rawExistingId = toOptionalString(qt.frontmatter.linearIssueId);
750
+ const existingId = isUsableLinearIssueId(rawExistingId, `QuickTask ${qt.id}`)
751
+ ? rawExistingId
752
+ : undefined;
753
+ if (existingId) {
754
+ const existingLabels = await readExistingLabelIds(client, existingId);
755
+ const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), ctx.strategy === 'label-on' ? ctx.labelId : undefined);
756
+ const u = await updateLinearIssue(client, existingId, {
757
+ title,
758
+ description: body,
759
+ projectId: ctx.projectId,
760
+ teamId,
761
+ // Linear rejects `stateId: null` on update (InvalidInput). Omit the
762
+ // field entirely when unmapped so the issue keeps its current state.
763
+ ...(stateId ? { stateId } : {}),
764
+ ...estimatePatch,
765
+ projectMilestoneId: ctx.milestoneId ?? null,
766
+ labelIds,
767
+ });
768
+ const fmUpdate = {
769
+ linearIssueId: u.id,
770
+ linearIssueIdentifier: u.identifier,
771
+ linearIssueUrl: u.url,
772
+ linearLabelIds: labelIds,
773
+ };
774
+ if (ctx.milestoneId)
775
+ fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
776
+ await updateArtifactFields(projectDir, config, 'quick', qt.id, fmUpdate);
777
+ return;
778
+ }
779
+ if (updateOnly) {
780
+ logger.warn(`Update-only: skipping quick task ${qt.id} (no linearIssueId).`);
781
+ return;
782
+ }
783
+ const initialLabelIds = mergeLabelIds([typeLabelId], ctx.strategy === 'label-on' ? ctx.labelId : undefined);
784
+ const c = await createLinearIssue(client, {
785
+ teamId,
786
+ projectId: ctx.projectId,
787
+ title,
788
+ description: body,
789
+ ...(stateId ? { stateId } : {}),
790
+ ...estimatePatch,
791
+ ...(ctx.milestoneId ? { projectMilestoneId: ctx.milestoneId } : {}),
792
+ labelIds: initialLabelIds,
793
+ });
794
+ const fmUpdate = {
795
+ linearIssueId: c.id,
796
+ linearIssueIdentifier: c.identifier,
797
+ linearIssueUrl: c.url,
798
+ linearLabelIds: initialLabelIds,
799
+ };
800
+ if (ctx.milestoneId)
801
+ fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
802
+ await updateArtifactFields(projectDir, config, 'quick', qt.id, fmUpdate);
803
+ }
804
+ /**
805
+ * Internal worker: create-or-update one backlog-item Linear issue. Always
806
+ * carries the team-scoped `backlog` label; merges the epic's `label-on`
807
+ * label when applicable (and preserves any user-added labels on update).
808
+ */
809
+ async function pushOneBacklogItemWithContext(projectDir, config, client, bl, ctx, typeLabelCache, teamId, updateOnly) {
810
+ const title = bl.title.trim();
811
+ const body = buildBacklogItemBody(bl);
812
+ const typeLabelId = await typeLabelCache('backlog');
813
+ const stateId = resolveBacklogStateIdForPush(config, toOptionalString(bl.frontmatter.status), getAutoStateIdMap(client));
814
+ const estimatePatch = buildEstimateInput(client, bl.frontmatter, bl.id).fieldPatch;
815
+ const rawExistingId = toOptionalString(bl.frontmatter.linearIssueId);
816
+ const existingId = isUsableLinearIssueId(rawExistingId, `Backlog ${bl.id}`)
817
+ ? rawExistingId
818
+ : undefined;
819
+ if (existingId) {
820
+ const existingLabels = await readExistingLabelIds(client, existingId);
821
+ const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), ctx.strategy === 'label-on' ? ctx.labelId : undefined);
822
+ const u = await updateLinearIssue(client, existingId, {
823
+ title,
824
+ description: body,
825
+ projectId: ctx.projectId,
826
+ teamId,
827
+ // Linear rejects `stateId: null` on update (InvalidInput). Omit the
828
+ // field entirely when unmapped so the issue keeps its current state.
829
+ ...(stateId ? { stateId } : {}),
830
+ ...estimatePatch,
831
+ labelIds,
832
+ projectMilestoneId: ctx.milestoneId ?? null,
833
+ });
834
+ const fmUpdate = {
835
+ linearIssueId: u.id,
836
+ linearIssueIdentifier: u.identifier,
837
+ linearIssueUrl: u.url,
838
+ linearLabelIds: labelIds,
839
+ };
840
+ if (ctx.milestoneId)
841
+ fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
842
+ await updateArtifactFields(projectDir, config, 'backlog', bl.id, fmUpdate);
843
+ return;
844
+ }
845
+ if (updateOnly) {
846
+ logger.warn(`Update-only: skipping backlog item ${bl.id} (no linearIssueId).`);
847
+ return;
848
+ }
849
+ const initialLabelIds = mergeLabelIds([typeLabelId], ctx.strategy === 'label-on' ? ctx.labelId : undefined);
850
+ const c = await createLinearIssue(client, {
851
+ teamId,
852
+ projectId: ctx.projectId,
853
+ title,
854
+ description: body,
855
+ labelIds: initialLabelIds,
856
+ ...(stateId ? { stateId } : {}),
857
+ ...estimatePatch,
858
+ ...(ctx.milestoneId ? { projectMilestoneId: ctx.milestoneId } : {}),
859
+ });
860
+ const fmUpdate = {
861
+ linearIssueId: c.id,
862
+ linearIssueIdentifier: c.identifier,
863
+ linearIssueUrl: c.url,
864
+ linearLabelIds: initialLabelIds,
865
+ };
866
+ if (ctx.milestoneId)
867
+ fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
868
+ await updateArtifactFields(projectDir, config, 'backlog', bl.id, fmUpdate);
869
+ }
870
+ /**
871
+ * QT-scope entry point: resolve the Linear container via linked epic or the
872
+ * standalone fallback, then push the single issue.
873
+ */
874
+ async function pushQuickTaskScope(projectDir, config, client, qtId, options, teamId, leadId) {
875
+ const updateOnly = options.updateOnly === true;
876
+ const qt = await loadForQuickTask(projectDir, config, qtId);
877
+ if (!qt) {
878
+ throw new Error(`Quick task not found: ${qtId}`);
879
+ }
880
+ const resolved = await resolveQuickOrBacklogContext(projectDir, config, client, 'quick', qtId, qt.frontmatter, options, teamId, leadId);
881
+ if (resolved.kind === 'cascaded') {
882
+ // Epic cascade already pushed this QT — just return the scope-1 plan.
883
+ return buildLinearPushPlan(projectDir, config, qtId, { updateOnly });
884
+ }
885
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
886
+ await pushOneQuickTaskWithContext(projectDir, config, client, qt, resolved.ctx, typeLabelCache, teamId, updateOnly);
887
+ return buildLinearPushPlan(projectDir, config, qtId, { updateOnly });
888
+ }
889
+ /**
890
+ * BL-scope entry point: same resolution order as QT, plus the mandatory
891
+ * `backlog` label.
892
+ */
893
+ async function pushBacklogItemScope(projectDir, config, client, blId, options, teamId, leadId) {
894
+ const updateOnly = options.updateOnly === true;
895
+ const bl = await loadForBacklogItem(projectDir, config, blId);
896
+ if (!bl) {
897
+ throw new Error(`Backlog item not found: ${blId}`);
898
+ }
899
+ const resolved = await resolveQuickOrBacklogContext(projectDir, config, client, 'backlog', blId, bl.frontmatter, options, teamId, leadId);
900
+ if (resolved.kind === 'cascaded') {
901
+ return buildLinearPushPlan(projectDir, config, blId, { updateOnly });
902
+ }
903
+ const typeLabelCache = createTypeLabelCache(client, teamId, config);
904
+ await pushOneBacklogItemWithContext(projectDir, config, client, bl, resolved.ctx, typeLabelCache, teamId, updateOnly);
905
+ return buildLinearPushPlan(projectDir, config, blId, { updateOnly });
906
+ }
907
+ /**
908
+ * Granular push entry point: dispatches on the artifact-id prefix. Accepts any
909
+ * supported artifact type (EPIC/FEAT/US/TASK); errors with an actionable
910
+ * message for types that are not pushable (ADR/SPRINT/checklist) or not yet
911
+ * supported (QT/BL go through the same router too).
912
+ */
913
+ export async function runLinearPush(projectDir, config, client, artifactId, options) {
914
+ const teamId = config.linear?.teamId;
915
+ if (!teamId) {
916
+ throw new Error('`linear.teamId` is not set. Run `planr linear init` first.');
917
+ }
918
+ const leadId = config.linear?.defaultProjectLead;
919
+ const opts = options ?? {};
920
+ const updateOnly = opts.updateOnly === true;
921
+ // One API round-trip per push to cache the team's workflow states. Lets
922
+ // every resolver (feature/story/QT/BL) map local status → Linear stateId
923
+ // even when the user hasn't configured `linear.pushStateIds` explicitly.
924
+ await ensureAutoStateIdMap(client, teamId);
925
+ // Second round-trip for the team's issue estimation type — used by every
926
+ // resolver to snap local `storyPoints` to Linear's native estimate field.
927
+ // Cheap, non-blocking on failure (falls back to `'notUsed'`).
928
+ await ensureTeamEstimationType(client, teamId);
929
+ const type = findArtifactTypeById(artifactId);
930
+ if (!type) {
931
+ throw new Error(`Unknown artifact id: ${artifactId}. Expected an EPIC-/FEAT-/US-/TASK- prefix.`);
932
+ }
933
+ if (type === 'sprint' || type === 'adr' || type === 'checklist') {
934
+ throw new Error(`planr linear push does not support ${type}s in this release. Push its parent epic instead: planr linear push <EPIC-ID>.`);
935
+ }
936
+ if (type === 'epic') {
937
+ return pushEpicScope(projectDir, config, client, artifactId, updateOnly, teamId, leadId, opts.strategyOverride);
938
+ }
939
+ if (type === 'feature') {
940
+ return pushFeatureScope(projectDir, config, client, artifactId, opts, teamId, leadId);
941
+ }
942
+ if (type === 'story') {
943
+ return pushStoryScope(projectDir, config, client, artifactId, opts, teamId, leadId);
944
+ }
945
+ if (type === 'task') {
946
+ return pushTaskFileScope(projectDir, config, client, artifactId, opts, teamId, leadId);
947
+ }
948
+ if (type === 'quick') {
949
+ return pushQuickTaskScope(projectDir, config, client, artifactId, opts, teamId, leadId);
950
+ }
951
+ if (type === 'backlog') {
952
+ return pushBacklogItemScope(projectDir, config, client, artifactId, opts, teamId, leadId);
953
+ }
954
+ return null;
955
+ }
956
+ //# sourceMappingURL=linear-push-service.js.map