@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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 (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,81 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The committed **last-pushed snapshot** sidecar.
5
+ *
6
+ * One-way sync (repo → Linear) records what it last pushed per spec at
7
+ * `{sync.baseDir}/{identifier}.base.json`, committed so each worktree carries its
8
+ * own snapshot. The snapshot is a set of content hashes
9
+ * (`{ issue, subIssues: {id:hash} }`, see compare.js
10
+ * `snapshotOf`); `planChanges` diffs the current projection against it to decide
11
+ * create/update/skip — no remote read. After a successful push the engine
12
+ * rewrites it (`writeBase`). Generic JSON read/write; the shape is the caller's.
13
+ */
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+
18
+ function baseFile(dir, identifier, config) {
19
+ return path.join(dir, config.sync.baseDir, `${identifier}.base.json`)
20
+ }
21
+
22
+ /**
23
+ * Read a spec's committed base. Returns the parsed object, or `null` when no base
24
+ * exists yet (never synced) — the compare treats null as "no prior state".
25
+ */
26
+ function readBase(dir, identifier, config) {
27
+ const file = baseFile(dir, identifier, config)
28
+ let raw
29
+ try {
30
+ raw = fs.readFileSync(file, 'utf-8')
31
+ } catch (error) {
32
+ if (error.code === 'ENOENT') return null
33
+ throw error
34
+ }
35
+ try {
36
+ return JSON.parse(raw)
37
+ } catch (error) {
38
+ throw new Error(`Invalid base ${path.relative(dir, file)}: ${error.message}`)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Rewrite a spec's committed base with the freshly-synced field set. Creates
44
+ * `{sync.baseDir}` if needed. Returns the absolute path written.
45
+ */
46
+ function writeBase(dir, identifier, config, data) {
47
+ const file = baseFile(dir, identifier, config)
48
+ fs.mkdirSync(path.dirname(file), { recursive: true })
49
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
50
+ return file
51
+ }
52
+
53
+ /**
54
+ * Back up the about-to-be-clobbered `side` ('local' | 'remote') into
55
+ * `{sync.backupDir}` before a --force. `timestamp` is caller-supplied (the engine
56
+ * never reads the clock); the name is made collision-safe with a `-N` counter.
57
+ * Returns the absolute path written, or null when `data` is nullish (nothing to
58
+ * back up — e.g. forcing a pull with no prior remote).
59
+ */
60
+ function backup(side, dir, identifier, config, { timestamp, data }) {
61
+ if (data == null) return null
62
+ const backupRoot = path.join(dir, config.sync.backupDir)
63
+ fs.mkdirSync(backupRoot, { recursive: true })
64
+
65
+ const stem = `${identifier}.${side}.${timestamp}`
66
+ let file = path.join(backupRoot, `${stem}.json`)
67
+ let n = 1
68
+ while (fs.existsSync(file)) {
69
+ file = path.join(backupRoot, `${stem}-${n}.json`)
70
+ n += 1
71
+ }
72
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
73
+ return file
74
+ }
75
+
76
+ module.exports = {
77
+ readBase,
78
+ writeBase,
79
+ backup,
80
+ baseFile,
81
+ }
@@ -0,0 +1,306 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * One-sided change detection for one-way sync (repo → tracker).
5
+ *
6
+ * The repo is the source of truth; Linear is a generated mirror. We never read
7
+ * remote content. Instead we record a **last-pushed snapshot** — a content hash
8
+ * per object — and diff the current local projection against it:
9
+ *
10
+ * - an item with no id → CREATE (never pushed)
11
+ * - an item whose hash changed → UPDATE (edited since last push)
12
+ * - an item whose hash matches → skip (unchanged)
13
+ *
14
+ * `planChanges(projection, snapshot)` returns the create/update plan the push
15
+ * skill applies over MCP; `snapshotOf(projection)` is what we record afterwards.
16
+ *
17
+ * Pure and deterministic: hashes are a sorted-key JSON → SHA-1, so key order and
18
+ * null/undefined never cause a false diff. No Date.now()/Math.random().
19
+ */
20
+
21
+ const { createHash } = require('node:crypto')
22
+ // The tolerant reduction `verify.js` already uses to tell the tracker'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
+ // Dialect-parameterised: the provider's `config.dialect` names the transforms.
26
+ const { stream } = require('./verify.js')
27
+
28
+ // Deterministic JSON: object keys sorted recursively; array order preserved.
29
+ // undefined normalises to null.
30
+ function stableStringify(value) {
31
+ if (value === undefined || value === null) return 'null'
32
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
33
+ if (typeof value === 'object') {
34
+ const keys = Object.keys(value).sort()
35
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
36
+ }
37
+ return JSON.stringify(value)
38
+ }
39
+
40
+ // Stable content hash of a value.
41
+ function hashField(value) {
42
+ return createHash('sha1').update(stableStringify(value)).digest('hex')
43
+ }
44
+
45
+ // --- content hashes (id / local handles excluded, so they never affect the
46
+ // diff — an id stamped in after a create must not read as an edit) ---------
47
+
48
+ // The spec ISSUE fields the repo owns and pushes: prose + workflow state.
49
+ // Priority, labels, cycles and comments are Linear-native triage — one-way sync
50
+ // neither pushes nor reads them, so a PM's triage is never clobbered.
51
+ //
52
+ // The COMBINED hash, retained for snapshots written before the fields were split
53
+ // (and read by any older CLI still pointed at this repo). New pushes diff
54
+ // `issueFields` below; this stays so neither direction breaks on the other.
55
+ function specIssueHash(p) {
56
+ return hashField({ description: p.description ?? null, state: p.status ?? null })
57
+ }
58
+
59
+ // Per-field hashes of the same two values.
60
+ //
61
+ // They are hashed SEPARATELY because they have different owners once a spec is
62
+ // finished. The repo owns the description forever, but the workflow state is
63
+ // handed off: a deploy pipeline (see `release.stages`) moves the issue past
64
+ // `complete`, and welding the two meant any prose edit re-emitted the state and
65
+ // dragged the issue back. Diffing them apart is what lets a push touch the
66
+ // description without re-asserting a state someone else now owns.
67
+ function specIssueFieldHashes(p, config) {
68
+ const hashes = {
69
+ description: hashField(p.description ?? null),
70
+ state: hashField(p.status ?? null),
71
+ }
72
+ // ONLY WHEN THE FIELD IS IN PLAY. A repo that has not opted `assignee` into
73
+ // `sync.fieldOwnership` must not accumulate assignee hashes in its snapshots —
74
+ // "the feature is inert" has to include the files it writes, or opting in later
75
+ // would find a history of hashes it never agreed to.
76
+ if (p.assignee !== undefined) hashes.assignee = hashField(p.assignee ?? null)
77
+
78
+ // A SECOND HASH OF THE SAME TEXT, answering a different question.
79
+ //
80
+ // `description` above decides what to PUSH: it is exact, because any
81
+ // difference between the repo and what the repo last sent is a difference the
82
+ // repo should send again. This one decides whether SOMEBODY ELSE has been
83
+ // here, and for that exactness is precisely wrong — the tracker reserialises
84
+ // markdown on save (bullets rewritten, ordered lists renumbered, table
85
+ // separators collapsed), so comparing the exact hash against a read-back would
86
+ // report a human edit on every intact mirror in the workspace.
87
+ //
88
+ // So it hashes the `stream` reduction instead: word characters only, which is
89
+ // what `compareStored` already compares and what makes every benign transform
90
+ // invisible. The cost is the mirror image — an edit consisting ONLY of
91
+ // punctuation or formatting is invisible here too — and that is the right way
92
+ // round: the unknown case goes to the branch that says nothing rather than the
93
+ // one that accuses (`.claude/rules/negative-checks.md` rule 4).
94
+ //
95
+ // It is recorded and never planned on. `issueChanges` reads `description`,
96
+ // `state` and `assignee` by name and never iterates this object, so adding a
97
+ // key here cannot make a push pending — there is a test for exactly that.
98
+ hashes.descriptionStream = hashField(stream(p.description ?? '', config))
99
+ return hashes
100
+ }
101
+
102
+ /**
103
+ * Has the tracker's copy of the description been edited by someone other than
104
+ * this repo, since the last push?
105
+ *
106
+ * THREE ANSWERS, and the third is the point: `true`, `false`, and `null` for
107
+ * cannot-tell. Only `true` may be reported to a user, because only `true` is
108
+ * evidence. The three cannot-tells, each an absence that proves nothing:
109
+ *
110
+ * - no `issueFields.descriptionStream` in the snapshot. EVERY snapshot written
111
+ * before this function existed lacks it, so reading that absence as evidence
112
+ * would accuse every spec in every repo on the first run after an upgrade.
113
+ * This is the blind spot that matters most here.
114
+ * - no `description` key on the remote object. The caller did not ask the
115
+ * tracker for it, or could not.
116
+ * - a `description` that is not a string. The same blind spot `compareStored`
117
+ * documents: a value the caller never fetched is indistinguishable from one
118
+ * the tracker really holds empty.
119
+ *
120
+ * Pure: no I/O, no clock, no randomness.
121
+ *
122
+ * @param {object|null} snapshot the committed last-pushed snapshot
123
+ * @param {object|null} remote what the tracker currently holds for the issue
124
+ * @returns {boolean|null}
125
+ */
126
+ function remoteDescriptionEdited(snapshot, remote, config) {
127
+ const fields = snapshot && snapshot.issueFields
128
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return null
129
+ const recorded = fields.descriptionStream
130
+ if (typeof recorded !== 'string' || !recorded) return null
131
+
132
+ if (!remote || typeof remote !== 'object') return null
133
+ // A provider whose remote shape differs supplies `config.remote.description`.
134
+ const hook = config && config.remote && config.remote.description
135
+ const text = typeof hook === 'function' ? hook(remote) : 'description' in remote ? remote.description : undefined
136
+ if (typeof text !== 'string') return null
137
+
138
+ return hashField(stream(text, config)) !== recorded
139
+ }
140
+ // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
141
+ const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
142
+
143
+ /**
144
+ * The snapshot to commit after a successful push: the spec-issue hash plus a
145
+ * content hash per sub-issue that currently has an id. Create items (id == null)
146
+ * aren't recorded until the skill stamps their returned id and the next
147
+ * projection includes it.
148
+ */
149
+ function snapshotOf(projection, config) {
150
+ const p = projection || {}
151
+ const byId = (arr, hash) => {
152
+ const out = {}
153
+ for (const item of arr || []) if (item && item.id != null) out[String(item.id)] = hash(item)
154
+ return out
155
+ }
156
+ return {
157
+ // Both shapes are written: `issueFields` is what a current push diffs, and
158
+ // `issue` keeps a snapshot readable by anything still expecting the combined
159
+ // hash. Cheap insurance — two SHA-1s of text already in hand.
160
+ issue: specIssueHash(p),
161
+ issueFields: specIssueFieldHashes(p, config),
162
+ subIssues: byId(p.subIssues, subIssueHash),
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Diff the local projection against the last-pushed snapshot.
168
+ * @returns {{ issue?: object, subIssues: {create,update} }}
169
+ * `unstamped` (when present) lists phases whose stamp is missing while the
170
+ * snapshot still remembers an unminted-for id — ambiguous, so neither created
171
+ * nor updated. create items carry a `ref` (local handle) and no id; update items carry both
172
+ * — the `ref` because the read-back check matches sub-issues to phases BY ref,
173
+ * and an update with only an id makes every one of them look unmatched.
174
+ * `plan.issue` (when present) is the spec issue's description + state; the push
175
+ * skill applies its provider's project/grouping choice on top of it.
176
+ */
177
+ function planChanges(projection, snapshot) {
178
+ const p = projection || {}
179
+ const snap = snapshot || {}
180
+ const snapS = snap.subIssues || {}
181
+
182
+ // Ids the last push minted that no phase claims any more. A phase file's stamp
183
+ // is the only link back to its sub-issue, and it lives in frontmatter — so a
184
+ // whole-file rewrite, a hand edit or a bad merge drops it while the sub-issue
185
+ // carries on existing. This snapshot is the only memory that it was ever
186
+ // minted, and nothing else in the push path re-reads the tracker.
187
+ const claimed = new Set()
188
+ for (const s of p.subIssues || []) if (s.id != null) claimed.add(String(s.id))
189
+ const unclaimed = Object.keys(snapS).filter((id) => !claimed.has(id))
190
+
191
+ const subIssues = { create: [], update: [] }
192
+ const unstamped = []
193
+ for (const s of p.subIssues || []) {
194
+ if (s.id == null) {
195
+ // THREE STATES, NOT TWO. "No stamp" means "new phase" only when every id
196
+ // the snapshot remembers is still claimed. With an unclaimed id sitting
197
+ // beside an unstamped phase the two readings — a new phase, and a phase
198
+ // whose stamp was lost — are indistinguishable from here, so this routes to
199
+ // the harmless branch and mints nothing. Being wrong this way costs a
200
+ // re-stamp; the other way cost a duplicate sub-issue and a manual cancel.
201
+ if (unclaimed.length) {
202
+ unstamped.push({ ref: s.ref, name: s.name, candidates: unclaimed.slice() })
203
+ } else {
204
+ subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
205
+ }
206
+ } else if (snapS[String(s.id)] !== subIssueHash(s)) {
207
+ subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
208
+ }
209
+ }
210
+
211
+ const plan = { subIssues }
212
+ if (unstamped.length) plan.unstamped = unstamped
213
+ const issue = issueChanges(p, snap)
214
+ if (issue) plan.issue = issue
215
+ return plan
216
+ }
217
+
218
+ /**
219
+ * What changed about the spec issue itself — `{description}`, `{state}`, or
220
+ * both, or null when neither did.
221
+ *
222
+ * Three states, not two: a snapshot may carry the split hashes, only the old
223
+ * combined one, or nothing at all. Only the first can say which field moved.
224
+ *
225
+ * The other two are UNKNOWN, and route to the harmless branch — today's welded
226
+ * behaviour, sending both fields. Sending a state that did not change is
227
+ * redundant; withholding one that did would leave the mirror silently stale, and
228
+ * that is the failure worth avoiding. The push rewrites the snapshot in the new
229
+ * shape, so a spec passes through `unknown` exactly once.
230
+ */
231
+ function issueChanges(projection, snapshot) {
232
+ const p = projection || {}
233
+ const snap = snapshot || {}
234
+ const both = () => {
235
+ const out = { description: p.description ?? null, state: p.status ?? null }
236
+ // ASSERT, NEVER CLEAR, on this path. There are no split hashes here, so
237
+ // whether an assignee was ever pushed is unknown — and the harmless reading
238
+ // of "unknown" is to send one we have and stay silent about one we don't.
239
+ if (p.assignee != null) out.assignee = p.assignee
240
+ return out
241
+ }
242
+
243
+ const fields = snap.issueFields
244
+ if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
245
+ // No split hashes recorded: an old snapshot, or no snapshot at all (a
246
+ // create, which needs both fields anyway).
247
+ if (snap.issue !== specIssueHash(p)) return both()
248
+ // Description and state are unchanged — but the COMBINED HASH SAYS NOTHING
249
+ // ABOUT THE ASSIGNEE, which was never one of its inputs. Absence of evidence
250
+ // again, and the two directions are not equally safe: asserting an assignee
251
+ // the repo actually recorded costs one redundant write, while staying quiet
252
+ // would strand it until some unrelated prose edit happened to push. Clearing
253
+ // is still never guessed at. The push rewrites the snapshot in the split
254
+ // shape, so a spec passes through here exactly once.
255
+ return p.assignee != null ? { assignee: p.assignee } : null
256
+ }
257
+
258
+ const want = specIssueFieldHashes(p)
259
+ const changed = {}
260
+ if (fields.description !== want.description) changed.description = p.description ?? null
261
+ if (fields.state !== want.state) changed.state = p.status ?? null
262
+
263
+ // ASSIGNEE — AN ABSENT SNAPSHOT KEY MEANS "NEVER PUSHED", NOT "WAS NULL".
264
+ //
265
+ // What would fool this check: every spec linked before assignee existed has a
266
+ // snapshot with no `assignee` key, and so does every spec in a repo that never
267
+ // opted the field in. Read that absence as `null` and the first push after
268
+ // upgrade computes null → null → "unchanged"… except where a PM had assigned
269
+ // the issue in Linear, which the repo would then silently clear. The bill for
270
+ // getting this wrong is somebody else's triage, workspace-wide, in one command.
271
+ //
272
+ // So absence is routed to the harmless branch: assert an assignee we actually
273
+ // have, and never send a clear at something we never set.
274
+ // (`.claude/rules/negative-checks.md` — prefer a positive signal to an absence,
275
+ // and bias the unknown case toward inaction.)
276
+ if (want.assignee !== undefined) {
277
+ if (fields.assignee === undefined) {
278
+ if (p.assignee != null) changed.assignee = p.assignee
279
+ } else if (fields.assignee !== want.assignee) {
280
+ // A recorded hash IS a positive signal that the repo pushed this field
281
+ // before, so a change to null here is a retraction of our own assignment
282
+ // rather than a guess at someone else's.
283
+ changed.assignee = p.assignee ?? null
284
+ }
285
+ }
286
+
287
+ return Object.keys(changed).length ? changed : null
288
+ }
289
+
290
+ // True when a plan would push nothing.
291
+ function isEmptyPlan(plan) {
292
+ return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
293
+ }
294
+
295
+ module.exports = {
296
+ planChanges,
297
+ remoteDescriptionEdited,
298
+ issueChanges,
299
+ specIssueFieldHashes,
300
+ snapshotOf,
301
+ isEmptyPlan,
302
+ hashField,
303
+ stableStringify,
304
+ specIssueHash,
305
+ subIssueHash,
306
+ }
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The tracker's markdown DIALECT — how a provider shapes outbound text and
5
+ * reduces read-backs for comparison. The engine is provider-neutral, but every
6
+ * tracker reserialises text its own way (one rewrites bullets and mangles
7
+ * emphasis, another stores a different format entirely), so the transforms live
8
+ * with the provider and arrive here on `config.dialect`.
9
+ *
10
+ * Three hooks, each `string → string` and pure:
11
+ *
12
+ * - `canonicalize(text)` — canonicalise a projected description so
13
+ * semantically-equal content hashes equal
14
+ * across the boundary.
15
+ * - `flattenTables(text)` — rewrite constructs the tracker's renderer is
16
+ * known to corrupt (applied to every projected
17
+ * body).
18
+ * - `canonicalForCompare(text)`— reduce a read-back so the tracker's own
19
+ * benign reserialisation does not read as data
20
+ * loss (see `verify.js`).
21
+ *
22
+ * The identity dialect is the default: no provider transforms, plus the one
23
+ * reduction that is universal (line endings, trailing whitespace, blank-run
24
+ * collapse) so `canonicalForCompare` is never a bare string equality.
25
+ */
26
+
27
+ function baseCanonicalForCompare(text) {
28
+ return String(text == null ? '' : text)
29
+ .replace(/\r\n/g, '\n')
30
+ .split('\n')
31
+ .map((line) => line.replace(/[ \t]+$/, ''))
32
+ .join('\n')
33
+ .replace(/\n{3,}/g, '\n\n')
34
+ .trim()
35
+ }
36
+
37
+ const identityDialect = Object.freeze({
38
+ canonicalize: (text) => text,
39
+ flattenTables: (text) => text,
40
+ canonicalForCompare: baseCanonicalForCompare,
41
+ })
42
+
43
+ // The dialect in play for a config: the provider's hooks over the identity
44
+ // defaults, so a provider may supply any subset.
45
+ function dialectOf(config) {
46
+ const d = config && config.dialect
47
+ if (!d || typeof d !== 'object') return identityDialect
48
+ return { ...identityDialect, ...d }
49
+ }
50
+
51
+ module.exports = { identityDialect, dialectOf, baseCanonicalForCompare }
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The frontmatter FIELD VOCABULARY — which keys a provider stamps into spec
5
+ * files. The engine reads and writes stamps by these names and hardcodes none
6
+ * of them: each provider's config loader supplies its own set (the Linear
7
+ * provider defaults to `linear_identifier` / `linear_issue_id` / …), so a
8
+ * second provider stamps its own keys without touching the engine.
9
+ *
10
+ * Required keys: `identifier`, `url`, `issueId`, `assigneeId`.
11
+ * Optional: `assigneeName`, and `legacyIdentifier` / `legacyIssueId` — the keys
12
+ * a pre-9.0 mirror stamped. A provider with no legacy era simply omits them,
13
+ * and legacy detection then reports nothing (`legacy.js`).
14
+ *
15
+ * A missing `config.fields` THROWS rather than defaulting: a silent default
16
+ * would let the engine read stamps under names the provider never wrote, and
17
+ * the failure that produces is an all-creates plan that duplicates a live
18
+ * mirror — far worse than a loud error at the first call.
19
+ */
20
+
21
+ function fieldsOf(config) {
22
+ const f = config && config.fields
23
+ if (!f || typeof f !== 'object' || Array.isArray(f)) {
24
+ throw new Error(
25
+ 'config.fields missing — the provider config must name its frontmatter fields (identifier, url, issueId, assigneeId)',
26
+ )
27
+ }
28
+ for (const key of ['identifier', 'url', 'issueId', 'assigneeId']) {
29
+ if (typeof f[key] !== 'string' || !f[key]) {
30
+ throw new Error(`config.fields.${key} missing — the provider config must name this frontmatter field`)
31
+ }
32
+ }
33
+ return f
34
+ }
35
+
36
+ module.exports = { fieldsOf }
@@ -0,0 +1,100 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Detect a spec whose mirror was created under the **pre-9.0 model**.
5
+ *
6
+ * v9 remapped the mirror: a spec is an issue (was a Project), a phase is a
7
+ * sub-issue (was a Milestone), and tasks are no longer objects at all. The
8
+ * frontmatter keys moved with it (in the Linear provider's vocabulary,
9
+ * `linear_project_id` -> `linear_identifier`, `linear_milestone_id` ->
10
+ * `linear_issue_id` — see `config.fields`).
11
+ *
12
+ * The failure this guards is silent and destructive: v9 looks for the new keys,
13
+ * finds nothing, and produces a perfectly ordinary **all-creates** plan. Applying
14
+ * it mints a fresh mirror and abandons the old one — in the field that would have
15
+ * been 17 new objects against 2 projects, 15 milestones and 145 task issues left
16
+ * orphaned, with nothing on screen suggesting a prior mirror existed. It was
17
+ * caught only because an all-creates plan looked wrong for specs synced an hour
18
+ * earlier.
19
+ *
20
+ * Pure reads; returns `null` for anything that is not demonstrably pre-9.0, so a
21
+ * never-pushed spec is never mistaken for a stranded one.
22
+ */
23
+
24
+ const fs = require('node:fs')
25
+ const path = require('node:path')
26
+
27
+ const { parseFrontmatter } = require('./normalize.js')
28
+ const { readBase } = require('./base.js')
29
+ const { listPhaseFiles } = require('./write.js')
30
+
31
+ // The pre-9.0 keys come from the provider's field vocabulary
32
+ // (`config.fields.legacyIdentifier` / `.legacyIssueId`). A provider with no
33
+ // pre-9.0 era omits them, and detection then reports nothing: there is no old
34
+ // model its repos could be stranded on, so an absence is not evidence.
35
+
36
+ function frontmatterOf(file) {
37
+ try {
38
+ return parseFrontmatter(fs.readFileSync(file, 'utf-8')).data || {}
39
+ } catch {
40
+ return {}
41
+ }
42
+ }
43
+
44
+ // A pre-9.0 snapshot recorded `{project, milestones, issues}`; v9 records
45
+ // `{issue, subIssues}`. Counting what it holds turns the warning from "this
46
+ // looks old" into "this many live objects would be abandoned".
47
+ function countOrphans(snapshot) {
48
+ if (!snapshot || typeof snapshot !== 'object') return null
49
+ const size = (v) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0)
50
+ const projects = snapshot.project ? 1 : 0
51
+ const milestones = size(snapshot.milestones)
52
+ const issues = size(snapshot.issues)
53
+ if (!projects && !milestones && !issues) return null
54
+ return { projects, milestones, issues, total: projects + milestones + issues }
55
+ }
56
+
57
+ /**
58
+ * @returns {null|{keys:string[], files:string[], orphans:object|null, orphanCount:number}}
59
+ */
60
+ function detectLegacyMirror({ dir, snapshotDir, identifier, config }) {
61
+ const fields = (config && config.fields) || {}
62
+ const legacyOverviewKey = fields.legacyIdentifier
63
+ const legacyPhaseKey = fields.legacyIssueId
64
+ // No legacy vocabulary declared -> this provider has no pre-9.0 era, so there
65
+ // is no old model its repos could be stranded on, and an absence of the keys
66
+ // proves nothing (`.claude/rules/negative-checks.md`).
67
+ if (!legacyOverviewKey && !legacyPhaseKey) return null
68
+
69
+ const keys = []
70
+ const files = []
71
+
72
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
73
+ const overview = frontmatterOf(path.join(snapshotDir, overviewFile))
74
+ if (legacyOverviewKey && overview[legacyOverviewKey] != null) {
75
+ keys.push(legacyOverviewKey)
76
+ files.push(overviewFile)
77
+ }
78
+
79
+ for (const file of listPhaseFiles(snapshotDir)) {
80
+ if (legacyPhaseKey && frontmatterOf(path.join(snapshotDir, file))[legacyPhaseKey] != null) {
81
+ if (!keys.includes(legacyPhaseKey)) keys.push(legacyPhaseKey)
82
+ files.push(file)
83
+ }
84
+ }
85
+
86
+ let orphans = null
87
+ try {
88
+ orphans = countOrphans(readBase(dir, identifier, config))
89
+ } catch {
90
+ /* an unreadable snapshot is not evidence either way */
91
+ }
92
+
93
+ // A legacy snapshot alone is enough: the spec may have had its frontmatter
94
+ // hand-cleaned while the mirror it names is still live.
95
+ if (!keys.length && !orphans) return null
96
+
97
+ return { keys, files, orphans, orphanCount: orphans ? orphans.total : 0 }
98
+ }
99
+
100
+ module.exports = { detectLegacyMirror }