@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,85 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
5
+ * generated mirror. We never read remote content: `push` builds the local
6
+ * projection, diffs it against the committed **last-pushed snapshot**, and
7
+ * returns a **create/update plan** the provider skill applies over MCP.
8
+ *
9
+ * The skill: applies the plan → stamps each newly-created id back into the phase
10
+ * frontmatter / task line → calls `recordPush`, which re-reads the (now
11
+ * id-stamped) projection and writes the snapshot. That closes the loop: the next
12
+ * `push` sees an unchanged snapshot and produces an empty plan.
13
+ *
14
+ * Pure aside from reading the snapshot; no adapter, no remote read, no
15
+ * Date.now(). `recordPush` writes the snapshot sidecar.
16
+ */
17
+
18
+ const { normalizeLocal, phasesWithheld, phaseModeFor } = require('./normalize.js')
19
+ const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
+ const { readBase, writeBase } = require('./base.js')
21
+ const { detectLegacyMirror } = require('./legacy.js')
22
+
23
+ // Build the one-way projection from a local snapshot: the spec issue's prose +
24
+ // status, and its phase sub-issues. `status` is the local lifecycle bucket; the
25
+ // skill maps it (and each sub-issue's `state`) to the Linear issue-state NAME via
26
+ // config.states at apply time.
27
+ function projectionOf(snapshotDir, config) {
28
+ const local = normalizeLocal(snapshotDir, config)
29
+ const projection = {
30
+ description: local.description ?? null,
31
+ status: local.workflowState ?? null,
32
+ subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
33
+ // How many phases `mapping.phases: 'deferred'` is holding back. Reporting
34
+ // only — `snapshotOf`/`specIssueHash` read named fields, so this never
35
+ // reaches a hash and cannot make an unchanged spec look edited.
36
+ phasesWithheld: phasesWithheld(snapshotDir, config),
37
+ // The phase mode that resolved for THIS spec's bucket. Reporting only, on
38
+ // the same terms: a spec with no sub-issues has to read as deliberate rather
39
+ // than as phase files that failed to parse, and with `mapping.phases` now a
40
+ // per-bucket map, which mode applied is no longer readable off the config.
41
+ phaseMode: phaseModeFor(local.workflowState, config),
42
+ }
43
+ // ASSIGNEE IS SET ONLY WHEN THE FIELD IS IN PLAY, and the distinction between
44
+ // `undefined` and `null` is load-bearing all the way down: `undefined` means
45
+ // the repo never opted in, so no hash is recorded and no op is ever emitted;
46
+ // `null` means opted in with nobody assigned, which CAN legitimately clear a
47
+ // previously-pushed assignee. Defaulting the key to null here would collapse
48
+ // the two and make an un-opted-in repo start clearing assignees on its next
49
+ // push. `normalizeLocal` omits the key entirely when `sync.fieldOwnership`
50
+ // does not list it.
51
+ if ('assignee' in local) projection.assignee = local.assignee ?? null
52
+ return projection
53
+ }
54
+
55
+ function push({ dir, snapshotDir, identifier, config }) {
56
+ const projection = projectionOf(snapshotDir, config)
57
+ const snapshot = readBase(dir, identifier, config)
58
+ const plan = planChanges(projection, snapshot)
59
+ // A spec still linked under the pre-9.0 model reads as unlinked here, so the
60
+ // plan above is all-creates and would abandon a live mirror. Carry the finding
61
+ // ON THE PLAN, not as a warning: `--json` routes warnings to stderr, and the
62
+ // skill that applies this plan is exactly the consumer that would miss them.
63
+ const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
64
+ if (legacy) plan.legacy = legacy
65
+ // Same reasoning as `legacy`: carried ON THE PLAN, not as a stderr warning,
66
+ // because `--json` routes warnings to stderr and the skill applying the plan
67
+ // is the consumer that most needs to know the missing sub-issues are deliberate.
68
+ if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
69
+ // Always set, unlike the two above: the skill relaying this should not have to
70
+ // know that an absent field means `subissue`. `isEmptyPlan` and `snapshotOf`
71
+ // both read named fields, so an extra key cannot make a spec look edited.
72
+ plan.phaseMode = projection.phaseMode
73
+ return { ok: true, empty: isEmptyPlan(plan), plan, projection }
74
+ }
75
+
76
+ /**
77
+ * Record the last-pushed snapshot from the CURRENT files — call after the skill
78
+ * has applied the plan and stamped new ids. Returns the snapshot path.
79
+ */
80
+ function recordPush({ dir, snapshotDir, identifier, config }) {
81
+ const projection = projectionOf(snapshotDir, config)
82
+ return writeBase(dir, identifier, config, snapshotOf(projection, config))
83
+ }
84
+
85
+ module.exports = { push, recordPush, projectionOf }
@@ -0,0 +1,278 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Retarget a mirror after the tracker's team key is renamed.
5
+ *
6
+ * Renaming a Linear team rewrites the key in every issue identifier
7
+ * (`SKI-7` → `SKS-7`). The repo stamps those identifiers in three places — spec
8
+ * frontmatter, the sync-base snapshot filenames, and the `subIssues` keys
9
+ * inside those snapshots — and nothing moves them, so afterwards every stamped
10
+ * spec is stale and `/spec-push` fails with `no issue found for SKI-7`.
11
+ *
12
+ * This module is the PURE half: it plans and applies a prefix move over the
13
+ * repo's machine-read fields. It is provider-neutral by design (decision 8) —
14
+ * "old prefix → new prefix over stamps and snapshots" involves no tracker at
15
+ * all, so it belongs here beside `legacy.js`. Detecting the rename and
16
+ * spot-checking an identifier are the only steps that touch a tracker, and they
17
+ * live in the provider's CLI.
18
+ *
19
+ * **Frontmatter only — never prose.** A naive repo-wide `SKI-` → `SKS-`
20
+ * substitution passes a casual eyeball and quietly rewrites the historical
21
+ * record ("Probe SKI-28 falsified the reported hypothesis").
22
+ * `.claude/rules/spec-planning.md` says never delete historical notes, so prose
23
+ * mentions, doc placeholders and test fixtures are out of scope entirely.
24
+ */
25
+
26
+ const fs = require('node:fs')
27
+ const path = require('node:path')
28
+ const { execFileSync } = require('node:child_process')
29
+
30
+ const { parseFrontmatter, LIFECYCLE_BUCKETS: BUCKETS } = require('./normalize.js')
31
+ const { fieldsOf } = require('./fields.js')
32
+
33
+ // An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
34
+ // because the stamped url field carries the identifier lowercased in its path
35
+ // (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
36
+ // in a live repo pointing at the old key — the same ones the hand-repair
37
+ // missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
38
+ // token like `utf-8` is never in scope.
39
+ const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
40
+
41
+ // The stamped identifier fields, from the provider's vocabulary.
42
+ const stampFields = (config) => {
43
+ const f = fieldsOf(config)
44
+ return [f.identifier, f.issueId]
45
+ }
46
+ const SNAPSHOT_SUFFIX = '.base.json'
47
+
48
+ // Every `.md` under each spec folder — overview and phase files alike, plus the
49
+ // legacy bare `<name>.md` shape.
50
+ function specMarkdownFiles(dir) {
51
+ const files = []
52
+ for (const bucket of BUCKETS) {
53
+ const root = path.join(dir, 'specs', bucket)
54
+ let entries
55
+ try {
56
+ entries = fs.readdirSync(root, { withFileTypes: true })
57
+ } catch {
58
+ continue
59
+ }
60
+ for (const entry of entries) {
61
+ const p = path.join(root, entry.name)
62
+ if (entry.isDirectory()) {
63
+ for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
64
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
65
+ files.push(p)
66
+ }
67
+ }
68
+ }
69
+ return files.sort()
70
+ }
71
+
72
+ const snapshotDirOf = (dir, config) => path.resolve(dir, config.sync.baseDir)
73
+
74
+ function snapshotFiles(dir, config) {
75
+ try {
76
+ return fs.readdirSync(snapshotDirOf(dir, config)).filter((f) => f.endsWith(SNAPSHOT_SUFFIX)).sort()
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ // Rewrite `<oldKey>-<n>` → `<newKey>-<n>` in `text`, leaving every other
83
+ // identifier alone. The number is preserved: a team rename does not renumber.
84
+ function movePrefix(text, oldKey, newKey) {
85
+ const want = String(oldKey).toLowerCase()
86
+ return String(text).replace(IDENTIFIER_RE, (whole, key, n) => {
87
+ if (key.toLowerCase() !== want) return whole
88
+ // Preserve the case it was written in: a URL path segment stays lowercase,
89
+ // a frontmatter stamp stays uppercase.
90
+ const next = key === key.toLowerCase() ? newKey.toLowerCase() : newKey
91
+ return `${next}-${n}`
92
+ })
93
+ }
94
+
95
+ /**
96
+ * The recorded key this repo believes it is stamped with.
97
+ *
98
+ * `config.provider.teamKey` is authoritative when set — but it defaults to `""`
99
+ * and is written by `init-config` and never read, which is exactly why a stale
100
+ * key never failed loudly. So fall back to the prefix actually observed in the
101
+ * stamps. Disagreeing stamps are reported, never guessed at and never thrown:
102
+ * the caller phrases its own refusal.
103
+ *
104
+ * @returns {{key: string|null, source: 'config'|'stamps'|null, keys?: string[], reason?: string}}
105
+ */
106
+ function deriveRecordedKey(dir, config) {
107
+ const configured = (config.provider && config.provider.teamKey) || ''
108
+ if (configured) return { key: configured, source: 'config' }
109
+
110
+ const keys = new Set()
111
+ for (const file of specMarkdownFiles(dir)) {
112
+ let raw
113
+ try {
114
+ raw = fs.readFileSync(file, 'utf-8')
115
+ } catch {
116
+ continue
117
+ }
118
+ const { data } = parseFrontmatter(raw)
119
+ for (const field of stampFields(config)) {
120
+ if (!data[field]) continue
121
+ const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(String(data[field]).trim())
122
+ if (m) keys.add(m[1])
123
+ }
124
+ }
125
+ for (const name of snapshotFiles(dir, config)) {
126
+ const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(name.slice(0, -SNAPSHOT_SUFFIX.length))
127
+ if (m) keys.add(m[1])
128
+ }
129
+
130
+ // BLIND SPOT: the scan sees `.md` frontmatter under the four lifecycle buckets
131
+ // and the snapshot filenames — nothing else. A stamp anywhere else (a spec
132
+ // parked outside a bucket, a non-markdown file) is invisible, so `no stamped
133
+ // identifiers` means "none where we looked". That is why it refuses instead of
134
+ // concluding the repo is unstamped: the caller stops, and a human looks.
135
+ const found = [...keys].sort()
136
+ if (found.length === 1) return { key: found[0], source: 'stamps' }
137
+ if (!found.length) return { key: null, source: null, keys: [], reason: 'no stamped identifiers found under specs/' }
138
+ return {
139
+ key: null,
140
+ source: null,
141
+ keys: found,
142
+ reason: `stamps disagree — found ${found.join(', ')}; set the provider config's team key to the recorded one`,
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Plan the prefix move. Pure: reads the repo, writes nothing.
148
+ *
149
+ * @returns {{stamps: Array<{file, from, to}>, snapshots: Array<{file, from, to, keys}>, configKey: {from,to}|null}}
150
+ * `stamps[].from`/`to` are whole file contents; the caller writes them as-is.
151
+ */
152
+ function planRetarget({ dir, oldKey, newKey, config }) {
153
+ const stamps = []
154
+ for (const file of specMarkdownFiles(dir)) {
155
+ let raw
156
+ try {
157
+ raw = fs.readFileSync(file, 'utf-8')
158
+ } catch {
159
+ continue
160
+ }
161
+ // Scoped to the leading `---` block. Everything after it is prose and is
162
+ // returned byte-identical.
163
+ const m = /^(---\n[\s\S]*?\n---)([\s\S]*)$/.exec(raw)
164
+ if (!m) continue
165
+ const head = movePrefix(m[1], oldKey, newKey)
166
+ if (head === m[1]) continue
167
+ stamps.push({ file: path.relative(dir, file), from: raw, to: head + m[2] })
168
+ }
169
+
170
+ const snapshots = []
171
+ const prefix = `${oldKey}-`
172
+ for (const name of snapshotFiles(dir, config)) {
173
+ const ident = name.slice(0, -SNAPSHOT_SUFFIX.length)
174
+ let body
175
+ try {
176
+ body = JSON.parse(fs.readFileSync(path.join(snapshotDirOf(dir, config), name), 'utf-8'))
177
+ } catch {
178
+ continue
179
+ }
180
+ // The `subIssues` map is keyed BY IDENTIFIER, so a rename strands every key
181
+ // inside the file as well as the filename. The hashes are content-derived
182
+ // and stay valid — only the keys move.
183
+ const keys = {}
184
+ let rekeyed = false
185
+ for (const [k, hash] of Object.entries(body.subIssues || {})) {
186
+ const next = k.startsWith(prefix) ? `${newKey}-${k.slice(prefix.length)}` : k
187
+ if (next !== k) rekeyed = true
188
+ keys[next] = hash
189
+ }
190
+ const renamed = ident.startsWith(prefix) ? `${newKey}-${ident.slice(prefix.length)}${SNAPSHOT_SUFFIX}` : name
191
+ if (renamed === name && !rekeyed) continue
192
+ snapshots.push({ file: name, from: name, to: renamed, keys, body })
193
+ }
194
+
195
+ const recorded = (config.provider && config.provider.teamKey) || ''
196
+ const configKey = recorded && recorded !== newKey ? { from: recorded, to: newKey } : null
197
+
198
+ return { stamps, snapshots, configKey }
199
+ }
200
+
201
+ // True when a plan would change nothing.
202
+ const isEmptyRetarget = (plan) => !plan.stamps.length && !plan.snapshots.length && !plan.configKey
203
+
204
+ // `git status --porcelain` over `dir`: [] clean, the lines when dirty, null when
205
+ // this is not a git repo.
206
+ function dirtyPaths(dir) {
207
+ let out
208
+ try {
209
+ out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], { stdio: ['ignore', 'pipe', 'ignore'] })
210
+ .toString()
211
+ .trim()
212
+ } catch {
213
+ return null
214
+ }
215
+ return out ? out.split('\n') : []
216
+ }
217
+
218
+ // Move a file, preferring `git mv` so history follows it. Falls back to a plain
219
+ // rename when the file is untracked (git mv refuses those) or git is absent.
220
+ function moveFile(dir, from, to) {
221
+ try {
222
+ execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
223
+ return 'git mv'
224
+ } catch {
225
+ fs.renameSync(path.join(dir, from), path.join(dir, to))
226
+ return 'rename'
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Apply a plan. Everything moves together — stamps, snapshot names, the keys
232
+ * inside them, and the config key — because a half-retargeted repo is harder to
233
+ * reason about than an un-retargeted one.
234
+ */
235
+ function applyRetarget(plan, { dir, config }) {
236
+ const changed = { files: [], snapshots: [], configKey: false }
237
+
238
+ for (const s of plan.stamps) {
239
+ fs.writeFileSync(path.join(dir, s.file), s.to, 'utf-8')
240
+ changed.files.push(s.file)
241
+ }
242
+
243
+ const baseRel = config.sync.baseDir
244
+ for (const snap of plan.snapshots) {
245
+ // Re-key BEFORE the rename, so the path being written is the one the plan
246
+ // recorded.
247
+ const src = path.join(dir, baseRel, snap.from)
248
+ fs.writeFileSync(src, JSON.stringify({ ...snap.body, subIssues: snap.keys }, null, 2) + '\n', 'utf-8')
249
+ if (snap.to !== snap.from) {
250
+ const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
251
+ changed.snapshots.push({ ...snap, how })
252
+ }
253
+ }
254
+
255
+ if (plan.configKey && config.provider && config.provider.configFile) {
256
+ // Textual, not parse-and-restringify: the config is hand-edited and carries
257
+ // ordering (and possibly comments) a JSON round-trip would discard. The
258
+ // file and the JSON key both come from the provider (`config.provider`).
259
+ const file = path.join(dir, config.provider.configFile)
260
+ const keyField = config.provider.teamKeyJsonField || 'teamKey'
261
+ const raw = fs.readFileSync(file, 'utf-8')
262
+ const re = new RegExp('("' + keyField + '"\\s*:\\s*")([^"]*)(")')
263
+ fs.writeFileSync(file, raw.replace(re, `$1${plan.configKey.to}$3`), 'utf-8')
264
+ changed.configKey = true
265
+ }
266
+
267
+ return changed
268
+ }
269
+
270
+ module.exports = {
271
+ planRetarget,
272
+ applyRetarget,
273
+ deriveRecordedKey,
274
+ isEmptyRetarget,
275
+ dirtyPaths,
276
+ movePrefix,
277
+ specMarkdownFiles,
278
+ }
@@ -0,0 +1,308 @@
1
+ 'use strict'
2
+
3
+ // Task bullets in a phase file are hand-wrapped prose, not single lines:
4
+ //
5
+ // - [x] Add the retry column to the outbox table, modelled on the existing
6
+ // queue: status, attempts, `nextAttemptAt`, …
7
+ //
8
+ // A Linear issue title is single-line, so the two representations differ by
9
+ // wrapping alone. This module is the one place that converts between them:
10
+ // `findTaskBlocks` reads wrapped bullets into logical tasks, `renderTaskBlock`
11
+ // writes a logical task back out re-wrapped in the file's own style.
12
+ //
13
+ // Everything here is line-index based so callers can splice whole blocks.
14
+
15
+ const DEFAULT_WIDTH = 80
16
+
17
+ // Start of a task bullet. The continuation lines that follow are any indented,
18
+ // non-empty lines that are not themselves a bullet or heading.
19
+ //
20
+ // The mark is ANY single character, not just ` `/`x`. Projects use `[~]` for
21
+ // in-progress, `[>]` for deferred, `[-]` for dropped, and a parser that only
22
+ // knew ` xX` matched none of them — so the whole bullet was claimed by no block
23
+ // and vanished from the mirror (bug-phase-content-dropped). What the mark MEANS
24
+ // is nobody's business here; it is carried through verbatim and re-emitted as
25
+ // written. Only `x`/`X` counts as done (see `parseTaskLine`).
26
+ const TASK_START_RE = /^([ \t]*)-\s*\[([^\]])\]\s*(.*)$/
27
+ const CONTINUATION_RE = /^[ \t]+\S/
28
+ const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
29
+ // A list-marker line (unordered or ordered). Distinguished from other block
30
+ // breaks because a wrapped continuation can legitimately begin with one.
31
+ const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
32
+ // A checkbox bullet — unambiguously a task, so it always starts its own block,
33
+ // at any indent. (A bare list marker is ambiguous; a checkbox never is.) Any
34
+ // mark, matching TASK_START_RE.
35
+ const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[^\]]\]/
36
+ // A bare list bullet — no checkbox — captured with its marker so a sub-bullet
37
+ // is re-rendered as the `-`/`*`/`1.` its author wrote.
38
+ const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
39
+
40
+ function indentWidth(line) {
41
+ return line.length - line.trimStart().length
42
+ }
43
+
44
+ // Collect the wrapped continuation lines following the bullet opening at
45
+ // `start`, seeded with the opener's own text. Shared by task bullets and plain
46
+ // sub-bullets — both wrap the same way, and both stop at the first line that
47
+ // belongs to something else.
48
+ function collectContinuation(lines, start, hang, inFence, parts) {
49
+ let j = start + 1
50
+ for (; j < lines.length; j++) {
51
+ const l = lines[j]
52
+ if (!l.trim()) break
53
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
54
+ if (!CONTINUATION_RE.test(l)) break
55
+ if (BLOCK_BREAK_RE.test(l)) {
56
+ // Indent alone can't tell a nested child from a wrapped continuation —
57
+ // both sit at the hanging indent. The *marker* is the reliable signal:
58
+ // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
59
+ // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
60
+ // sits exactly at the hang; shallower or deeper it's a real sub/
61
+ // sibling list → break, and the caller's scan claims it as a block of
62
+ // its own. (Keeping the at-hang continuation preserves the task's
63
+ // stamped id, so the next push updates instead of creating a
64
+ // duplicate issue.)
65
+ // - headings, quotes, tables and fences always break.
66
+ if (CHECKBOX_RE.test(l)) break
67
+ if (!LIST_MARKER_RE.test(l) || indentWidth(l) !== hang) break
68
+ }
69
+ parts.push(l.trim())
70
+ }
71
+ return { end: j, parts }
72
+ }
73
+
74
+ // Mark every line that lies inside a fenced code block — the opening fence, its
75
+ // content, and the closing fence all count as `true`. Line scanners that hunt
76
+ // for markdown structure (task bullets, section headings) use this to ignore an
77
+ // EXAMPLE of that structure shown inside a fence: a spec that documents a
78
+ // checklist format in a ``` block must not have those example bullets harvested
79
+ // as real tasks (and pushed as real tracker issues). Supports ``` and ~~~ fences
80
+ // of any length ≥ 3; a closing fence is a bare marker (no info string) of the
81
+ // same character and at least the opening length.
82
+ function fenceMask(lines) {
83
+ const mask = new Array(lines.length).fill(false)
84
+ let open = null // the opening marker string (``` or ~~~ …), or null
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const trimmed = lines[i].trim()
87
+ if (open) {
88
+ mask[i] = true
89
+ if (/^(`{3,}|~{3,})$/.test(trimmed) && trimmed[0] === open[0] && trimmed.length >= open.length) {
90
+ open = null
91
+ }
92
+ continue
93
+ }
94
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(lines[i])
95
+ if (m) {
96
+ open = m[1]
97
+ mask[i] = true
98
+ }
99
+ }
100
+ return mask
101
+ }
102
+
103
+ // Collapse a wrapped bullet's lines into the single logical line the rest of the
104
+ // sync engine (and Linear) works in.
105
+ function collapse(text) {
106
+ return String(text).replace(/\s+/g, ' ').trim()
107
+ }
108
+
109
+ // Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
110
+ // `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
111
+ // other break collapses to a single space. Use this wherever hand-wrapped prose
112
+ // is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
113
+ function collapseHyphenAware(text) {
114
+ return String(text)
115
+ .replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
116
+ .replace(/\s+/g, ' ')
117
+ .trim()
118
+ }
119
+
120
+ // Mark every character of `body` that lies inside an inline emphasis or link
121
+ // span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
122
+ // one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
123
+ // spans are exempt (Linear only rejoins them, harmlessly) but their contents are
124
+ // neutralised first so a `*` inside code can't spoof an italic marker.
125
+ function spanMask(body) {
126
+ const mask = new Array(body.length).fill(false)
127
+ // Neutralise code-span contents to same-length filler (indices stay aligned).
128
+ const chars = body.split('')
129
+ let m
130
+ const codeRe = /`[^`]*`/g
131
+ while ((m = codeRe.exec(body)) !== null) {
132
+ for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
133
+ }
134
+ let masked = chars.join('')
135
+ const cover = (re) => {
136
+ re.lastIndex = 0
137
+ let mm
138
+ while ((mm = re.exec(masked)) !== null) {
139
+ for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
140
+ if (mm[0].length === 0) re.lastIndex++
141
+ }
142
+ }
143
+ cover(/\*\*.+?\*\*/g) // bold
144
+ cover(/\[[^\]]*\]\([^)]*\)/g) // link
145
+ // Neutralise the bold/link spans already found, so their asterisks aren't
146
+ // reused when scanning for single-`*` italics.
147
+ const m2 = masked.split('')
148
+ for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
149
+ masked = m2.join('')
150
+ cover(/\*[^*]+?\*/g) // italic
151
+ return mask
152
+ }
153
+
154
+ /**
155
+ * Find every task bullet in `lines` as a logical block, plus the non-checkbox
156
+ * bullets that live inside a task's list subtree.
157
+ * @returns {Array<{start:number, end:number, indent:string, marker:string,
158
+ * checkbox:boolean, mark:string|null, text:string}>}
159
+ * `end` is exclusive. `text` is the collapsed single-line form, id included.
160
+ * `checkbox` is false for a plain sub-bullet; its `mark` is then null and its
161
+ * `marker` is the bullet it was written with (`-`, `*`, `1.` …).
162
+ */
163
+ function findTaskBlocks(lines) {
164
+ const blocks = []
165
+ const inFence = fenceMask(lines)
166
+ // The marker indents of the task bullets whose list subtree is still open,
167
+ // outermost first. Without this the scan had no model of nesting at all: a
168
+ // bare bullet that dedented out of a nested checkbox belonged to nothing and
169
+ // was silently dropped, taking its wrapped continuations with it.
170
+ const open = []
171
+
172
+ for (let i = 0; i < lines.length; i++) {
173
+ const line = lines[i]
174
+ // A blank line alone doesn't close a subtree — a loose list is still a list.
175
+ // What closes it is a later line at or shallower than the task's own indent.
176
+ if (!line.trim()) continue
177
+ const indent = indentWidth(line)
178
+ while (open.length && indent <= open[open.length - 1]) open.pop()
179
+ if (inFence[i]) continue // an example bullet inside a ``` block is not a task
180
+
181
+ const t = TASK_START_RE.exec(line)
182
+ if (t) {
183
+ // The hanging indent: where the bullet's text starts (marker width). A
184
+ // wrapped continuation aligns exactly AT this column.
185
+ const { end, parts } = collectContinuation(lines, i, line.length - t[3].length, inFence, [t[3]])
186
+ blocks.push({
187
+ start: i,
188
+ end,
189
+ indent: t[1],
190
+ marker: '-',
191
+ checkbox: true,
192
+ // Case-folded for `x` (the one mark whose meaning we act on), otherwise
193
+ // verbatim — a project's `~`/`>`/`-` must round-trip as written.
194
+ mark: t[2].toLowerCase() === 'x' ? 'x' : t[2],
195
+ text: collapseHyphenAware(parts.join('\n')),
196
+ })
197
+ open.push(indent)
198
+ i = end - 1
199
+ continue
200
+ }
201
+
202
+ // A bare bullet is claimed ONLY inside an open task's subtree — there it is
203
+ // part of the task and must be re-rendered with it. Outside one it is
204
+ // ordinary prose, which the projection now passes through verbatim, so
205
+ // claiming it here would only re-wrap a list nobody asked us to touch.
206
+ if (!open.length) continue
207
+ const b = BULLET_RE.exec(line)
208
+ if (!b) continue
209
+ const { end, parts } = collectContinuation(lines, i, line.length - b[3].length, inFence, [b[3]])
210
+ blocks.push({
211
+ start: i,
212
+ end,
213
+ indent: b[1],
214
+ marker: b[2],
215
+ checkbox: false,
216
+ mark: null,
217
+ text: collapseHyphenAware(parts.join('\n')),
218
+ })
219
+ i = end - 1
220
+ }
221
+ return blocks
222
+ }
223
+
224
+ /**
225
+ * Render a logical task back into wrapped file lines, matching the surrounding
226
+ * style: `- [x] ` opener, continuations aligned under the text.
227
+ * @returns {string[]}
228
+ */
229
+ // Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
230
+ // only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
231
+ // emphasis run never straddles a line (Linear mangles one that does). The first
232
+ // line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
233
+ // single span overflows rather than being split.
234
+ function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
235
+ const mask = spanMask(body)
236
+ const words = body.split(' ')
237
+ // The index in `body` of the space that precedes each word.
238
+ const spaceBefore = []
239
+ let pos = 0
240
+ for (let k = 0; k < words.length; k++) {
241
+ if (k > 0) {
242
+ spaceBefore[k] = pos
243
+ pos += 1
244
+ }
245
+ pos += words[k].length
246
+ }
247
+
248
+ const out = []
249
+ let line = firstPrefix
250
+ let first = true
251
+ for (let k = 0; k < words.length; k++) {
252
+ const word = words[k]
253
+ // Break only at a safe gap; a gap inside a span overflows instead of
254
+ // splitting it (so an over-width single span stays whole on one line).
255
+ const canBreak = k > 0 && !mask[spaceBefore[k]]
256
+ if (!first && canBreak && line.length + 1 + word.length > width) {
257
+ out.push(line)
258
+ line = hang + word
259
+ } else {
260
+ line += (first ? '' : ' ') + word
261
+ first = false
262
+ }
263
+ }
264
+ out.push(line)
265
+ return out
266
+ }
267
+
268
+ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
269
+ const opener = `${indent}- [${done ? 'x' : ' '}] `
270
+ const hang = ' '.repeat(opener.length)
271
+ const body = collapse(text) + (id ? ` (${id})` : '')
272
+ return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
273
+ }
274
+
275
+ // Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
276
+ // different column. Only *prose* lines count — a single wide table row or a long
277
+ // line of fenced code would otherwise pull the whole file's prose to a wider
278
+ // column than the author wrapped at. Falls back to the default when there's
279
+ // nothing prose-like to learn from.
280
+ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
281
+ let inFence = false
282
+ const widths = []
283
+ for (const l of lines) {
284
+ if (/^[ \t]*```/.test(l)) {
285
+ inFence = !inFence
286
+ continue
287
+ }
288
+ if (inFence) continue
289
+ if (!l.trim()) continue
290
+ if (l.includes('|')) continue // table row
291
+ widths.push(l.length)
292
+ }
293
+ if (!widths.length) return fallback
294
+ const max = Math.max(...widths)
295
+ return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
296
+ }
297
+
298
+ module.exports = {
299
+ fenceMask,
300
+ findTaskBlocks,
301
+ renderTaskBlock,
302
+ wrapEmphasisAware,
303
+ spanMask,
304
+ collapse,
305
+ collapseHyphenAware,
306
+ inferWidth,
307
+ DEFAULT_WIDTH,
308
+ }