@skitterbyte/skitterspec-linear 14.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 (49) hide show
  1. package/MIGRATION.md +304 -4
  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 +103 -25
  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 +1101 -108
  11. package/assets/rules/spec-planning.md +39 -7
  12. package/assets/rules/spec-reports.md +210 -31
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +102 -49
  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 +183 -39
  19. package/assets/skills/spec-hotfix/SKILL.md +96 -49
  20. package/assets/skills/spec-init/SKILL.md +18 -6
  21. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  22. package/assets/skills/spec-next/SKILL.md +147 -62
  23. package/assets/skills/spec-push/SKILL.md +45 -0
  24. package/assets/skills/spec-review/SKILL.md +89 -2
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +26 -3
  27. package/assets/skills/spec-status/SKILL.md +20 -6
  28. package/assets/skills/spec-sync/SKILL.md +1 -0
  29. package/package.json +2 -2
  30. package/src/cli.js +940 -116
  31. package/src/env/classify.js +87 -2
  32. package/src/env/config.js +214 -17
  33. package/src/env/hooks.js +49 -9
  34. package/src/env/live.js +94 -0
  35. package/src/env/resolve.js +36 -2
  36. package/src/env/review.js +581 -21
  37. package/src/env/serve.js +298 -19
  38. package/src/env/supervise.js +8 -1
  39. package/src/init.js +88 -13
  40. package/src/vendor/linear/api.js +111 -2
  41. package/src/vendor/linear/cli-sync.js +661 -11
  42. package/src/vendor/linear/config.js +41 -13
  43. package/src/vendor/linear/doctor.js +6 -5
  44. package/src/vendor/sync-core/index.js +11 -3
  45. package/src/vendor/sync-core/src/compare.js +65 -0
  46. package/src/vendor/sync-core/src/normalize.js +26 -0
  47. package/src/vendor/sync-core/src/retarget.js +1 -1
  48. package/src/vendor/sync-core/src/task-block.js +2 -2
  49. /package/assets/hooks/{review-gate.js → review-gate.cjs} +0 -0
@@ -11,13 +11,13 @@
11
11
  *
12
12
  * Mirrors the shape/idiom of `src/env/config.js` (frozen defaults, merge known
13
13
  * keys only, forward-compatible on unknown keys). Zero-dependency. The one place
14
- * it is stricter: a `sync.fieldOwnership` value outside `both|pull|push` is a
15
- * hard error — the engine's whole safety model rests on those enums.
14
+ * it is stricter: a `sync.fieldOwnership` value outside `both|pull|push|none` is
15
+ * a hard error — the engine's whole safety model rests on those enums.
16
16
  *
17
17
  * Shape (see assets/core/linear.config.md for field docs):
18
18
  * {
19
19
  * linear: { teamKey, teamId, projectId },
20
- * intake: { label, bugLabels, hotfixLabels },
20
+ * intake: { label, bugLabels, hotfixLabels, preserveOriginal },
21
21
  * mapping: { specFolder, phases, tasks },
22
22
  * states: { backlog, "in-progress", complete, cancelled },
23
23
  * release: { stages: [{ key, state }] },
@@ -25,7 +25,7 @@
25
25
  * branch: { pattern },
26
26
  * sync: {
27
27
  * baseDir, backupDir,
28
- * fieldOwnership: { <field>: "both" | "pull" | "push" },
28
+ * fieldOwnership: { <field>: "both" | "pull" | "push" | "none" },
29
29
  * localOnlySections: string[]
30
30
  * }
31
31
  * }
@@ -36,7 +36,14 @@ const { join } = require('node:path')
36
36
 
37
37
  const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
38
38
 
39
- const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
39
+ // `none` is how a repo DECLINES a field the defaults own. It has to be a
40
+ // value rather than an omitted key, because an omitted key cannot override a
41
+ // default that is present — and `mergeFieldOwnership` merges per key onto the
42
+ // defaults, so there is no way to subtract one. Every reader asks `ownsField`
43
+ // rather than testing for the key, which is what keeps the two indistinguishable
44
+ // downstream: a declined field and an unlisted one both leave the projection
45
+ // without that key at all.
46
+ const OWNERSHIP = Object.freeze(['both', 'pull', 'push', 'none'])
40
47
 
41
48
  // How a phase's task list is projected into its sub-issue description.
42
49
  // checklist — mirror the tasks as a read-only markdown checklist (default)
@@ -125,7 +132,19 @@ const DEFAULT_CONFIG = Object.freeze({
125
132
  // and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
126
133
  // both: production is the more specific destination, and the cost of getting it
127
134
  // wrong is asymmetric — a fix that lands only on main never reaches prod.
128
- intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]) }),
135
+ // `preserveOriginal` is the other half of intake, and it is about what
136
+ // adoption COSTS rather than where it routes. Adopting an existing issue
137
+ // replaces the reporter's description with the generated spec, so
138
+ // `spec-sync preserve` first posts that description as a comment — the one
139
+ // surface on the issue that sync never touches.
140
+ //
141
+ // ON BY DEFAULT, and `false` is how a project declines. Opt-in was rejected
142
+ // for the reason `sync.fieldOwnership.assignee` records below: a repo that
143
+ // never added the line would look identical to one that did not want the
144
+ // feature, and the only signal would be noticing a lost report weeks later.
145
+ // Being wrong in this direction costs a comment nobody needed; the other
146
+ // direction costs someone their words.
147
+ intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]), preserveOriginal: true }),
129
148
  // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
130
149
  // synced (they live only in the repo phase files).
131
150
  // A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
@@ -165,17 +184,22 @@ const DEFAULT_CONFIG = Object.freeze({
165
184
  // the set, so the PM's triage is never touched. The `push` marker is retained
166
185
  // for shape; any key you add joins the pushed projection.
167
186
  //
168
- // `assignee` is the one field that OPTS IN this way rather than shipping on:
169
- // add `"assignee": "push"` and the issue is assigned to whoever is building
170
- // the spec, and released when it completes. Left out (the default) the whole
171
- // feature is inert nothing is written, no hash is recorded in a snapshot,
172
- // and `status` prints no assignee line. It is deliberately not a new config
173
- // key: this map is already the documented extension point, and assignment is
174
- // exactly "one more field the repo owns".
187
+ // `assignee` ships ON, and the opt-out is `"assignee": "none"`. It was
188
+ // opt-in until v16, and the cost of that was silent in exactly the wrong
189
+ // direction: a repo that never added the line looked identical to one that
190
+ // did not want the feature, so the only signal was noticing an unassigned
191
+ // issue weeks later.
192
+ //
193
+ // WHAT MAKES ON-BY-DEFAULT SAFE, and it is not this line: a spec that
194
+ // records no assignee sends none (so a PM's triage survives), and a snapshot
195
+ // with no assignee key means "never pushed" rather than "was null" (so no
196
+ // upgrade emits a clear). Flipping the default widens who gets assigned; it
197
+ // cannot widen who gets UNassigned. See `compare.js` `issueChanges`.
175
198
  fieldOwnership: Object.freeze({
176
199
  description: 'push',
177
200
  subIssues: 'push',
178
201
  workflowState: 'push',
202
+ assignee: 'push',
179
203
  }),
180
204
  localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
181
205
  // Fields that are keyed collections (arrays of objects with a stable id),
@@ -203,6 +227,7 @@ function defaults() {
203
227
  label: DEFAULT_CONFIG.intake.label,
204
228
  bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
205
229
  hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
230
+ preserveOriginal: DEFAULT_CONFIG.intake.preserveOriginal,
206
231
  },
207
232
  mapping: { ...DEFAULT_CONFIG.mapping },
208
233
  states: { ...DEFAULT_CONFIG.states },
@@ -412,6 +437,9 @@ function mergeConfig(base, parsed) {
412
437
  if (Array.isArray(parsed.intake.hotfixLabels)) {
413
438
  base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
414
439
  }
440
+ // `'boolean'`, so only a real `false` turns this off. A string, a null or a
441
+ // typo leaves the default standing rather than reading as a decline.
442
+ assign(base.intake, parsed.intake, 'preserveOriginal', 'boolean')
415
443
  }
416
444
 
417
445
  if (isObject(parsed.mapping)) {
@@ -261,10 +261,11 @@ function keyCheck(s = {}, tracker = {}) {
261
261
  *
262
262
  * WHAT WOULD MAKE THIS CHECK ACCUSE THE INNOCENT, and why it does not:
263
263
  *
264
- * - **A project that never opted in.** Assignment is opt-in through
265
- * `sync.fieldOwnership.assignee`; without it nothing reads an identity, so
266
- * reporting one as missing would tell a healthy project to fix something it
267
- * deliberately does not use. That is `skipped`, and it is the common case.
264
+ * - **A project that does not own the field.** `sync.fieldOwnership.assignee`
265
+ * decides, and `"none"` declines it as surely as omitting it does; without
266
+ * ownership nothing reads an identity, so reporting one as missing would
267
+ * tell a healthy project to fix something it deliberately does not use.
268
+ * That is `skipped`.
268
269
  * - **An identity that is merely underivable.** No key, offline, a shared
269
270
  * credential — all ordinary, all `missing` rather than `broken`, because the
270
271
  * lifecycle skills skip assignment and carry on. Nothing here fails a run.
@@ -276,7 +277,7 @@ function keyCheck(s = {}, tracker = {}) {
276
277
  function identityCheck(s = {}, tracker = {}) {
277
278
  if (!tracker.present) return row('identity', 'identity', 'skipped', 'no tracker configured')
278
279
  if (!s.owned) {
279
- return row('identity', 'identity', 'skipped', 'assignment not enabled (sync.fieldOwnership.assignee)')
280
+ return row('identity', 'identity', 'skipped', 'assignment not owned here (sync.fieldOwnership.assignee)')
280
281
  }
281
282
  if (!s.ok) {
282
283
  return row(
@@ -13,18 +13,19 @@
13
13
  * stored matches what was sent — it merges nothing (see `src/verify.js`).
14
14
  */
15
15
 
16
- const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
- const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
16
+ const { ownsField, normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
+ const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
18
18
  const { readBase, writeBase } = require('./src/base.js')
19
19
  const { push, recordPush, projectionOf } = require('./src/push.js')
20
20
  const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
21
21
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
22
22
  const { detectLegacyMirror } = require('./src/legacy.js')
23
- const { compareStored } = require('./src/verify.js')
23
+ const { compareStored, stream } = require('./src/verify.js')
24
24
  const { flattenNestedTables } = require('./src/tables.js')
25
25
  const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
26
26
 
27
27
  module.exports = {
28
+ ownsField,
28
29
  normalizeLocal,
29
30
  // Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
30
31
  // to read, so the listing must resolve the mode rather than assume `subissue`.
@@ -58,6 +59,13 @@ module.exports = {
58
59
  sanitizeSpecMarkdown,
59
60
  detectLegacyMirror,
60
61
  compareStored,
62
+ // The tolerant reduction behind both `compareStored` and the snapshot's
63
+ // `descriptionStream` hash — exported so a provider can reduce a read-back the
64
+ // same way rather than writing a second one that disagrees.
65
+ stream,
66
+ // Did someone else edit the tracker's description since the last push?
67
+ // Three-valued; `null` is cannot-tell and must never be reported as drift.
68
+ remoteDescriptionEdited,
61
69
  flattenNestedTables,
62
70
  planRetarget,
63
71
  applyRetarget,
@@ -19,6 +19,10 @@
19
19
  */
20
20
 
21
21
  const { createHash } = require('node:crypto')
22
+ // The tolerant reduction `verify.js` already uses to tell Linear's own
23
+ // reserialisation apart from a real edit. Imported rather than reimplemented:
24
+ // two reductions that disagree would be two different answers to one question.
25
+ const { stream } = require('./verify.js')
22
26
 
23
27
  // Deterministic JSON: object keys sorted recursively; array order preserved.
24
28
  // undefined normalises to null.
@@ -69,8 +73,68 @@ function specIssueFieldHashes(p) {
69
73
  // "the feature is inert" has to include the files it writes, or opting in later
70
74
  // would find a history of hashes it never agreed to.
71
75
  if (p.assignee !== undefined) hashes.assignee = hashField(p.assignee ?? null)
76
+
77
+ // A SECOND HASH OF THE SAME TEXT, answering a different question.
78
+ //
79
+ // `description` above decides what to PUSH: it is exact, because any
80
+ // difference between the repo and what the repo last sent is a difference the
81
+ // repo should send again. This one decides whether SOMEBODY ELSE has been
82
+ // here, and for that exactness is precisely wrong — Linear reserialises
83
+ // markdown on save (bullets rewritten, ordered lists renumbered, table
84
+ // separators collapsed), so comparing the exact hash against a read-back would
85
+ // report a human edit on every intact mirror in the workspace.
86
+ //
87
+ // So it hashes the `stream` reduction instead: word characters only, which is
88
+ // what `compareStored` already compares and what makes every benign transform
89
+ // invisible. The cost is the mirror image — an edit consisting ONLY of
90
+ // punctuation or formatting is invisible here too — and that is the right way
91
+ // round: the unknown case goes to the branch that says nothing rather than the
92
+ // one that accuses (`.claude/rules/negative-checks.md` rule 4).
93
+ //
94
+ // It is recorded and never planned on. `issueChanges` reads `description`,
95
+ // `state` and `assignee` by name and never iterates this object, so adding a
96
+ // key here cannot make a push pending — there is a test for exactly that.
97
+ hashes.descriptionStream = hashField(stream(p.description ?? ''))
72
98
  return hashes
73
99
  }
100
+
101
+ /**
102
+ * Has the tracker's copy of the description been edited by someone other than
103
+ * this repo, since the last push?
104
+ *
105
+ * THREE ANSWERS, and the third is the point: `true`, `false`, and `null` for
106
+ * cannot-tell. Only `true` may be reported to a user, because only `true` is
107
+ * evidence. The three cannot-tells, each an absence that proves nothing:
108
+ *
109
+ * - no `issueFields.descriptionStream` in the snapshot. EVERY snapshot written
110
+ * before this function existed lacks it, so reading that absence as evidence
111
+ * would accuse every spec in every repo on the first run after an upgrade.
112
+ * This is the blind spot that matters most here.
113
+ * - no `description` key on the remote object. The caller did not ask the
114
+ * tracker for it, or could not.
115
+ * - a `description` that is not a string. The same blind spot `compareStored`
116
+ * documents: a value the caller never fetched is indistinguishable from one
117
+ * the tracker really holds empty.
118
+ *
119
+ * Pure: no I/O, no clock, no randomness.
120
+ *
121
+ * @param {object|null} snapshot the committed last-pushed snapshot
122
+ * @param {object|null} remote what the tracker currently holds for the issue
123
+ * @returns {boolean|null}
124
+ */
125
+ function remoteDescriptionEdited(snapshot, remote) {
126
+ const fields = snapshot && snapshot.issueFields
127
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return null
128
+ const recorded = fields.descriptionStream
129
+ if (typeof recorded !== 'string' || !recorded) return null
130
+
131
+ if (!remote || typeof remote !== 'object') return null
132
+ if (!('description' in remote)) return null
133
+ const text = remote.description
134
+ if (typeof text !== 'string') return null
135
+
136
+ return hashField(stream(text)) !== recorded
137
+ }
74
138
  // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
75
139
  const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
76
140
 
@@ -228,6 +292,7 @@ function isEmptyPlan(plan) {
228
292
 
229
293
  module.exports = {
230
294
  planChanges,
295
+ remoteDescriptionEdited,
231
296
  issueChanges,
232
297
  specIssueFieldHashes,
233
298
  snapshotOf,
@@ -488,11 +488,36 @@ function lintPhases(snapshotDir, config) {
488
488
 
489
489
  // --- ownership-driven field set ---------------------------------------------
490
490
 
491
+ /**
492
+ * Does this repo own `field` — i.e. does the projection carry it at all?
493
+ *
494
+ * THE VALUE DECIDES, NEVER THE KEY'S PRESENCE. A field configured `none` is
495
+ * declared not-ours, and it must be indistinguishable from one the config never
496
+ * listed: both produce a projection with no such key, which is the single
497
+ * vocabulary every downstream reader already speaks for "not in play"
498
+ * (`compare.js` hashes it only when defined; the status report prints an
499
+ * assignee line only when defined).
500
+ *
501
+ * Asked in one place so the two cannot drift. Testing `field in fieldOwnership`
502
+ * at a call site reads correct and is not: once a field is owned by DEFAULT the
503
+ * key is always present, and the test silently becomes "always true".
504
+ */
505
+ function ownsField(config, field) {
506
+ const owned = config && config.sync && config.sync.fieldOwnership
507
+ return !!owned && field in owned && owned[field] !== 'none'
508
+ }
509
+
491
510
  // Reduce an `extracted` map to exactly the configured field keys, defaulting a
492
511
  // missing field to `null` so local and remote always share an identical set.
512
+ //
513
+ // A declined field is OMITTED, not set null. The two are not interchangeable
514
+ // here: `null` is a value this projection asserts — it is how a finished spec
515
+ // hands its issue back — so writing it for a field the repo does not own would
516
+ // clear in Linear exactly what declining ownership promised not to touch.
493
517
  function toFieldSet(extracted, config) {
494
518
  const out = {}
495
519
  for (const field of Object.keys(config.sync.fieldOwnership)) {
520
+ if (!ownsField(config, field)) continue
496
521
  out[field] = field in extracted ? extracted[field] : null
497
522
  }
498
523
  return out
@@ -1065,6 +1090,7 @@ function stateSuggestions(config, workspaceStates) {
1065
1090
  }
1066
1091
 
1067
1092
  module.exports = {
1093
+ ownsField,
1068
1094
  stateSuggestions,
1069
1095
  configuredStateNames,
1070
1096
  normalizeLocal,
@@ -36,7 +36,7 @@ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
36
36
  // An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
37
37
  // because `linear_url` carries the identifier lowercased in its path
38
38
  // (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
39
- // in ~/code/ereqs pointing at the old key — the same ones the hand-repair
39
+ // in a live repo pointing at the old key — the same ones the hand-repair
40
40
  // missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
41
41
  // token like `utf-8` is never in scope.
42
42
  const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
@@ -2,8 +2,8 @@
2
2
 
3
3
  // Task bullets in a phase file are hand-wrapped prose, not single lines:
4
4
  //
5
- // - [x] Add `DbProcessEventOutbox` to `prisma/schema.prisma`, modelled on
6
- // `DbNotificationOutbox`: status, attempts, `nextAttemptAt`, …
5
+ // - [x] Add the retry column to the outbox table, modelled on the existing
6
+ // queue: status, attempts, `nextAttemptAt`, …
7
7
  //
8
8
  // A Linear issue title is single-line, so the two representations differ by
9
9
  // wrapping alone. This module is the one place that converts between them: