@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.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- 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 }
|