@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,94 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Flatten markdown tables that sit INSIDE a list item, because both tracker
5
+ * dialects need it: Linear's renderer corrupts nested-table data cells
6
+ * (measured on probe SKI-28, 2026-08-28 — every data cell loses its first N
7
+ * characters, N being the rendered list indent; the auth header
8
+ * `X-Extraction-Key` was stored as `Extraction-Key`), and ADF's `listItem`
9
+ * cannot contain a `table` node at all, so a Jira render would drop or mangle
10
+ * it. The re-emitted shapes are ones proven to survive nesting unchanged:
11
+ *
12
+ * - 2 columns → a bullet list (`- a — b`), the key/value case
13
+ * - otherwise → a fenced code block wrapping the original rows verbatim
14
+ *
15
+ * This shapes the PROJECTION only. Repo files are never rewritten: the source
16
+ * markdown is valid and renders correctly in GitHub and every editor.
17
+ */
18
+
19
+ const { fenceMask } = require('../../sync-core/src/task-block.js')
20
+
21
+ // A table row: optional indent, then a `|`-delimited line. We only ever act on
22
+ // indented ones — a column-0 table (the `## Phases` index, every Impact map) is
23
+ // rendered correctly by Linear and must project byte-identically.
24
+ const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
25
+ // The separator under the header — `|---|:--:|`. Its presence is what makes the
26
+ // block a table rather than prose that happens to contain pipes.
27
+ const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
28
+
29
+ // Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
30
+ // documented `` `a | b` `` alternation stays one cell instead of splitting.
31
+ function splitCells(body) {
32
+ const cells = []
33
+ let cur = ''
34
+ let code = false
35
+ for (const ch of body) {
36
+ if (ch === '`') code = !code
37
+ if (ch === '|' && !code) {
38
+ cells.push(cur.trim())
39
+ cur = ''
40
+ continue
41
+ }
42
+ cur += ch
43
+ }
44
+ cells.push(cur.trim())
45
+ return cells
46
+ }
47
+
48
+ /**
49
+ * Rewrite every indented table in `md`. Returns the text unchanged when there is
50
+ * nothing nested to flatten.
51
+ * @param {string} md
52
+ * @returns {string}
53
+ */
54
+ function flattenNestedTables(md) {
55
+ if (md == null) return md
56
+ const lines = String(md).split('\n')
57
+ const inFence = fenceMask(lines)
58
+ const out = []
59
+
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const header = ROW_RE.exec(lines[i])
62
+ // A table shown as an EXAMPLE inside a ``` block is documentation — often of
63
+ // this very bug — so it is left exactly as written.
64
+ if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
65
+ out.push(lines[i])
66
+ continue
67
+ }
68
+
69
+ const indent = header[1]
70
+ const rows = [splitCells(header[2])]
71
+ const raw = [lines[i], lines[i + 1]]
72
+ let j = i + 2
73
+ for (; j < lines.length && !inFence[j]; j++) {
74
+ const row = ROW_RE.exec(lines[j])
75
+ if (!row) break
76
+ rows.push(splitCells(row[2]))
77
+ raw.push(lines[j])
78
+ }
79
+
80
+ if (rows[0].length === 2) {
81
+ // Header first, bolded: dropping it would lose content and inventing a
82
+ // caption would invent it.
83
+ out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
84
+ for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
85
+ } else {
86
+ out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
87
+ }
88
+ i = j - 1
89
+ }
90
+ return out.join('\n')
91
+ }
92
+
93
+
94
+ module.exports = { flattenNestedTables, splitCells }
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Terminal input helpers for a credentials CLI — reading a piped secret, and
5
+ * prompting for one with the echo off.
6
+ */
7
+
8
+ // Read stdin to completion (for `--stdin`).
9
+ function readAllStdin(input) {
10
+ return new Promise((resolve, reject) => {
11
+ let data = ''
12
+ input.setEncoding('utf-8')
13
+ input.on('data', (chunk) => (data += chunk))
14
+ input.on('end', () => resolve(data))
15
+ input.on('error', reject)
16
+ })
17
+ }
18
+
19
+ // Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
20
+ // hook — filtering it is what keeps the key off the screen (and out of a
21
+ // screen-shared terminal or a recorded session).
22
+ //
23
+ // READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
24
+ // the interface loses it: readline clears from the cursor to the end of the
25
+ // screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
26
+ // it appeared and the user was left staring at a blank line while the process
27
+ // waited for a key — indistinguishable from a hang.
28
+ //
29
+ // The hook is assigned BEFORE `question`, so the very first redraw goes through
30
+ // it. readline hands it `prompt + what has been typed`; re-writing only the
31
+ // prompt is what keeps the key hidden while the prompt survives every redraw.
32
+ function promptHidden(question, input, out) {
33
+ const readline = require('node:readline')
34
+ return new Promise((resolve) => {
35
+ // `out`, never `process.stdout`: they are the same stream in production, and
36
+ // hardcoding one half meant readline cleared a screen the prompt had not
37
+ // been written to under test — the split that hid this bug from the suite.
38
+ const rl = readline.createInterface({ input, output: out, terminal: true })
39
+ rl._writeToOutput = (s) => {
40
+ if (s.includes(question)) out.write(question)
41
+ }
42
+ rl.question(question, (answer) => {
43
+ out.write('\n')
44
+ rl.close()
45
+ resolve(answer)
46
+ })
47
+ })
48
+ }
49
+
50
+ module.exports = { readAllStdin, promptHidden }
@@ -0,0 +1,226 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which tickets a release contains.
5
+ *
6
+ * The repo lands specs with `merge --ff-only`, so history is linear and branch
7
+ * names never reach it. A commit's `Refs:` trailer is therefore the only place
8
+ * the ticket survives into the range a release scans — see the
9
+ * `commit-trailers` rule. Nothing here is tracker-specific: the identifier
10
+ * shape (`KEY-123`) and the team-key prefix are common to the trackers this
11
+ * scans for, which is why the module lives in the kit.
12
+ *
13
+ * The parsing half is pure and takes commits as data, so the rules below are
14
+ * testable without git. Reading the range, and enriching refs with issue titles,
15
+ * belong to the caller.
16
+ */
17
+
18
+ // An issue identifier: uppercase team key, dash, number.
19
+ const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
20
+ // A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
21
+ // that happens to mention the word.
22
+ const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
23
+
24
+ /**
25
+ * The refs a single commit body claims.
26
+ *
27
+ * Deliberately blind to three things that LOOK like trailers and are not:
28
+ *
29
+ * - a line inside a ``` fence — a commit explaining the convention quotes it;
30
+ * - a quoted line (`> Refs: …`) — same reason, in review replies;
31
+ * - an indented line (4+ spaces) — markdown code, e.g. a sample message.
32
+ *
33
+ * Counting any of those would make a release claim work it does not contain,
34
+ * which is worse than missing a ticket: the missing one is noticed when someone
35
+ * looks for it, the invented one never is.
36
+ */
37
+ function refsInBody(body) {
38
+ const found = []
39
+ let fenced = false
40
+ for (const raw of String(body || '').split('\n')) {
41
+ const line = raw.replace(/\r$/, '')
42
+ if (/^\s*```/.test(line)) {
43
+ fenced = !fenced
44
+ continue
45
+ }
46
+ if (fenced) continue
47
+ if (/^\s*>/.test(line)) continue
48
+ if (/^ {4,}|\t/.test(line)) continue
49
+ const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
50
+ if (!m) continue
51
+ for (const id of m[1].match(IDENTIFIER_RE) || []) found.push(id)
52
+ }
53
+ return found
54
+ }
55
+
56
+ /**
57
+ * Whether a commit's changed paths are ENTIRELY bookkeeping.
58
+ *
59
+ * The problem it solves: a spec's `chore(spec): complete <name>` commit carries
60
+ * the same `Refs:` trailer as the code it describes, but lands after the tag
61
+ * that shipped that code — so the ticket turns up in two consecutive release
62
+ * ranges, once for its code and once for its paperwork.
63
+ *
64
+ * Deliberately a POSITIVE signal: it says yes only when it actually saw paths
65
+ * and every one of them sits under an ignored prefix. It never reasons from an
66
+ * absence.
67
+ *
68
+ * WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
69
+ * no files at all for a MERGE commit (without `-m`) — verified, not assumed —
70
+ * nor for a genuinely empty one, and a `git log` that failed outright yields no
71
+ * paths for anything. Under that phrasing every one of those becomes a release
72
+ * silently losing its tickets. Under this one they mean "nothing was seen, so
73
+ * nothing is known", which keeps the pre-filter behaviour: an over-claimed
74
+ * ticket is noticed when someone looks for it, whereas a ticket that quietly
75
+ * belongs to no release never is.
76
+ *
77
+ * Matching is path-prefix, not glob: `specs` and `specs/` both mean the
78
+ * directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
79
+ * never swallows `specs-archive/`.
80
+ *
81
+ * @param {string[]|null|undefined} paths repo-relative paths the commit changed
82
+ * @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
83
+ */
84
+ function onlyIgnoredPaths(paths, ignorePaths) {
85
+ if (!Array.isArray(paths) || !paths.length) return false
86
+ const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
87
+ .filter((p) => typeof p === 'string' && p.trim())
88
+ .map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
89
+ .filter(Boolean)
90
+ if (!prefixes.length) return false
91
+ return paths.every((raw) => {
92
+ const file = String(raw == null ? '' : raw)
93
+ .trim()
94
+ .replace(/^\.\//, '')
95
+ // An unreadable entry is an unknown, not an ignored one — it makes the whole
96
+ // commit count, per the bias above.
97
+ if (!file) return false
98
+ return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Fold commits into the report a release needs.
104
+ *
105
+ * @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
106
+ * @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
107
+ * see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
108
+ * report is what it was before the filter existed.
109
+ * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
110
+ * `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
111
+ * is listed once, where it first appears, not eight times. `ignored` is
112
+ * reported rather than merely subtracted — a filter that removes commits in
113
+ * silence reads as "there was nothing there".
114
+ */
115
+ function ticketsInRange(commits, options = {}) {
116
+ const ignorePaths = (options && options.ignorePaths) || []
117
+ const seen = new Map()
118
+ let unreferenced = 0
119
+ let ignored = 0
120
+ for (const commit of commits || []) {
121
+ if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
122
+ // Not counted as unreferenced either: that number exists to surface a
123
+ // MISSED trailer, and a paperwork commit is not a gap someone should hunt.
124
+ ignored++
125
+ continue
126
+ }
127
+ const refs = [...new Set(refsInBody(commit && commit.body))]
128
+ if (!refs.length) {
129
+ unreferenced++
130
+ continue
131
+ }
132
+ for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
133
+ }
134
+ return {
135
+ tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
136
+ unreferenced,
137
+ ignored,
138
+ total: (commits || []).length,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Split a release's tickets into the ones a stage move may touch and the ones it
144
+ * must not, with a reason for every exclusion.
145
+ *
146
+ * Three reasons to leave a ticket alone, and each is a case where acting would
147
+ * be worse than not acting:
148
+ *
149
+ * - **foreign** — the ref is not this repo's team. A range can carry another
150
+ * team's ref (a shared dependency, a quoted ticket), and writing to a team
151
+ * this repo was never configured for is the worst failure available here.
152
+ * - **unlinked** — the ref is this team's, but no spec in the repo claims it.
153
+ * It may be tracker-only work, or a typo in a trailer. Either way nothing
154
+ * here knows what it is, so it is reported, not moved.
155
+ * - **unfinished** — a spec that has not reached the ceded bucket. Its code
156
+ * really is in the release (it landed via `/spec-to-main`), but push still
157
+ * owns its workflow state and would bounce it straight back. Exactly one
158
+ * writer per issue at any moment beats a visible flip-flop.
159
+ *
160
+ * Pure: it takes the spec list as data, so the rules are testable without a repo.
161
+ */
162
+ function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
163
+ const bucketOf = new Map()
164
+ for (const spec of specs || []) {
165
+ if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
166
+ }
167
+ const key = String(teamKey || '').trim().toUpperCase()
168
+ const movable = []
169
+ const foreign = []
170
+ const unlinked = []
171
+ const unfinished = []
172
+ for (const ticket of tickets || []) {
173
+ const ref = ticket && ticket.ref
174
+ if (!ref) continue
175
+ if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
176
+ foreign.push(ticket)
177
+ } else if (!bucketOf.has(ref)) {
178
+ unlinked.push(ticket)
179
+ } else if (bucketOf.get(ref) !== cededBucket) {
180
+ unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
181
+ } else {
182
+ movable.push(ticket)
183
+ }
184
+ }
185
+ return { movable, foreign, unlinked, unfinished }
186
+ }
187
+
188
+ /**
189
+ * Whether moving from `fromState` to the rung `toKey` runs against the declared
190
+ * order — and if so, how to say it.
191
+ *
192
+ * WARNS, never refuses. A rollback from test and a hotfix going straight to prod
193
+ * are both legitimate, and a check that blocked either would be wrong on healthy
194
+ * input. An issue that is on no rung yet is entering the ladder, which is the
195
+ * normal case and says nothing.
196
+ *
197
+ * `lifecycleStates` is the bucket map's own state names, and a state among them
198
+ * means the issue is at its LIFECYCLE position, not on a rung — even when the
199
+ * two names coincide. They routinely do: `states.complete` and a final `prod`
200
+ * rung are both naturally "Done", and without this a spec that had merely been
201
+ * completed would read as already deployed to prod, so its first real deploy
202
+ * would be warned about as a move backwards. Bucket wins over ladder here for
203
+ * the same reason it wins in `bucketForState`.
204
+ *
205
+ * @returns {string|null}
206
+ */
207
+ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
208
+ const list = Array.isArray(stages) ? stages : []
209
+ const to = list.findIndex((s) => s && s.key === toKey)
210
+ if (to < 0) return null
211
+ const want = String(fromState || '').toLowerCase().trim()
212
+ if (!want) return null
213
+ const lifecycle = new Set(
214
+ (Array.isArray(lifecycleStates) ? lifecycleStates : [])
215
+ .filter((n) => typeof n === 'string')
216
+ .map((n) => n.toLowerCase().trim()),
217
+ )
218
+ if (lifecycle.has(want)) return null
219
+ const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
220
+ if (from < 0) return null
221
+ if (to < from) return `moves back from "${list[from].key}"`
222
+ if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
223
+ return null
224
+ }
225
+
226
+ module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
@@ -0,0 +1,166 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Spec resolution and listing for a provider CLI — how a spec argument becomes
5
+ * a snapshot directory, how a spec names its snapshot sidecar, and which specs
6
+ * a repo holds with which tracker links.
7
+ *
8
+ * The frontmatter key that links a spec is the provider's
9
+ * (`config.fields.identifier` — sync-core `src/fields.js`); everything else
10
+ * here is filesystem shape shared by every provider. `program` prefixes the
11
+ * user-facing refusals (`spec-sync: spec not found: …`).
12
+ */
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+
17
+ const { BUCKETS, findSpecFolder } = require('../../../env/resolve.js')
18
+ const { readSnapshot, parseFrontmatter, lintPhases, fieldsOf, projectionOf, compareStored } = require('../../sync-core')
19
+
20
+ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
21
+ // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder
22
+ // found under specs/** (preferred) or a literal path to a snapshot directory.
23
+ function resolveSnapshotDir(specArg, dir) {
24
+ const found = findSpecFolder(specArg, dir)
25
+ if (found) return found.path
26
+ const literal = path.resolve(dir, specArg)
27
+ if (fs.existsSync(literal) && fs.statSync(literal).isDirectory()) return literal
28
+ return null
29
+ }
30
+
31
+ // The identifier keying the snapshot sidecar: the spec's stamped identifier
32
+ // if set, else its folder name (so the engine is usable before a spec is
33
+ // linked).
34
+ function specIdentifier(snapshotDir, config) {
35
+ const field = fieldsOf(config).identifier
36
+ try {
37
+ const { frontmatter } = readSnapshot(snapshotDir, config)
38
+ if (frontmatter[field]) return String(frontmatter[field])
39
+ } catch {
40
+ /* fall through to folder name */
41
+ }
42
+ return path.basename(snapshotDir)
43
+ }
44
+
45
+ // Read a spec's stamped identifier without throwing: an unlinked spec, an
46
+ // unreadable file and a spec with no frontmatter all read as `null`.
47
+ function linkedIdentifier(overviewPath, config) {
48
+ const field = fieldsOf(config).identifier
49
+ let raw
50
+ try {
51
+ raw = fs.readFileSync(overviewPath, 'utf-8')
52
+ } catch {
53
+ return null
54
+ }
55
+ const { data } = parseFrontmatter(raw)
56
+ return data[field] ? String(data[field]) : null
57
+ }
58
+
59
+ // Every spec under specs/<bucket>/, paired with the tracker issue it is
60
+ // linked to (`null` when it has never been pushed). Folder specs read
61
+ // `snapshot.overviewFile`; legacy bare `<name>.md` specs read the file
62
+ // itself. Sorted for stable output.
63
+ function listSpecs(dir, config) {
64
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
65
+ const specs = []
66
+ for (const bucket of BUCKETS) {
67
+ const root = path.join(dir, 'specs', bucket)
68
+ let entries
69
+ try {
70
+ entries = fs.readdirSync(root, { withFileTypes: true })
71
+ } catch {
72
+ continue
73
+ }
74
+ for (const entry of entries) {
75
+ let name
76
+ let overviewPath
77
+ if (entry.isDirectory()) {
78
+ name = entry.name
79
+ overviewPath = path.join(root, name, overviewFile)
80
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
81
+ name = entry.name.slice(0, -3)
82
+ overviewPath = path.join(root, entry.name)
83
+ } else {
84
+ continue
85
+ }
86
+ specs.push({ spec: name, bucket, identifier: linkedIdentifier(overviewPath, config) })
87
+ }
88
+ }
89
+ return specs.sort((a, b) => a.spec.localeCompare(b.spec))
90
+ }
91
+
92
+ // Resolve a spec argument to its folder, or null with a message on stdout.
93
+ // EVERY failure path here must print and EVERY caller must return a non-zero
94
+ // code: a push skill checks $? before it applies a plan, so a resolve failure
95
+ // that exits 0 reads as "nothing to do" rather than "I could not find the
96
+ // spec".
97
+ function resolveOrExit(specArg, dir, out) {
98
+ if (!specArg) {
99
+ out.write(`${program}: no spec given\n`)
100
+ return null
101
+ }
102
+ const snapshotDir = resolveSnapshotDir(specArg, dir)
103
+ if (!snapshotDir) {
104
+ out.write(`${program}: spec not found: ${specArg}\n`)
105
+ return null
106
+ }
107
+ return snapshotDir
108
+ }
109
+
110
+ // Phase-status warnings for a spec, one formatted line each. A spec states
111
+ // each phase's status three times and only the h1 emoji is read — get it
112
+ // wrong and the phase projects as `backlog`, pushes cleanly, and is recorded
113
+ // as intended: invisible. Reported by every subcommand that reads a
114
+ // projection, fatal in none — a legacy spec must still push.
115
+ function warningLines(snapshotDir, config) {
116
+ return lintPhases(snapshotDir, config).map((w) => ` warning ${w.file}: ${w.message}`)
117
+ }
118
+
119
+ // Emit warnings on stderr, keeping stdout pure for a machine-readable payload.
120
+ function warnToErr(snapshotDir, config, err) {
121
+ const lines = warningLines(snapshotDir, config)
122
+ if (lines.length) err.write(lines.join('\n') + '\n')
123
+ }
124
+
125
+ // Render the read-back comparison — the same check `verify` and `apply`
126
+ // both report through, so the two cannot disagree about what "intact" means.
127
+ function verifyLines(snapshotDir, config, stored, identifier) {
128
+ const projection = projectionOf(snapshotDir, config)
129
+ const checks = []
130
+ if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
131
+ for (const [ref, text] of Object.entries(stored.subIssues || {})) {
132
+ const sub = projection.subIssues.find((s) => s.ref === ref)
133
+ if (!sub) {
134
+ checks.push([`sub-issue ${ref}`, null, text])
135
+ continue
136
+ }
137
+ checks.push([`sub-issue ${ref}`, sub.goal, text])
138
+ }
139
+
140
+ const lines = [`${program} verify: ${identifier}`]
141
+ let bad = 0
142
+ for (const [label, sent, got] of checks) {
143
+ if (sent == null) {
144
+ lines.push(` ?? ${label}: read back, but the projection has no such phase — stale ref?`)
145
+ bad++
146
+ continue
147
+ }
148
+ const r = compareStored(sent, got, config)
149
+ if (r.ok) continue
150
+ bad++
151
+ lines.push(
152
+ ` !! ${label}: the tracker stored different text — ${Math.abs(r.lost)} character(s) ` +
153
+ `${r.lost > 0 ? 'lost' : 'added'}, first difference at ${r.at}`,
154
+ ` sent: …${r.sentContext}…`,
155
+ ` stored: …${r.storedContext}…`,
156
+ )
157
+ }
158
+ if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
159
+ else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
160
+ return lines
161
+ }
162
+
163
+ return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, resolveOrExit, warningLines, warnToErr, verifyLines }
164
+ }
165
+
166
+ module.exports = { makeSpecHelpers }
@@ -0,0 +1,22 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which transport a command runs over: `--via` wins, else the config's
5
+ * declared default, else "api when a credential resolved, mcp when it didn't".
6
+ *
7
+ * This expression was repeated verbatim nine times in the first provider —
8
+ * hoisted so the precedence is decided once. Per-command DEGRADATION stays with
9
+ * the command: which operations are impossible over mcp (exit 1, say so) and
10
+ * which hand the skill instructions (exit 0) is a per-command judgment, not a
11
+ * transport fact.
12
+ */
13
+
14
+ function resolveTransport(flags, config, credentialOk) {
15
+ return (
16
+ (flags && flags.via) ||
17
+ (config && config.apply && config.apply.transport) ||
18
+ (credentialOk ? 'api' : 'mcp')
19
+ )
20
+ }
21
+
22
+ module.exports = { resolveTransport }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Provider-neutral one-way sync engine (repo → tracker).
5
+ *
6
+ * Every function is parameterised by a plain `config` object — it knows nothing
7
+ * about any specific tracker. The repo is the source of truth: the engine builds
8
+ * a local projection, diffs it against a committed last-pushed snapshot
9
+ * (`planChanges`), and returns a create/update plan the provider skill applies
10
+ * over its API. No remote content is read for CONTENT: nothing the tracker holds
11
+ * ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
12
+ * one function that looks at a tracker value, and it only checks that what was
13
+ * stored matches what was sent — it merges nothing (see `src/verify.js`).
14
+ */
15
+
16
+ const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
+ const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
18
+ const { readBase, writeBase } = require('./src/base.js')
19
+ const { push, recordPush, projectionOf } = require('./src/push.js')
20
+ const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
21
+ const { detectLegacyMirror } = require('./src/legacy.js')
22
+ const { compareStored, stream } = require('./src/verify.js')
23
+ const { identityDialect, dialectOf } = require('./src/dialect.js')
24
+ const { fieldsOf } = require('./src/fields.js')
25
+ const { renderTaskBlock, findTaskBlocks, wrapEmphasisAware, collapseHyphenAware, inferWidth, fenceMask, DEFAULT_WIDTH } = require('./src/task-block.js')
26
+ const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
27
+
28
+ module.exports = {
29
+ ownsField,
30
+ normalizeLocal,
31
+ // Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
32
+ // to read, so the listing must resolve the mode rather than assume `subissue`.
33
+ phaseModeFor,
34
+ lintPhases,
35
+ readSnapshot,
36
+ parseFrontmatter,
37
+ projectionOf,
38
+ planChanges,
39
+ snapshotOf,
40
+ isEmptyPlan,
41
+ push,
42
+ recordPush,
43
+ remoteWorkflowState,
44
+ titleFromText,
45
+ validateStates,
46
+ stateSuggestions,
47
+ stageForState,
48
+ remoteStage,
49
+ LADDER_ORIGIN_BUCKET,
50
+ hashField,
51
+ stableStringify,
52
+ readBase,
53
+ writeBase,
54
+ writeFrontmatter,
55
+ deleteFrontmatter,
56
+ stampSubIssueId,
57
+ stampIssueId,
58
+ findPhaseFileByTitle,
59
+ listPhaseFiles,
60
+ detectLegacyMirror,
61
+ compareStored,
62
+ // The tolerant reduction behind both `compareStored` and the snapshot's
63
+ // `descriptionStream` hash — exported so a provider can reduce a read-back the
64
+ // same way rather than writing a second one that disagrees.
65
+ stream,
66
+ // Did someone else edit the tracker's description since the last push?
67
+ // Three-valued; `null` is cannot-tell and must never be reported as drift.
68
+ remoteDescriptionEdited,
69
+ // Provider seams: the frontmatter field vocabulary and the markdown dialect
70
+ // a provider's config supplies (`config.fields` / `config.dialect`).
71
+ fieldsOf,
72
+ identityDialect,
73
+ dialectOf,
74
+ // The lifecycle buckets, exported once so providers and tests stop growing
75
+ // private copies (the tracker-free common package keeps its own — it cannot
76
+ // depend on this package).
77
+ LIFECYCLE_BUCKETS,
78
+ // Task-block helpers a provider's own text tooling builds on (the Linear
79
+ // sanitiser reflows emphasis-aware with these).
80
+ renderTaskBlock,
81
+ findTaskBlocks,
82
+ wrapEmphasisAware,
83
+ collapseHyphenAware,
84
+ inferWidth,
85
+ fenceMask,
86
+ DEFAULT_WIDTH,
87
+ planRetarget,
88
+ applyRetarget,
89
+ deriveRecordedKey,
90
+ isEmptyRetarget,
91
+ dirtyPaths,
92
+ }