@skitterbyte/skitterspec-linear 15.0.0 → 17.0.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 (44) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +34 -1
  3. package/assets/claude-md-section.md +29 -18
  4. package/assets/commands/spec-remote-review.md +22 -0
  5. package/assets/core/SETUP.md +10 -6
  6. package/assets/core/env.config.json.example +4 -2
  7. package/assets/core/env.config.md +100 -23
  8. package/assets/core/linear.config.json.example +2 -1
  9. package/assets/core/linear.config.md +49 -22
  10. package/assets/review/page.html +1044 -101
  11. package/assets/rules/spec-planning.md +35 -3
  12. package/assets/rules/spec-reports.md +131 -20
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +68 -30
  15. package/assets/skills/spec-cancel/SKILL.md +2 -2
  16. package/assets/skills/spec-claim/SKILL.md +12 -4
  17. package/assets/skills/spec-complete/SKILL.md +2 -2
  18. package/assets/skills/spec-diff/SKILL.md +131 -36
  19. package/assets/skills/spec-hotfix/SKILL.md +61 -25
  20. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  21. package/assets/skills/spec-next/SKILL.md +121 -58
  22. package/assets/skills/spec-push/SKILL.md +45 -0
  23. package/assets/skills/spec-review/SKILL.md +89 -2
  24. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  25. package/assets/skills/spec-start/SKILL.md +26 -3
  26. package/assets/skills/spec-status/SKILL.md +20 -6
  27. package/assets/skills/spec-sync/SKILL.md +1 -0
  28. package/package.json +2 -2
  29. package/src/cli.js +913 -116
  30. package/src/env/classify.js +87 -2
  31. package/src/env/config.js +214 -17
  32. package/src/env/live.js +94 -0
  33. package/src/env/resolve.js +36 -2
  34. package/src/env/review.js +542 -21
  35. package/src/env/serve.js +298 -19
  36. package/src/env/supervise.js +8 -1
  37. package/src/init.js +60 -9
  38. package/src/vendor/linear/api.js +111 -2
  39. package/src/vendor/linear/cli-sync.js +661 -11
  40. package/src/vendor/linear/config.js +41 -13
  41. package/src/vendor/linear/doctor.js +6 -5
  42. package/src/vendor/sync-core/index.js +11 -3
  43. package/src/vendor/sync-core/src/compare.js +65 -0
  44. package/src/vendor/sync-core/src/normalize.js +26 -0
@@ -117,6 +117,76 @@ const ISSUE_FIELDS =
117
117
  // resolves by id and can still be assigned.
118
118
  const USER_FIELDS = 'id name displayName email active'
119
119
 
120
+ /**
121
+ * Codes Linear returns that no amount of waiting will clear. `userError` decides
122
+ * it on its own where Linear sends one; this is the fallback for a payload that
123
+ * carries a code and not the flag.
124
+ *
125
+ * DELIBERATELY SHORT. A code absent from this list and carrying no `userError`
126
+ * is a cannot-tell, not a retryable — see `LinearRefusal` below.
127
+ */
128
+ const UNRETRYABLE_CODES = new Set(['USAGE_LIMIT_EXCEEDED', 'FEATURE_NOT_ACCESSIBLE', 'INVALID_INPUT'])
129
+
130
+ /**
131
+ * What Linear refused, kept whole.
132
+ *
133
+ * THE FIELDS ARE ADDED BESIDE `message`, NEVER INSTEAD OF IT. Every existing
134
+ * caller prints `error.message`, and this must not require them all to change to
135
+ * go on working — so the joined message is still exactly what it was.
136
+ *
137
+ * `retryable` is THREE-VALUED and the third value is the point:
138
+ *
139
+ * - `false` — Linear said `userError`, or sent a code that never clears. Waiting
140
+ * cannot help, so the retry loop must not spend three attempts proving it.
141
+ * - `true` — set by the throttle path, where waiting is the entire answer.
142
+ * - `null` — nothing said either way. Every error the MCP path produces and
143
+ * every older Linear response lands here, and it must read as neither: a
144
+ * missing field is not evidence (`.claude/rules/negative-checks.md` rule 1).
145
+ *
146
+ * WHAT THIS COST WHEN IT WAS MISSING: a push failed twice with
147
+ * `Linear API error: usage limit exceeded`, which reads like a throttle. An hour
148
+ * went into checking a rate limit that was untouched, while
149
+ * `extensions.userPresentableMessage` — naming both the free-plan issue cap and
150
+ * how to fix it — sat one layer below what was printed.
151
+ */
152
+ class LinearRefusal extends Error {
153
+ constructor(message, { code = null, userError = null, userPresentableMessage = null, meta = null, retryable = null } = {}) {
154
+ super(message)
155
+ this.name = 'LinearRefusal'
156
+ this.code = code
157
+ this.userError = userError
158
+ this.userPresentableMessage = userPresentableMessage
159
+ this.meta = meta
160
+ this.retryable = retryable
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Read a GraphQL `errors` array into one refusal. Pure.
166
+ *
167
+ * The first entry carries the detail — Linear sends one error per failed field
168
+ * and a mutation fails on one — while the message joins them all, as it always
169
+ * has, so nothing that reads `message` notices this changed.
170
+ */
171
+ function refusalFrom(errors) {
172
+ const joined = errors.map((e) => (e && e.message) || String(e)).join('; ')
173
+ const ext = (errors[0] && errors[0].extensions) || {}
174
+ const code = typeof ext.code === 'string' ? ext.code : null
175
+ const userError = typeof ext.userError === 'boolean' ? ext.userError : null
176
+ // `false` only on a POSITIVE signal. An unrecognised code with no flag stays
177
+ // `null`, so it keeps whatever retry behaviour it had rather than gaining a
178
+ // verdict nothing supports.
179
+ const retryable = userError === true || (code !== null && UNRETRYABLE_CODES.has(code)) ? false : null
180
+ return new LinearRefusal(`Linear API error: ${joined}`, {
181
+ code,
182
+ userError,
183
+ userPresentableMessage:
184
+ typeof ext.userPresentableMessage === 'string' ? ext.userPresentableMessage : null,
185
+ meta: ext.meta && typeof ext.meta === 'object' ? ext.meta : null,
186
+ retryable,
187
+ })
188
+ }
189
+
120
190
  /**
121
191
  * A GraphQL caller bound to one key. Throws a clear Error on transport failure,
122
192
  * on an HTTP error, and on a GraphQL `errors` payload — an `apply` that half
@@ -160,12 +230,20 @@ function makeClient({ apiKey, fetch: fetchImpl, endpoint = ENDPOINT, sleep, maxR
160
230
  continue
161
231
  }
162
232
  if (res.status === 429) {
163
- throw new Error(`Linear rate-limited this request and did not recover after ${maxRetries} retries`)
233
+ // RETRYABLE, said out loud. This is the failure where waiting is the
234
+ // whole answer, and the one a refusal must be distinguishable from.
235
+ throw new LinearRefusal(
236
+ `Linear rate-limited this request and did not recover after ${maxRetries} retries`,
237
+ { retryable: true },
238
+ )
164
239
  }
165
240
  if (!res.ok) throw new Error(`Linear API returned HTTP ${res.status}`)
166
241
  const body = await res.json()
167
242
  if (body && Array.isArray(body.errors) && body.errors.length) {
168
- throw new Error(`Linear API error: ${body.errors.map((e) => e.message).join('; ')}`)
243
+ // NOT RETRIED, when Linear says it cannot succeed. The loop above exists
244
+ // for throttling; a usage cap retried three times is three identical
245
+ // refusals and a slower failure.
246
+ throw refusalFrom(body.errors)
169
247
  }
170
248
  return body && body.data
171
249
  }
@@ -388,6 +466,34 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
388
466
  if (data && data.team) return (data.team.states && data.team.states.nodes) || []
389
467
  return (data && data.workflowStates && data.workflowStates.nodes) || []
390
468
  },
469
+ // An issue's comments, and a comment posted onto it — the two halves of
470
+ // `spec-sync preserve`, which keeps a reporter's original description alive
471
+ // once the linking push replaces it with the generated spec.
472
+ //
473
+ // COMMENTS ARE NOT PART OF THE PROJECTION and must never become part of it.
474
+ // `sync.fieldOwnership` excludes them deliberately (they are Linear-native
475
+ // triage), which is exactly what makes a comment a safe place to preserve
476
+ // something: no push can clobber it, and no read of one feeds the repo.
477
+ // These two ops exist for one write at adoption and one read to make that
478
+ // write idempotent — nothing else calls them.
479
+ //
480
+ // API-only, like `listIssueStates` and `readTeam`: the contract runs one
481
+ // way, so the adapter may add ops and may only never be missing one.
482
+ async listComments(issueId) {
483
+ const data = await call(
484
+ `query($id: String!) { issue(id: $id) { comments { nodes { id body } } } }`,
485
+ { id: issueId },
486
+ )
487
+ return (data && data.issue && data.issue.comments && data.issue.comments.nodes) || []
488
+ },
489
+ async createComment(issueId, body) {
490
+ const data = await call(
491
+ `mutation($input: CommentCreateInput!) {
492
+ commentCreate(input: $input) { success comment { id body } } }`,
493
+ { input: { issueId, body } },
494
+ )
495
+ return (data && data.commentCreate && data.commentCreate.comment) || null
496
+ },
391
497
  }
392
498
  }
393
499
 
@@ -424,6 +530,9 @@ function stateIdFor(bucket, config, states) {
424
530
 
425
531
  module.exports = {
426
532
  ENDPOINT,
533
+ LinearRefusal,
534
+ refusalFrom,
535
+ UNRETRYABLE_CODES,
427
536
  MAX_RETRIES,
428
537
  resolveApiKey,
429
538
  makeClient,