@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -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 +606 -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 +88 -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 +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -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 +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,338 @@
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 skitterspec tells the tracker about its
62
+ // own transitions and nothing else. The description is resent whenever it
63
+ // changes; the state only when the spec itself moves. Welding the two meant any
64
+ // prose edit re-emitted the state and dragged back an issue someone had moved
65
+ // on the tracker — which is theirs to do. Diffing them apart is what lets a
66
+ // push touch the description without re-asserting a state.
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
+
141
+ /**
142
+ * The same question for one phase SUB-ISSUE: has its body been edited on the
143
+ * tracker since the last push? It matters most where the tracker renders a
144
+ * phase's task list as clickable boxes — GitHub does — because ticking one
145
+ * there is an edit the next push replaces.
146
+ *
147
+ * The same three answers and the same cannot-tells as above, plus one of its
148
+ * own: no `subIssueStreams` in the snapshot, or none for this id. Every
149
+ * snapshot written before this existed lacks it, and a phase whose body was
150
+ * never sent (no goal) has nothing recorded.
151
+ *
152
+ * @param {object|null} snapshot the committed last-pushed snapshot
153
+ * @param {string} id the sub-issue's id as the snapshot keys it
154
+ * @param {object|null} remote what the tracker currently holds for it
155
+ * @returns {boolean|null}
156
+ */
157
+ function remoteSubIssueEdited(snapshot, id, remote, config) {
158
+ const streams = snapshot && snapshot.subIssueStreams
159
+ if (!streams || typeof streams !== 'object' || Array.isArray(streams)) return null
160
+ const recorded = streams[String(id)]
161
+ if (typeof recorded !== 'string' || !recorded) return null
162
+ return remoteDescriptionEdited({ issueFields: { descriptionStream: recorded } }, remote, config)
163
+ }
164
+ // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
165
+ const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
166
+
167
+ /**
168
+ * The snapshot to commit after a successful push: the spec-issue hash plus a
169
+ * content hash per sub-issue that currently has an id. Create items (id == null)
170
+ * aren't recorded until the skill stamps their returned id and the next
171
+ * projection includes it.
172
+ */
173
+ function snapshotOf(projection, config) {
174
+ const p = projection || {}
175
+ const byId = (arr, hash) => {
176
+ const out = {}
177
+ for (const item of arr || []) if (item && item.id != null) out[String(item.id)] = hash(item)
178
+ return out
179
+ }
180
+ return {
181
+ // Both shapes are written: `issueFields` is what a current push diffs, and
182
+ // `issue` keeps a snapshot readable by anything still expecting the combined
183
+ // hash. Cheap insurance — two SHA-1s of text already in hand.
184
+ issue: specIssueHash(p),
185
+ issueFields: specIssueFieldHashes(p, config),
186
+ subIssues: byId(p.subIssues, subIssueHash),
187
+ // Recorded and never planned on, like `descriptionStream`: `planChanges`
188
+ // reads `subIssues` by name, so this key cannot make a push pending. Only a
189
+ // phase whose body is actually sent (a string goal) gets one.
190
+ subIssueStreams: byId(
191
+ (p.subIssues || []).filter((s) => s && typeof s.goal === 'string'),
192
+ (s) => hashField(stream(s.goal, config)),
193
+ ),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Diff the local projection against the last-pushed snapshot.
199
+ * @returns {{ issue?: object, subIssues: {create,update} }}
200
+ * `unstamped` (when present) lists phases whose stamp is missing while the
201
+ * snapshot still remembers an unminted-for id — ambiguous, so neither created
202
+ * nor updated. create items carry a `ref` (local handle) and no id; update items carry both
203
+ * — the `ref` because the read-back check matches sub-issues to phases BY ref,
204
+ * and an update with only an id makes every one of them look unmatched.
205
+ * `plan.issue` (when present) is the spec issue's description + state; the push
206
+ * skill applies its provider's project/grouping choice on top of it.
207
+ */
208
+ function planChanges(projection, snapshot) {
209
+ const p = projection || {}
210
+ const snap = snapshot || {}
211
+ const snapS = snap.subIssues || {}
212
+
213
+ // Ids the last push minted that no phase claims any more. A phase file's stamp
214
+ // is the only link back to its sub-issue, and it lives in frontmatter — so a
215
+ // whole-file rewrite, a hand edit or a bad merge drops it while the sub-issue
216
+ // carries on existing. This snapshot is the only memory that it was ever
217
+ // minted, and nothing else in the push path re-reads the tracker.
218
+ const claimed = new Set()
219
+ for (const s of p.subIssues || []) if (s.id != null) claimed.add(String(s.id))
220
+ const unclaimed = Object.keys(snapS).filter((id) => !claimed.has(id))
221
+
222
+ const subIssues = { create: [], update: [] }
223
+ const unstamped = []
224
+ for (const s of p.subIssues || []) {
225
+ if (s.id == null) {
226
+ // THREE STATES, NOT TWO. "No stamp" means "new phase" only when every id
227
+ // the snapshot remembers is still claimed. With an unclaimed id sitting
228
+ // beside an unstamped phase the two readings — a new phase, and a phase
229
+ // whose stamp was lost — are indistinguishable from here, so this routes to
230
+ // the harmless branch and mints nothing. Being wrong this way costs a
231
+ // re-stamp; the other way cost a duplicate sub-issue and a manual cancel.
232
+ if (unclaimed.length) {
233
+ unstamped.push({ ref: s.ref, name: s.name, candidates: unclaimed.slice() })
234
+ } else {
235
+ subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
236
+ }
237
+ } else if (snapS[String(s.id)] !== subIssueHash(s)) {
238
+ subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
239
+ }
240
+ }
241
+
242
+ const plan = { subIssues }
243
+ if (unstamped.length) plan.unstamped = unstamped
244
+ const issue = issueChanges(p, snap)
245
+ if (issue) plan.issue = issue
246
+ return plan
247
+ }
248
+
249
+ /**
250
+ * What changed about the spec issue itself — `{description}`, `{state}`, or
251
+ * both, or null when neither did.
252
+ *
253
+ * Three states, not two: a snapshot may carry the split hashes, only the old
254
+ * combined one, or nothing at all. Only the first can say which field moved.
255
+ *
256
+ * The other two are UNKNOWN, and route to the harmless branch — today's welded
257
+ * behaviour, sending both fields. Sending a state that did not change is
258
+ * redundant; withholding one that did would leave the mirror silently stale, and
259
+ * that is the failure worth avoiding. The push rewrites the snapshot in the new
260
+ * shape, so a spec passes through `unknown` exactly once.
261
+ */
262
+ function issueChanges(projection, snapshot) {
263
+ const p = projection || {}
264
+ const snap = snapshot || {}
265
+ const both = () => {
266
+ const out = { description: p.description ?? null, state: p.status ?? null }
267
+ // ASSERT, NEVER CLEAR, on this path. There are no split hashes here, so
268
+ // whether an assignee was ever pushed is unknown — and the harmless reading
269
+ // of "unknown" is to send one we have and stay silent about one we don't.
270
+ if (p.assignee != null) out.assignee = p.assignee
271
+ return out
272
+ }
273
+
274
+ const fields = snap.issueFields
275
+ if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
276
+ // No split hashes recorded: an old snapshot, or no snapshot at all (a
277
+ // create, which needs both fields anyway).
278
+ if (snap.issue !== specIssueHash(p)) return both()
279
+ // Description and state are unchanged — but the COMBINED HASH SAYS NOTHING
280
+ // ABOUT THE ASSIGNEE, which was never one of its inputs. Absence of evidence
281
+ // again, and the two directions are not equally safe: asserting an assignee
282
+ // the repo actually recorded costs one redundant write, while staying quiet
283
+ // would strand it until some unrelated prose edit happened to push. Clearing
284
+ // is still never guessed at. The push rewrites the snapshot in the split
285
+ // shape, so a spec passes through here exactly once.
286
+ return p.assignee != null ? { assignee: p.assignee } : null
287
+ }
288
+
289
+ const want = specIssueFieldHashes(p)
290
+ const changed = {}
291
+ if (fields.description !== want.description) changed.description = p.description ?? null
292
+ if (fields.state !== want.state) changed.state = p.status ?? null
293
+
294
+ // ASSIGNEE — AN ABSENT SNAPSHOT KEY MEANS "NEVER PUSHED", NOT "WAS NULL".
295
+ //
296
+ // What would fool this check: every spec linked before assignee existed has a
297
+ // snapshot with no `assignee` key, and so does every spec in a repo that never
298
+ // opted the field in. Read that absence as `null` and the first push after
299
+ // upgrade computes null → null → "unchanged"… except where a PM had assigned
300
+ // the issue in Linear, which the repo would then silently clear. The bill for
301
+ // getting this wrong is somebody else's triage, workspace-wide, in one command.
302
+ //
303
+ // So absence is routed to the harmless branch: assert an assignee we actually
304
+ // have, and never send a clear at something we never set.
305
+ // (`.claude/rules/negative-checks.md` — prefer a positive signal to an absence,
306
+ // and bias the unknown case toward inaction.)
307
+ if (want.assignee !== undefined) {
308
+ if (fields.assignee === undefined) {
309
+ if (p.assignee != null) changed.assignee = p.assignee
310
+ } else if (fields.assignee !== want.assignee) {
311
+ // A recorded hash IS a positive signal that the repo pushed this field
312
+ // before, so a change to null here is a retraction of our own assignment
313
+ // rather than a guess at someone else's.
314
+ changed.assignee = p.assignee ?? null
315
+ }
316
+ }
317
+
318
+ return Object.keys(changed).length ? changed : null
319
+ }
320
+
321
+ // True when a plan would push nothing.
322
+ function isEmptyPlan(plan) {
323
+ return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
324
+ }
325
+
326
+ module.exports = {
327
+ planChanges,
328
+ remoteDescriptionEdited,
329
+ remoteSubIssueEdited,
330
+ issueChanges,
331
+ specIssueFieldHashes,
332
+ snapshotOf,
333
+ isEmptyPlan,
334
+ hashField,
335
+ stableStringify,
336
+ specIssueHash,
337
+ subIssueHash,
338
+ }
@@ -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 }