@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,1252 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `skitterspec-jira spec-sync <sub>` — the Jira provider's engine CLI.
5
+ *
6
+ * Built on the provider kit: spec resolution, transport precedence, the flag
7
+ * parser with its unknown-flag refusal, the credentials prompts and the apply
8
+ * orchestration are all shared machinery; what this file owns is Jira's — the
9
+ * status-by-transition ops, the JQL, the config file it names in refusals,
10
+ * and which operations the MCP transport cannot express.
11
+ *
12
+ * Exit-code semantics mirror the Linear provider's table: `ref` prints the
13
+ * bare identifier on stdout ONLY on success (so `$(spec-sync ref)` cannot
14
+ * splice an error into a commit body); `verify` warns and never fails on
15
+ * losses; `preserve` exits 0 on every cannot-tell; dry-runs are the default
16
+ * wherever a write exists; an unknown `--flag` refuses before anything runs.
17
+ */
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+
22
+ const {
23
+ push,
24
+ recordPush,
25
+ readBase,
26
+ isEmptyPlan,
27
+ validateStates,
28
+ stateSuggestions,
29
+ remoteDescriptionEdited,
30
+ writeFrontmatter,
31
+ stampSubIssueId,
32
+ listPhaseFiles,
33
+ normalizeLocal,
34
+ } = require('../sync-core')
35
+ const kit = require('../provider-kit')
36
+ const { BUCKETS, findSpecFolder, currentBranch } = require('../../env/resolve.js')
37
+
38
+ const { loadJiraConfig, mergeConfig, defaults, CONFIG_FILE, LIFECYCLE_BUCKETS, DEFAULT_CONFIG } = require('./config.js')
39
+ const { resolveCredential, writeCredential, removeCredential, storePath, storeMode, fingerprint } = require('./credentials.js')
40
+ const { makeApiAdapter } = require('./api.js')
41
+
42
+ const PROGRAM = 'spec-sync'
43
+ const specHelpers = kit.makeSpecHelpers({ program: PROGRAM })
44
+ const { resolveSnapshotDir, specIdentifier, listSpecs, resolveOrExit, warnToErr, verifyLines } = specHelpers
45
+
46
+ // An issue identifier: project key, dash, number.
47
+ const ID_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // The state gate. Jira refuses an unknown STATUS at transition time — after
51
+ // the description write has already landed — so a half-typed `states` table
52
+ // would mirror the prose and strand the workflow move. Same discipline as the
53
+ // Linear provider: validate against the project's real statuses first.
54
+ function stateCheckFailure(config, flags) {
55
+ if (flags.skipStateCheck) return null
56
+ if (!flags.workspaceStates) {
57
+ return [
58
+ `${PROGRAM} plan: refusing — the configured issue states have not been validated`,
59
+ " pass --workspace-states <file> (a JSON array of the project's status",
60
+ ' names — `spec-sync states --json` prints exactly that), or',
61
+ ' --skip-state-check to push anyway.',
62
+ ' Jira refuses an unknown status only at transition time, after the',
63
+ ' description write has landed: the push would half-apply.',
64
+ ]
65
+ }
66
+ if (!fs.existsSync(flags.workspaceStates)) {
67
+ return [`${PROGRAM} plan: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
68
+ }
69
+ let names
70
+ try {
71
+ names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
72
+ } catch (error) {
73
+ return [`${PROGRAM} plan: refusing — --workspace-states is not valid JSON: ${error.message}`]
74
+ }
75
+ const list = Array.isArray(names) ? names : []
76
+ const missing = validateStates(config, list)
77
+ if (missing.length) {
78
+ const lines = [`${PROGRAM} plan: refusing — configured status name(s) not in the project`, '']
79
+ for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
80
+ lines.push(` ${label}: "${configured}" is not a status in this project`)
81
+ if (suggestion) lines.push(` use "${suggestion}" instead`)
82
+ }
83
+ lines.push('', ` available: ${list.join(', ') || '(the project reported none)'}`, ` Fix ${CONFIG_FILE} -> states.`)
84
+ return lines
85
+ }
86
+ return null
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Apply ops: the kit orchestration with Jira's tracker calls. Status is moved
91
+ // by TRANSITION after the create/update — Jira has no "set status" field — and
92
+ // `transitionTo` is idempotent when the issue already sits on the target.
93
+ function applyOps({ adapter, config }) {
94
+ const statusFor = (bucket) => {
95
+ const name = config.states && config.states[bucket]
96
+ if (!name) {
97
+ throw new Error(`no status configured for the "${bucket}" bucket — fix ${CONFIG_FILE} -> states`)
98
+ }
99
+ return name
100
+ }
101
+ const settle = async (keyOrId, bucket) => {
102
+ if (!bucket) return
103
+ await adapter.transitionTo(keyOrId, statusFor(bucket))
104
+ }
105
+ return {
106
+ prevalidateStates: (buckets) => {
107
+ for (const bucket of buckets) statusFor(bucket)
108
+ },
109
+ searchTwin: (title) => adapter.searchIssues({ query: title, projectKey: config.jira.projectKey }),
110
+ twinAdvice: (twin, wanted, specName) =>
111
+ `${twin.identifier} is already titled "${wanted}" and no spec claims it — ` +
112
+ `stamp it onto the spec with \`spec-sync stamp ${specName} --issue ${twin.identifier}\`, ` +
113
+ 'or pass --force-new to create a second',
114
+ missingIssueMessage: (identifier) => `no Jira issue found for ${identifier}`,
115
+ remoteEditedLine: (identifier) =>
116
+ ` !! ${identifier}'s description was edited on Jira since the last push — this replaces it`,
117
+ mintIssue: async ({ title, issue }) => {
118
+ const created = await adapter.createIssue({
119
+ title,
120
+ description: issue && issue.description != null ? issue.description : undefined,
121
+ assigneeId: issue && issue.assignee ? issue.assignee : undefined,
122
+ projectKey: config.jira.projectKey,
123
+ issueTypeName: config.jira.issueType,
124
+ })
125
+ await settle(created.identifier, issue && issue.state)
126
+ return created
127
+ },
128
+ updateIssue: async (existing, issue) => {
129
+ const updates = {}
130
+ if (issue.description !== undefined) updates.description = issue.description
131
+ // Null is meaningful here — it is how Jira unassigns — so the key is
132
+ // forwarded whenever the plan carries it, never truthiness-tested.
133
+ if ('assignee' in issue) updates.assigneeId = issue.assignee ?? null
134
+ if (Object.keys(updates).length) await adapter.updateIssue(existing.identifier, updates)
135
+ await settle(existing.identifier, issue.state)
136
+ },
137
+ mintSubIssue: async (parentId, sub) => {
138
+ const created = await adapter.createSubIssue(parentId, {
139
+ title: sub.name,
140
+ description: sub.goal != null ? sub.goal : undefined,
141
+ projectKey: config.jira.projectKey,
142
+ subtaskTypeName: config.jira.subtaskType,
143
+ })
144
+ await settle(created.identifier, sub.state)
145
+ return created
146
+ },
147
+ updateSubIssue: async (sub) => {
148
+ const updates = {}
149
+ if (sub.name != null) updates.title = sub.name
150
+ if (sub.goal != null) updates.description = sub.goal
151
+ if (Object.keys(updates).length) await adapter.updateIssue(sub.id, updates)
152
+ await settle(sub.id, sub.state)
153
+ },
154
+ }
155
+ }
156
+
157
+ function apiAdapterFor(config, flags) {
158
+ if (flags.adapter) return { ok: true, adapter: flags.adapter }
159
+ const cred = resolveCredential(config, flags.env || process.env)
160
+ if (!cred.ok) return { ok: false, error: cred.error }
161
+ return {
162
+ ok: true,
163
+ adapter: makeApiAdapter({
164
+ siteUrl: config.jira.siteUrl,
165
+ email: cred.email,
166
+ token: cred.token,
167
+ fetch: flags.fetch,
168
+ }),
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Subcommands. Each returns an exit code and writes only to the streams it is
174
+ // handed, so every branch is testable offline.
175
+
176
+ function specSyncNormalize(dir, config, specArg, out, err) {
177
+ const snapshotDir = resolveOrExit(specArg, dir, out)
178
+ if (!snapshotDir) return 1
179
+ warnToErr(snapshotDir, config, err)
180
+ out.write(JSON.stringify(normalizeLocal(snapshotDir, config), null, 2) + '\n')
181
+ return 0
182
+ }
183
+
184
+ function specSyncPlan(dir, config, specArg, flags, out, err) {
185
+ const snapshotDir = resolveOrExit(specArg, dir, out)
186
+ if (!snapshotDir) return 1
187
+ const refused = stateCheckFailure(config, flags)
188
+ if (refused) {
189
+ out.write(refused.join('\n') + '\n')
190
+ return 1
191
+ }
192
+ warnToErr(snapshotDir, config, err)
193
+ const identifier = specIdentifier(snapshotDir, config)
194
+ const r = push({ dir, snapshotDir, identifier, config })
195
+ if (flags.json || !out.isTTY) {
196
+ out.write(JSON.stringify(r.plan, null, 2) + '\n')
197
+ return 0
198
+ }
199
+ if (r.empty) {
200
+ out.write(`${PROGRAM} plan: ${identifier} — nothing to push\n`)
201
+ return 0
202
+ }
203
+ const c = r.plan.subIssues.create.length
204
+ const u = r.plan.subIssues.update.length
205
+ out.write(
206
+ `${PROGRAM} plan: ${identifier} — ${r.plan.issue ? 'issue update, ' : ''}${c} subtask create(s), ${u} update(s)\n` +
207
+ ' apply it: spec-sync plan <spec> --json > plan.json && spec-sync apply <spec> --plan plan.json\n',
208
+ )
209
+ return 0
210
+ }
211
+
212
+ async function specSyncApply(dir, config, specArg, flags, out, err) {
213
+ if (!flags.plan && !flags.all) {
214
+ out.write(`${PROGRAM} apply: pass --plan <file> (from \`plan --json\`), or --all <bucket>\n`)
215
+ return 1
216
+ }
217
+ const cred = apiAdapterFor(config, flags)
218
+ const transport = kit.resolveTransport(flags, config, cred.ok)
219
+ if (transport === 'mcp') {
220
+ if (flags.all) {
221
+ out.write(`${PROGRAM} apply --all: the MCP transport cannot bulk-apply — set credentials and re-run\n`)
222
+ return 1
223
+ }
224
+ out.write(
225
+ `${PROGRAM} apply: transport = mcp — nothing written by the engine.\n` +
226
+ ' Apply the plan with the Atlassian MCP tools (createJiraIssue /\n' +
227
+ ' editJiraIssue / transitionJiraIssue), then stamp the returned keys\n' +
228
+ ' with `spec-sync stamp` and record with `spec-sync record`.\n',
229
+ )
230
+ return 0
231
+ }
232
+ if (!cred.ok) {
233
+ out.write(`${PROGRAM} apply: ${cred.error}\n`)
234
+ return 1
235
+ }
236
+
237
+ const applyOne = (snapshotDir, plan) =>
238
+ kit.applySpecPlan({
239
+ dir,
240
+ config,
241
+ snapshotDir,
242
+ plan,
243
+ adapter: cred.adapter,
244
+ ops: applyOps({ adapter: cred.adapter, config }),
245
+ forceNew: !!flags.forceNew,
246
+ program: PROGRAM,
247
+ })
248
+
249
+ if (flags.all) {
250
+ const bucket = String(flags.all)
251
+ if (!BUCKETS.includes(bucket)) {
252
+ out.write(`${PROGRAM} apply --all: not a lifecycle bucket: ${bucket}\n`)
253
+ return 1
254
+ }
255
+ const specs = listSpecs(dir, config).filter((s) => s.bucket === bucket)
256
+ let failed = 0
257
+ for (const spec of specs) {
258
+ const snapshotDir = resolveSnapshotDir(spec.spec, dir)
259
+ const identifier = specIdentifier(snapshotDir, config)
260
+ const r = push({ dir, snapshotDir, identifier, config })
261
+ if (r.empty) continue
262
+ out.write(`${PROGRAM} apply: ${spec.spec}\n`)
263
+ try {
264
+ const applied = await applyOne(snapshotDir, r.plan)
265
+ out.write(applied.lines.join('\n') + '\n')
266
+ } catch (error) {
267
+ failed++
268
+ out.write(` !! ${error.message}\n`)
269
+ if (error.orphans && error.orphans.length) {
270
+ out.write(` created but not stamped: ${error.orphans.join(', ')} — stamp before re-running\n`)
271
+ }
272
+ }
273
+ }
274
+ out.write(`${PROGRAM} apply --all ${bucket}: ${specs.length} spec(s), ${failed} failed\n`)
275
+ return failed ? 1 : 0
276
+ }
277
+
278
+ const snapshotDir = resolveOrExit(specArg, dir, out)
279
+ if (!snapshotDir) return 1
280
+ let plan
281
+ try {
282
+ plan = JSON.parse(fs.readFileSync(flags.plan, 'utf-8'))
283
+ } catch (error) {
284
+ out.write(`${PROGRAM} apply: cannot read --plan: ${error.message}\n`)
285
+ return 1
286
+ }
287
+ if (isEmptyPlan(plan)) {
288
+ out.write(`${PROGRAM} apply: empty plan — nothing to do\n`)
289
+ return 0
290
+ }
291
+ out.write(`${PROGRAM} apply: transport = api\n`)
292
+ warnToErr(snapshotDir, config, err)
293
+ try {
294
+ const applied = await applyOne(snapshotDir, plan)
295
+ out.write(applied.lines.join('\n') + '\n')
296
+ return 0
297
+ } catch (error) {
298
+ out.write(`${PROGRAM} apply: ${error.message}\n`)
299
+ if (error.orphans && error.orphans.length) {
300
+ out.write(
301
+ ` created but not stamped: ${error.orphans.join(', ')} — stamp them with \`spec-sync stamp\` before re-running,\n` +
302
+ ' or the next apply mints duplicates\n',
303
+ )
304
+ }
305
+ return 1
306
+ }
307
+ }
308
+
309
+ async function specSyncStamp(dir, config, specArg, flags, out) {
310
+ const snapshotDir = resolveOrExit(specArg, dir, out)
311
+ if (!snapshotDir) return 1
312
+ // Validate EVERYTHING before writing ANYTHING: a half-stamped spec mints
313
+ // duplicates on the next push, which is worse than a refused stamp.
314
+ const problems = []
315
+ if (!flags.issue) problems.push('--issue <KEY-n> is required')
316
+ else if (!ID_RE.test(flags.issue)) problems.push(`--issue ${flags.issue} is not an issue key (PROJ-123)`)
317
+ if (flags.url && !/^https?:\/\//i.test(flags.url)) problems.push(`--url must be http(s): ${flags.url}`)
318
+ const subs = []
319
+ for (const pair of flags.subs || []) {
320
+ const [ref, ...rest] = String(pair).split('=')
321
+ const id = rest.join('=').trim()
322
+ const file = listPhaseFiles(snapshotDir).find(
323
+ (f) => f.replace(/\.md$/, '') === String(ref).trim().replace(/\.md$/, ''),
324
+ )
325
+ if (!file) problems.push(`--sub ${pair}: no phase file named ${ref}`)
326
+ else if (!ID_RE.test(id)) problems.push(`--sub ${pair}: ${id || '(empty)'} is not an issue key`)
327
+ else subs.push({ file, id })
328
+ }
329
+ // The live half of the same promise: when credentials resolve, a key Jira
330
+ // positively answers "no such issue" to is refused — a wrong stamp's whole
331
+ // failure mode is a duplicate minted LATER, far from the typo, so catching
332
+ // it here is worth one read per key. Offline use stays first-class: no
333
+ // credentials, or a Jira that cannot be reached, is a cannot-tell and stamps
334
+ // exactly as before (negative-checks rule 4) — a thrown read says it could
335
+ // not check rather than implying it did.
336
+ let verified = false
337
+ if (!problems.length) {
338
+ const cred = apiAdapterFor(config, flags)
339
+ if (cred.ok) {
340
+ try {
341
+ const keys = [[flags.issue, '--issue'], ...subs.map((sub) => [sub.id, `--sub ${path.basename(sub.file)}`])]
342
+ for (const [id, label] of keys) {
343
+ if ((await cred.adapter.readIssue(id)) == null) {
344
+ problems.push(`${label} ${id}: Jira has no such issue — check the key in the browser URL`)
345
+ }
346
+ }
347
+ verified = !problems.length
348
+ } catch {
349
+ out.write(`${PROGRAM} stamp: could not check the key(s) against Jira — stamping anyway\n`)
350
+ }
351
+ }
352
+ }
353
+ if (problems.length) {
354
+ out.write([`${PROGRAM} stamp: refusing — nothing written`, ...problems.map((p) => ` ${p}`)].join('\n') + '\n')
355
+ return 1
356
+ }
357
+ const fields = config.fields
358
+ const site = config.jira.siteUrl
359
+ const url = flags.url || (site ? `${site}/browse/${flags.issue}` : null)
360
+ writeFrontmatter(snapshotDir, config, {
361
+ [fields.identifier]: flags.issue,
362
+ ...(url ? { [fields.url]: url } : {}),
363
+ })
364
+ for (const { file, id } of subs) stampSubIssueId(snapshotDir, file, id, config)
365
+ out.write(
366
+ `${PROGRAM} stamp: ${flags.issue} stamped onto ${path.basename(snapshotDir)} (+${subs.length} subtask stamp(s))` +
367
+ `${verified ? ' — verified against Jira' : ''}\n`,
368
+ )
369
+ return 0
370
+ }
371
+
372
+ function specSyncRecord(dir, config, specArg, out) {
373
+ const snapshotDir = resolveOrExit(specArg, dir, out)
374
+ if (!snapshotDir) return 1
375
+ const file = recordPush({ dir, snapshotDir, identifier: specIdentifier(snapshotDir, config), config })
376
+ out.write(`${PROGRAM} record: snapshot written — ${path.relative(dir, file)}\n`)
377
+ return 0
378
+ }
379
+
380
+ function specSyncStatus(dir, config, specArg, flags, out, err) {
381
+ const snapshotDir = resolveOrExit(specArg, dir, out)
382
+ if (!snapshotDir) return 1
383
+ const identifier = specIdentifier(snapshotDir, config)
384
+ const lines = [`${PROGRAM} status: ${identifier}`]
385
+ warnToErr(snapshotDir, config, err)
386
+
387
+ if (flags.workspaceStates) {
388
+ const refused = stateCheckFailure(config, { workspaceStates: flags.workspaceStates })
389
+ if (refused) {
390
+ out.write(refused.join('\n') + '\n')
391
+ return 1
392
+ }
393
+ lines.push(' states: every configured status exists in the project')
394
+ }
395
+
396
+ const r = push({ dir, snapshotDir, identifier, config })
397
+ if (r.empty) lines.push(' push: in sync — nothing would push')
398
+ else {
399
+ const c = r.plan.subIssues.create.length
400
+ const u = r.plan.subIssues.update.length
401
+ lines.push(` push: pending — ${r.plan.issue ? 'issue update, ' : ''}${c} subtask create(s), ${u} update(s)`)
402
+ }
403
+
404
+ if (flags.remote) {
405
+ let remote
406
+ try {
407
+ remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
408
+ } catch (error) {
409
+ out.write(`${PROGRAM} status: cannot read --remote: ${error.message}\n`)
410
+ return 1
411
+ }
412
+ // NO STATE LINE, deliberately: skitterspec tells the tracker about its own
413
+ // transitions and never reads a status back. A ticket someone moved there
414
+ // is theirs to have moved, and push resends State only when the spec does.
415
+ // SILENT ON EVERYTHING EXCEPT A POSITIVE ANSWER: `remoteDescriptionEdited`
416
+ // returns null for every cannot-tell, and none of those is evidence.
417
+ if (remoteDescriptionEdited(readBase(dir, identifier, config), remote, config) === true) {
418
+ lines.push(
419
+ ` drift: ${identifier}'s description was edited on Jira since the last push`,
420
+ ' (repo wins on next push — read it before pushing)',
421
+ )
422
+ }
423
+ }
424
+ out.write(lines.join('\n') + '\n')
425
+ return 0
426
+ }
427
+
428
+ function specSyncVerify(dir, config, specArg, flags, out) {
429
+ const snapshotDir = resolveOrExit(specArg, dir, out)
430
+ if (!snapshotDir) return 1
431
+ if (!flags.stored) {
432
+ out.write(`${PROGRAM} verify: pass --stored <file> ({ issue, subIssues: { ref: text } })\n`)
433
+ return 1
434
+ }
435
+ let stored
436
+ try {
437
+ stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
438
+ } catch (error) {
439
+ out.write(`${PROGRAM} verify: cannot read --stored: ${error.message}\n`)
440
+ return 1
441
+ }
442
+ // A snapshot passed as --stored compares hashes against text and reports
443
+ // total loss on a healthy mirror — refuse the shape explicitly.
444
+ if (stored && stored.issueFields) {
445
+ out.write(
446
+ `${PROGRAM} verify: that looks like a .base.json snapshot, not a read-back —\n` +
447
+ ' pass the DESCRIPTIONS the tracker returned ({ issue, subIssues: { ref: text } })\n',
448
+ )
449
+ return 1
450
+ }
451
+ out.write(verifyLines(snapshotDir, config, stored || {}, specIdentifier(snapshotDir, config)).join('\n') + '\n')
452
+ // Warn, never fail: losses are for a human to read, and failing the command
453
+ // would fail the push that just succeeded.
454
+ return 0
455
+ }
456
+
457
+ function specSyncLinked(dir, config, flags, out) {
458
+ const specs = listSpecs(dir, config)
459
+ if (flags.json) {
460
+ out.write(JSON.stringify(specs, null, 2) + '\n')
461
+ return 0
462
+ }
463
+ if (!specs.length) {
464
+ out.write(`${PROGRAM} linked: no specs found under specs/\n`)
465
+ return 0
466
+ }
467
+ const lines = [`${PROGRAM} linked:`]
468
+ for (const s of specs) lines.push(` ${s.identifier || '—'}\t${s.spec} (${s.bucket})`)
469
+ lines.push(` ${specs.filter((s) => s.identifier).length}/${specs.length} linked`)
470
+ out.write(lines.join('\n') + '\n')
471
+ return 0
472
+ }
473
+
474
+ async function specSyncStates(dir, config, flags, out) {
475
+ const cred = apiAdapterFor(config, flags)
476
+ const transport = kit.resolveTransport(flags, config, cred.ok)
477
+ if (transport === 'mcp') {
478
+ out.write(
479
+ `${PROGRAM} states: transport = mcp — fetch the project's statuses with the\n` +
480
+ ' Atlassian MCP tools and write the names to a JSON array file for\n' +
481
+ ' --workspace-states.\n',
482
+ )
483
+ return 0
484
+ }
485
+ if (!cred.ok) {
486
+ out.write(`${PROGRAM} states: ${cred.error}\n`)
487
+ return 1
488
+ }
489
+ if (!config.jira.projectKey) {
490
+ out.write(`${PROGRAM} states: no jira.projectKey configured — fix ${CONFIG_FILE}\n`)
491
+ return 1
492
+ }
493
+ let states
494
+ try {
495
+ states = await cred.adapter.listIssueStates(config.jira.projectKey)
496
+ } catch (error) {
497
+ out.write(`${PROGRAM} states: ${error.message}\n`)
498
+ return 1
499
+ }
500
+ const names = states.map((s) => s.name)
501
+ if (flags.json) {
502
+ // The bare array --workspace-states consumes.
503
+ out.write(JSON.stringify(names, null, 2) + '\n')
504
+ return 0
505
+ }
506
+ const catOf = new Map(states.map((s) => [s.name, s.category || null]))
507
+ const lines = [
508
+ `${PROGRAM} states: ${config.jira.projectKey}`,
509
+ ...names.map((n) => ` ${n}${catOf.get(n) ? ` [${catOf.get(n)}]` : ''}`),
510
+ ]
511
+ // The bucket map — the one visual answer to "is skitterspec wired to what
512
+ // this project actually has?". Every bucket gets a row and a verdict, a
513
+ // missing status names its closest match, and the remap is one command.
514
+ const suggestions = new Map(stateSuggestions(config, names).map((x) => [x.bucket, x.suggestion]))
515
+ const buckets = Object.entries(config.states)
516
+ const bw = Math.max(...buckets.map(([b]) => b.length))
517
+ const nw = Math.max(...buckets.map(([, n]) => String(n).length))
518
+ lines.push('', ' map: lifecycle bucket -> Jira status')
519
+ const missing = []
520
+ for (const [bucket, name] of buckets) {
521
+ const ok = names.includes(name)
522
+ if (!ok) missing.push(bucket)
523
+ const tail = ok
524
+ ? `ok${catOf.get(name) ? ` [${catOf.get(name)}]` : ''}`
525
+ : `MISSING from the project${suggestions.get(bucket) ? ` — closest: "${suggestions.get(bucket)}"` : ''}`
526
+ lines.push(` ${bucket.padEnd(bw)} -> ${String(name).padEnd(nw)} ${tail}`)
527
+ }
528
+ if (missing.length) lines.push(` remap: ${PROGRAM} init-config --force --state <bucket>="<status>"`)
529
+ out.write(lines.join('\n') + '\n')
530
+ return 0
531
+ }
532
+
533
+ async function specSyncList(dir, config, flags, out) {
534
+ const cred = apiAdapterFor(config, flags)
535
+ const transport = kit.resolveTransport(flags, config, cred.ok)
536
+ if (transport === 'mcp' || !cred.ok) {
537
+ // Degrades rather than refusing: a listing is a convenience, and with no
538
+ // credentials there is simply nothing to list from here.
539
+ out.write(`${PROGRAM} list: needs the API transport — ${cred.ok ? 'pass --via api' : cred.error.split('\n')[0]}\n`)
540
+ return 0
541
+ }
542
+ if (!config.jira.projectKey) {
543
+ out.write(`${PROGRAM} list: no jira.projectKey configured — fix ${CONFIG_FILE}\n`)
544
+ return 1
545
+ }
546
+ let issues
547
+ try {
548
+ const stateNames = flags.stateArgs || []
549
+ const query = stateNames.length
550
+ ? { projectKey: config.jira.projectKey, jqlExtra: stateNames }
551
+ : { projectKey: config.jira.projectKey }
552
+ issues = await cred.adapter.searchIssues({ ...query, query: '', limit: flags.limit || 50 })
553
+ if (stateNames.length) {
554
+ const want = new Set(stateNames.map((s) => s.toLowerCase()))
555
+ issues = issues.filter((i) => i.state && want.has(String(i.state.name).toLowerCase()))
556
+ } else if (!flags.all) {
557
+ issues = issues.filter((i) => !i.state || !/done/i.test(String(i.state.name)))
558
+ }
559
+ } catch (error) {
560
+ out.write(`${PROGRAM} list: ${error.message}\n`)
561
+ return 1
562
+ }
563
+ const linked = new Map(listSpecs(dir, config).filter((s) => s.identifier).map((s) => [s.identifier, s]))
564
+ const rows = issues
565
+ .filter((i) => !i.parent)
566
+ .map((i) => ({
567
+ identifier: i.identifier,
568
+ title: i.title,
569
+ state: i.state ? i.state.name : null,
570
+ spec: linked.has(i.identifier) ? linked.get(i.identifier).spec : null,
571
+ }))
572
+ if (flags.json) {
573
+ out.write(JSON.stringify(rows, null, 2) + '\n')
574
+ return 0
575
+ }
576
+ if (!rows.length) {
577
+ out.write(`${PROGRAM} list: nothing matched\n`)
578
+ return 0
579
+ }
580
+ const lines = [`${PROGRAM} list: ${config.jira.projectKey}`]
581
+ for (const r of rows) {
582
+ lines.push(` ${r.identifier}\t${r.state || '—'}\t${r.title || ''}${r.spec ? ` -> ${r.spec}` : ''}`)
583
+ }
584
+ out.write(lines.join('\n') + '\n')
585
+ return 0
586
+ }
587
+
588
+ // The `Refs:` trailer's source of truth: the ticket for the spec this branch
589
+ // (or the named spec) belongs to. Bare identifier on stdout ONLY on success —
590
+ // every no-ref case writes stderr and exits 1, so `$(spec-sync ref)` cannot
591
+ // splice an error into a commit body.
592
+ function specSyncRef(dir, config, specArg, flags, out, err) {
593
+ let snapshotDir = null
594
+ if (specArg) {
595
+ snapshotDir = resolveSnapshotDir(specArg, dir)
596
+ if (!snapshotDir) {
597
+ err.write(`${PROGRAM} ref: spec not found: ${specArg}\n`)
598
+ return 1
599
+ }
600
+ } else {
601
+ const branch = currentBranch(dir)
602
+ if (!branch) {
603
+ err.write(`${PROGRAM} ref: not on a branch\n`)
604
+ return 1
605
+ }
606
+ const slug = branch.includes('/') ? branch.slice(branch.indexOf('/') + 1) : branch
607
+ const type = branch.includes('/') ? branch.slice(0, branch.indexOf('/')) : null
608
+ const name = type ? `${type}-${slug}` : slug
609
+ const found = findSpecFolder(name, dir)
610
+ if (!found) {
611
+ err.write(`${PROGRAM} ref: no spec matches branch ${branch}\n`)
612
+ return 1
613
+ }
614
+ snapshotDir = found.path
615
+ }
616
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
617
+ const id = specHelpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
618
+ if (!id) {
619
+ err.write(`${PROGRAM} ref: ${path.basename(snapshotDir)} has no ${config.fields.identifier} — omit the trailer\n`)
620
+ return 1
621
+ }
622
+ if (flags.json) out.write(JSON.stringify({ identifier: id }) + '\n')
623
+ else out.write(id + '\n')
624
+ return 0
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // `released [<range>]` — which tickets a commit range contains, off the
629
+ // `Refs:` trailers (kit `src/released.js` owns the parsing rules).
630
+ function readCommitRange(dir, range) {
631
+ const { execFileSync } = require('node:child_process')
632
+ const SEP = 'SKITTERSPEC'
633
+ let raw
634
+ try {
635
+ raw = execFileSync('git', ['-C', dir, 'log', `--format=%x00SKITTERSPEC%x00%H%n%B%x00END%x00`, '--name-only', range], {
636
+ encoding: 'utf-8',
637
+ stdio: ['ignore', 'pipe', 'pipe'],
638
+ maxBuffer: 64 * 1024 * 1024,
639
+ })
640
+ } catch (error) {
641
+ const detail = (error.stderr || '').toString().trim().split('\n')[0] || error.message
642
+ return { ok: false, error: detail }
643
+ }
644
+ const commits = []
645
+ for (const chunk of raw.split(SEP)) {
646
+ if (!chunk.trim()) continue
647
+ const endAt = chunk.indexOf('END')
648
+ if (endAt < 0) continue
649
+ const head = chunk.slice(0, endAt)
650
+ const paths = chunk
651
+ .slice(endAt + 'END'.length)
652
+ .split('\n')
653
+ .map((l) => l.trim())
654
+ .filter(Boolean)
655
+ const nl = head.indexOf('\n')
656
+ const sha = head.slice(0, nl < 0 ? head.length : nl).trim()
657
+ const body = nl < 0 ? '' : head.slice(nl + 1)
658
+ commits.push({ sha, subject: body.split('\n')[0] || '', body, paths })
659
+ }
660
+ return { ok: true, commits }
661
+ }
662
+
663
+ function specSyncReleased(dir, config, rangeArg, flags, out) {
664
+ const range = rangeArg || 'HEAD'
665
+ const read = readCommitRange(dir, range)
666
+ if (!read.ok) {
667
+ out.write(`${PROGRAM} released: ${read.error}\n`)
668
+ return 1
669
+ }
670
+ const report = kit.ticketsInRange(read.commits, {
671
+ ignorePaths: (config.release && config.release.ignorePaths) || [],
672
+ })
673
+ report.tickets = kit.nameTickets(report.tickets, listSpecs(dir, config, { urls: true }))
674
+ if (flags.json) {
675
+ out.write(JSON.stringify(report, null, 2) + '\n')
676
+ return 0
677
+ }
678
+ const lines = [`${PROGRAM} released: ${range} — ${report.total} commit(s)`]
679
+ for (const t of report.tickets) lines.push(` ${t.ref} (${t.commits} commit(s))${t.spec ? ` ${t.spec}` : ''}`)
680
+ if (!report.tickets.length) lines.push(' no referenced tickets')
681
+ if (report.unreferenced) lines.push(` ${report.unreferenced} commit(s) carry no Refs: trailer`)
682
+ if (report.ignored) lines.push(` ${report.ignored} bookkeeping commit(s) ignored`)
683
+ out.write(lines.join('\n') + '\n')
684
+ return 0
685
+ }
686
+
687
+ // ---------------------------------------------------------------------------
688
+ // `preserve <spec>` — post the issue's current description back onto the issue
689
+ // as a comment BEFORE the linking push replaces it. Every cannot-tell exits 0:
690
+ // a missing link, an unreachable Jira, an issue with no description, and an
691
+ // already-preserved report are all states to report, not failures.
692
+ const PRESERVE_HEADING = '**Original report**'
693
+ const PRESERVE_MARKER = 'preserved by skitterspec before the spec replaced this description'
694
+
695
+ async function specSyncPreserve(dir, config, specArg, flags, out) {
696
+ const snapshotDir = resolveOrExit(specArg, dir, out)
697
+ if (!snapshotDir) return 1
698
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
699
+ const identifier = specHelpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
700
+ const done = (msg) => {
701
+ out.write(`${PROGRAM} preserve: ${msg}\n`)
702
+ return 0
703
+ }
704
+ if (!identifier) return done(`${path.basename(snapshotDir)} is not linked — nothing to preserve`)
705
+
706
+ let original = null
707
+ if (flags.text) {
708
+ try {
709
+ original = fs.readFileSync(flags.text, 'utf-8')
710
+ } catch (error) {
711
+ out.write(`${PROGRAM} preserve: cannot read --text: ${error.message}\n`)
712
+ return 1
713
+ }
714
+ }
715
+
716
+ const cred = apiAdapterFor(config, flags)
717
+ const transport = kit.resolveTransport(flags, config, cred.ok)
718
+ if (transport === 'mcp' && original == null) {
719
+ return done(
720
+ 'transport = mcp — read the issue description with getJiraIssue, save it to a\n' +
721
+ ' file, and re-run with --text <file>; the comment is then posted for you\n' +
722
+ ` (or post it yourself with addCommentToJiraIssue, headed "${PRESERVE_HEADING}")`,
723
+ )
724
+ }
725
+ if (!cred.ok) return done(`no credentials — ${cred.error.split('\n')[0]}; nothing preserved`)
726
+
727
+ try {
728
+ const comments = await cred.adapter.listComments(identifier)
729
+ if (comments.some((c) => c.body && c.body.includes(PRESERVE_MARKER))) {
730
+ return done(`${identifier} — already preserved, nothing to do`)
731
+ }
732
+ if (original == null) {
733
+ const issue = await cred.adapter.readIssue(identifier)
734
+ if (!issue) return done(`${identifier} not found on Jira — nothing preserved`)
735
+ original = issue.description
736
+ }
737
+ if (original == null || !String(original).trim()) {
738
+ return done(`${identifier} has no description — nothing to preserve`)
739
+ }
740
+ await cred.adapter.createComment(identifier, `${PRESERVE_HEADING}\n\n_${PRESERVE_MARKER}_\n\n${original}`)
741
+ return done(`${identifier} — original report preserved as a comment (${String(original).length} characters kept)`)
742
+ } catch (error) {
743
+ return done(`could not reach Jira (${String(error.message).split('\n')[0]}); nothing preserved`)
744
+ }
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Credentials: `status` is a READINESS check (exit 1 with none); `set`
749
+ // prompts — the email visibly (it is not a secret; it appears on every
750
+ // issue), the token hidden; `unset` removes the site's entry.
751
+ async function specSyncCredentials(dir, config, action, flags, out) {
752
+ // The store is USER-LEVEL — a machine credential must be storable before any
753
+ // repo is configured — so the site (the store's scope key) may come from the
754
+ // flag as well as the config. SETUP.md's ordering depends on this.
755
+ const site = (flags.siteUrl || (config.jira && config.jira.siteUrl) || '').replace(/\/+$/, '')
756
+ if (!site) {
757
+ out.write(
758
+ `${PROGRAM} credentials: no site to key the entry by — pass --site-url\n` +
759
+ ` https://<your-site>.atlassian.net, or configure ${CONFIG_FILE} first\n` +
760
+ ' (/spec-jira-setup, or spec-sync init-config).\n',
761
+ )
762
+ return 1
763
+ }
764
+ const env = flags.env || process.env
765
+ const file = storePath(env)
766
+
767
+ if (action === 'status') {
768
+ const resolved = resolveCredential(config, env)
769
+ const mode = storeMode(file)
770
+ const payload = {
771
+ store: file,
772
+ mode,
773
+ site,
774
+ credentials: resolved.ok
775
+ ? { present: true, source: resolved.source, email: resolved.email, token: fingerprint(resolved.token) }
776
+ : { present: false },
777
+ }
778
+ if (flags.json) {
779
+ out.write(JSON.stringify(payload, null, 2) + '\n')
780
+ return resolved.ok ? 0 : 1
781
+ }
782
+ const lines = [`${PROGRAM} credentials:`]
783
+ lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
784
+ lines.push(` site: ${site}`)
785
+ if (resolved.ok) {
786
+ const where = resolved.source === 'env' ? `environment (${resolved.envVars.join(', ')})` : 'store'
787
+ lines.push(` email: ${resolved.email}`)
788
+ lines.push(` token: set — ${fingerprint(resolved.token)} from the ${where}`)
789
+ } else {
790
+ lines.push(' token: not set')
791
+ lines.push('')
792
+ lines.push(' Run this yourself, in your own terminal — not through an assistant:')
793
+ lines.push(' skitterspec-jira spec-sync credentials set')
794
+ }
795
+ out.write(lines.join('\n') + '\n')
796
+ return resolved.ok ? 0 : 1
797
+ }
798
+
799
+ if (action === 'set') {
800
+ if (flags.keyArgGiven) {
801
+ out.write(
802
+ `${PROGRAM} credentials: --key is not supported, on purpose.\n` +
803
+ ' A secret in the command line is visible in shell history and to `ps`.\n' +
804
+ ' Run `credentials set` with no arguments and paste at the prompt (input\n' +
805
+ ' is hidden), or pipe it: `… | credentials set --email <addr> --stdin`.\n',
806
+ )
807
+ return 1
808
+ }
809
+ let email = flags.email || ''
810
+ let token = ''
811
+ const input = flags.input || process.stdin
812
+ if (flags.stdin) {
813
+ // A TTY on stdin is positive evidence that nothing was piped: `--stdin`
814
+ // then waits for an EOF a terminal never sends. Refuse and name the two
815
+ // working forms rather than blocking forever.
816
+ if (input.isTTY) {
817
+ out.write(
818
+ `${PROGRAM} credentials: --stdin expects a pipe, but stdin is a terminal.\n` +
819
+ ' Run it without --stdin to be prompted, or pipe the token:\n' +
820
+ ' <command that prints the token> | skitterspec-jira spec-sync credentials set --email <addr> --stdin\n',
821
+ )
822
+ return 1
823
+ }
824
+ if (!email) {
825
+ out.write(`${PROGRAM} credentials: --stdin needs --email <addr> (the token is the piped half)\n`)
826
+ return 1
827
+ }
828
+ token = (await kit.readAllStdin(input)).trim()
829
+ } else {
830
+ if (!input.isTTY) {
831
+ out.write(
832
+ `${PROGRAM} credentials: not a terminal — cannot prompt.\n` +
833
+ ' Run it in your own terminal, or pipe the token with --email and --stdin.\n',
834
+ )
835
+ return 1
836
+ }
837
+ if (!email) {
838
+ const readline = require('node:readline')
839
+ email = await new Promise((resolve) => {
840
+ const rl = readline.createInterface({ input, output: out, terminal: true })
841
+ rl.question(`Jira account email for ${site}: `, (answer) => {
842
+ rl.close()
843
+ resolve(String(answer || '').trim())
844
+ })
845
+ })
846
+ }
847
+ if (!email) {
848
+ out.write(`${PROGRAM} credentials: empty email — nothing stored.\n`)
849
+ return 1
850
+ }
851
+ token = (await kit.promptHidden(`Jira API token for ${email} (hidden): `, input, out)).trim()
852
+ }
853
+ if (!token) {
854
+ out.write(`${PROGRAM} credentials: empty token — nothing stored.\n`)
855
+ return 1
856
+ }
857
+ const r = writeCredential(file, site, email, token)
858
+ if (!r.ok) {
859
+ out.write(`${PROGRAM} credentials: ${r.reason}\n`)
860
+ return 1
861
+ }
862
+ out.write(`${PROGRAM} credentials: stored for ${site} in ${r.path} (600)\n`)
863
+ return 0
864
+ }
865
+
866
+ if (action === 'unset') {
867
+ const r = removeCredential(file, site)
868
+ if (!r.ok) {
869
+ out.write(`${PROGRAM} credentials: ${r.reason}\n`)
870
+ return 1
871
+ }
872
+ out.write(
873
+ r.removed
874
+ ? `${PROGRAM} credentials: removed the entry for ${site} from ${r.path}\n`
875
+ : `${PROGRAM} credentials: nothing stored for ${site} — nothing to remove\n`,
876
+ )
877
+ return 0
878
+ }
879
+
880
+ out.write('Usage: skitterspec-jira spec-sync credentials <status|set|unset> [--email <addr>] [--stdin] [--json]\n')
881
+ return 1
882
+ }
883
+
884
+ // ---------------------------------------------------------------------------
885
+ async function specSyncInitConfig(dir, flags, out) {
886
+ const file = path.join(dir, CONFIG_FILE)
887
+ if (fs.existsSync(file) && !flags.force) {
888
+ out.write(`${PROGRAM} init-config: ${CONFIG_FILE} already exists — pass --force to overwrite\n`)
889
+ return 1
890
+ }
891
+ if (!flags.siteUrl || !flags.projectKey) {
892
+ out.write(`${PROGRAM} init-config: --site-url and --project-key are required\n`)
893
+ return 1
894
+ }
895
+ // Only the keys the operator set are written; everything else stays a
896
+ // loader default, validated through the loader's own merge so a bad value
897
+ // fails HERE rather than on the first push.
898
+ const parsed = { jira: { siteUrl: flags.siteUrl, projectKey: flags.projectKey } }
899
+ if (flags.issueType) parsed.jira.issueType = flags.issueType
900
+ if (flags.subtaskType) parsed.jira.subtaskType = flags.subtaskType
901
+ if (flags.stateNames && Object.keys(flags.stateNames).length) {
902
+ for (const bucket of Object.keys(flags.stateNames)) {
903
+ if (!LIFECYCLE_BUCKETS.includes(bucket)) {
904
+ out.write(
905
+ `${PROGRAM} init-config: --state ${bucket}=… is not a lifecycle bucket (${LIFECYCLE_BUCKETS.join('|')})\n`,
906
+ )
907
+ return 1
908
+ }
909
+ }
910
+ parsed.states = flags.stateNames
911
+ }
912
+ let merged
913
+ try {
914
+ merged = mergeConfig(defaults(), parsed)
915
+ } catch (error) {
916
+ out.write(`${PROGRAM} init-config: ${error.message}\n`)
917
+ return 1
918
+ }
919
+
920
+ // Validate the states map against the project's REAL statuses whenever the
921
+ // credentials can reach it — a map this command could have checked must not
922
+ // sail through to fail at doctor or, worse, at the first half-applied push.
923
+ // Refuses with per-bucket suggestions and writes NOTHING; it never picks a
924
+ // mapping itself, because "Done -> On Live" is a judgment only the operator
925
+ // can make (`/spec-jira-setup` is the surface that asks). Unreachable or
926
+ // credential-less is a cannot-tell: the config is still written, with one
927
+ // line saying what was not checked.
928
+ const probe = { jira: { siteUrl: merged.jira.siteUrl }, auth: merged.auth }
929
+ const cred = flags.adapter
930
+ ? { ok: true }
931
+ : resolveCredential(probe, flags.env || process.env)
932
+ if (cred.ok) {
933
+ const adapter =
934
+ flags.adapter ||
935
+ makeApiAdapter({ siteUrl: merged.jira.siteUrl, email: cred.email, token: cred.token, fetch: flags.fetch })
936
+ let names = null
937
+ try {
938
+ names = (await adapter.listIssueStates(merged.jira.projectKey)).map((st) => st.name)
939
+ } catch (error) {
940
+ out.write(`${PROGRAM} init-config: could not check the project's statuses (${String(error.message).split('\n')[0]}) — doctor --check-remote will\n`)
941
+ }
942
+ if (names) {
943
+ const missing = [...new Set(validateStates(merged, names))]
944
+ if (missing.length) {
945
+ const lines = [`${PROGRAM} init-config: refusing — nothing written`, '']
946
+ for (const { label, configured, suggestion } of stateSuggestions(merged, names)) {
947
+ lines.push(` ${label}: "${configured}" is not a status in ${merged.jira.projectKey}`)
948
+ if (suggestion) lines.push(` try --state ${label}="${suggestion}"`)
949
+ }
950
+ lines.push(
951
+ '',
952
+ ` available: ${names.join(', ')}`,
953
+ ' Map every lifecycle bucket to one of those with --state <bucket>=<name>,',
954
+ ' or run /spec-jira-setup to be walked through it.',
955
+ )
956
+ out.write(lines.join('\n') + '\n')
957
+ return 1
958
+ }
959
+ }
960
+ } else {
961
+ out.write(`${PROGRAM} init-config: states not checked against the project (no credentials) — doctor --check-remote will\n`)
962
+ }
963
+
964
+ fs.mkdirSync(path.dirname(file), { recursive: true })
965
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
966
+ out.write(`${PROGRAM} init-config: wrote ${CONFIG_FILE}\n`)
967
+ // A worktree forks from a COMMIT, so an uncommitted config is invisible in
968
+ // every spec worktree — where the sync skills actually run. Say so here,
969
+ // once, rather than letting each worktree report "Jira sync is off".
970
+ out.write(` commit it — spec worktrees fork from commits, and sync is off wherever this file is absent\n`)
971
+ return 0
972
+ }
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Doctor: readiness across the layers this provider owns. The row framework
976
+ // and the missing-is-not-broken discipline are the kit's.
977
+ async function specSyncDoctor(dir, flags, out) {
978
+ const { doctorRow: row, combineDoctorChecks } = kit
979
+ const checks = []
980
+ let loaded = null
981
+ try {
982
+ loaded = loadJiraConfig(dir)
983
+ } catch (error) {
984
+ checks.push(row('tracker', 'Jira config', 'broken', error.message, `fix ${CONFIG_FILE}`))
985
+ }
986
+ if (loaded) {
987
+ if (!loaded.present) {
988
+ checks.push(
989
+ row(
990
+ 'tracker',
991
+ 'Jira config',
992
+ 'missing',
993
+ `${CONFIG_FILE} not present — Jira sync is off`,
994
+ 'skitterspec-jira spec-sync init-config --site-url … --project-key …',
995
+ ),
996
+ )
997
+ } else if (!loaded.config.jira.siteUrl || !loaded.config.jira.projectKey) {
998
+ checks.push(row('tracker', 'Jira config', 'broken', 'siteUrl or projectKey is empty', `fix ${CONFIG_FILE}`))
999
+ } else {
1000
+ checks.push(row('tracker', 'Jira config', 'ok', `${loaded.config.jira.projectKey} @ ${loaded.config.jira.siteUrl}`))
1001
+ // A worktree forks from a COMMIT, so a config that exists only in the
1002
+ // working tree is invisible in every spec worktree — where the sync
1003
+ // skills actually run — and sync there reports itself off with no clue
1004
+ // pointing back here. The positive signal is git tracking the file;
1005
+ // every cannot-tell (no git repo, no git binary, a crashed call) stays
1006
+ // silent rather than accusing a healthy setup (negative-checks rule 4).
1007
+ // WHAT WOULD FOOL THIS: tracked-but-modified — worktrees then see the
1008
+ // last committed mapping, not this one. Lesser and not warned on.
1009
+ try {
1010
+ const { execFileSync } = require('node:child_process')
1011
+ const tracked = execFileSync('git', ['-C', dir, 'ls-files', '--', CONFIG_FILE], {
1012
+ encoding: 'utf-8',
1013
+ stdio: ['ignore', 'pipe', 'ignore'],
1014
+ }).trim()
1015
+ const inRepo = execFileSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], {
1016
+ encoding: 'utf-8',
1017
+ stdio: ['ignore', 'pipe', 'ignore'],
1018
+ }).trim()
1019
+ if (inRepo === 'true' && !tracked) {
1020
+ checks.push(
1021
+ row(
1022
+ 'tracker',
1023
+ 'Config committed',
1024
+ 'warn',
1025
+ `${CONFIG_FILE} is untracked — invisible in spec worktrees, sync is off there`,
1026
+ `git add -- ${CONFIG_FILE} && git commit`,
1027
+ ),
1028
+ )
1029
+ }
1030
+ } catch {
1031
+ // cannot tell — say nothing
1032
+ }
1033
+ }
1034
+ }
1035
+ const config = loaded && loaded.present ? loaded.config : null
1036
+ if (config && config.jira.siteUrl) {
1037
+ const cred = resolveCredential(config, flags.env || process.env)
1038
+ checks.push(
1039
+ cred.ok
1040
+ ? row('credentials', 'Credentials', 'ok', `${cred.email} (token ${fingerprint(cred.token)}, ${cred.source})`)
1041
+ : row('credentials', 'Credentials', 'missing', cred.error.split('\n')[0], 'skitterspec-jira spec-sync credentials set'),
1042
+ )
1043
+ if (flags.remoteCheck && cred.ok) {
1044
+ const adapter =
1045
+ flags.adapter ||
1046
+ makeApiAdapter({ siteUrl: config.jira.siteUrl, email: cred.email, token: cred.token, fetch: flags.fetch })
1047
+ try {
1048
+ const viewer = await adapter.readViewer()
1049
+ checks.push(row('remote', 'Jira reachable', 'ok', viewer ? `as ${viewer.name || viewer.id}` : 'yes'))
1050
+ const states = await adapter.listIssueStates(config.jira.projectKey)
1051
+ // Deduped: complete and cancelled both map to one status by default,
1052
+ // and "not in the project: Done, Done" reads as a stutter, not a fact.
1053
+ const missing = [...new Set(validateStates(config, states.map((s) => s.name)))]
1054
+ checks.push(
1055
+ missing.length
1056
+ ? row(
1057
+ 'states',
1058
+ 'Status names',
1059
+ 'broken',
1060
+ `not in the project: ${missing.join(', ')}`,
1061
+ `fix ${CONFIG_FILE} -> states (see spec-sync states)`,
1062
+ )
1063
+ : row('states', 'Status names', 'ok', 'every configured status exists'),
1064
+ )
1065
+ } catch (error) {
1066
+ checks.push(row('remote', 'Jira reachable', 'broken', String(error.message).split('\n')[0], 'check credentials and siteUrl'))
1067
+ }
1068
+ } else if (flags.remoteCheck) {
1069
+ checks.push(row('remote', 'Jira reachable', 'skipped', 'no credentials to check with'))
1070
+ }
1071
+ }
1072
+ const report = combineDoctorChecks(checks)
1073
+ if (flags.json) {
1074
+ out.write(JSON.stringify(report, null, 2) + '\n')
1075
+ return report.ok ? 0 : 1
1076
+ }
1077
+ const mark = { ok: 'ok', missing: '--', broken: 'XX', skipped: '..', warn: '!!' }
1078
+ const lines = [`${PROGRAM} doctor:`]
1079
+ for (const c of report.checks) {
1080
+ lines.push(` ${mark[c.state] || '??'} ${c.label}: ${c.detail}`)
1081
+ if (c.fix && c.state !== 'ok') lines.push(` fix: ${c.fix}`)
1082
+ }
1083
+ out.write(lines.join('\n') + '\n')
1084
+ return report.ok ? 0 : 1
1085
+ }
1086
+
1087
+ // ---------------------------------------------------------------------------
1088
+ const USAGE = `Usage: skitterspec-jira spec-sync <subcommand>
1089
+
1090
+ normalize <spec> print the local projection as JSON
1091
+ plan <spec> --workspace-states <f> what a push would change (dry, JSON)
1092
+ apply <spec> --plan <f> | --all <b> apply a plan over the API
1093
+ stamp <spec> --issue KEY [--sub r=K] write returned keys into the spec
1094
+ record <spec> write the last-pushed snapshot
1095
+ status <spec> [--remote <f>] read-only drift report
1096
+ verify <spec> --stored <f> compare a read-back against the repo
1097
+ states [--json] the project's status names
1098
+ list [--all | --state NAME] tracker listing for the project
1099
+ linked [--json] specs and their linked issues (offline)
1100
+ ref [<spec>] the Refs: trailer value for this branch
1101
+ released [<range>] tickets a commit range contains
1102
+ preserve <spec> [--text <f>] keep an adopted issue's original report
1103
+ credentials <status|set|unset> the email + API-token pair (user-level)
1104
+ init-config --site-url … --project-key … write the config
1105
+ doctor [--check-remote] [--json] readiness report
1106
+
1107
+ Not yet shipped for Jira (the Linear provider has them): assign/whoami/users,
1108
+ stage, retarget, reattach, sanitise.
1109
+ `
1110
+
1111
+ async function specSync(rest, io = {}) {
1112
+ const out = io.out || process.stdout
1113
+ const err = io.err || process.stderr
1114
+ const [sub, ...args] = rest
1115
+ let dir = io.cwd || process.cwd()
1116
+
1117
+ const { flags, positional, unknown } = parseArgs(args, sub)
1118
+ if (unknown.length) {
1119
+ out.write(kit.unknownFlagLines(PROGRAM, unknown).join('\n') + '\n')
1120
+ return 1
1121
+ }
1122
+ if (flags.dirOverride) dir = flags.dirOverride
1123
+ dir = path.resolve(dir)
1124
+ // Injection seam, alongside cwd/out/err: `env` supplies the credential
1125
+ // lookup and `adapter`/`fetch` stand in for the network, so every command is
1126
+ // exercised end to end offline. Production passes none and gets the real thing.
1127
+ flags.env = io.env || process.env
1128
+ if (io.adapter) flags.adapter = io.adapter
1129
+ if (io.fetch) flags.fetch = io.fetch
1130
+ if (io.input) flags.input = io.input
1131
+ if (io.tty !== undefined) out.isTTY = io.tty
1132
+
1133
+ if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
1134
+ if (sub === 'doctor') return specSyncDoctor(dir, flags, out)
1135
+
1136
+ let loaded
1137
+ try {
1138
+ loaded = loadJiraConfig(dir)
1139
+ } catch (error) {
1140
+ out.write(`${PROGRAM}: ${error.message}\n`)
1141
+ return 1
1142
+ }
1143
+ // Credentials are user-level, not repo-level: storing or checking them must
1144
+ // work before the repo opts in, or setup has a circular order (the smoke
1145
+ // found exactly this — `credentials set` refused on a fresh repo).
1146
+ if (sub === 'credentials') return specSyncCredentials(dir, loaded.config, positional[0], flags, out)
1147
+ if (!loaded.present) {
1148
+ // Opt-out, not an error: a repo with no jira config has nothing to sync.
1149
+ out.write(`${PROGRAM}: ${CONFIG_FILE} not present — Jira sync is off for this repo\n`)
1150
+ return 0
1151
+ }
1152
+ const config = loaded.config
1153
+
1154
+ switch (sub) {
1155
+ case 'normalize':
1156
+ return specSyncNormalize(dir, config, positional[0], out, err)
1157
+ case 'plan':
1158
+ return specSyncPlan(dir, config, positional[0], flags, out, err)
1159
+ case 'apply':
1160
+ return specSyncApply(dir, config, positional[0], flags, out, err)
1161
+ case 'stamp':
1162
+ return specSyncStamp(dir, config, positional[0], flags, out)
1163
+ case 'record':
1164
+ return specSyncRecord(dir, config, positional[0], out)
1165
+ case 'status':
1166
+ return specSyncStatus(dir, config, positional[0], flags, out, err)
1167
+ case 'verify':
1168
+ return specSyncVerify(dir, config, positional[0], flags, out)
1169
+ case 'linked':
1170
+ return specSyncLinked(dir, config, flags, out)
1171
+ case 'states':
1172
+ return specSyncStates(dir, config, flags, out)
1173
+ case 'list':
1174
+ return specSyncList(dir, config, flags, out)
1175
+ case 'ref':
1176
+ return specSyncRef(dir, config, positional[0], flags, out, err)
1177
+ case 'released':
1178
+ return specSyncReleased(dir, config, positional[0], flags, out)
1179
+ case 'preserve':
1180
+ return specSyncPreserve(dir, config, positional[0], flags, out)
1181
+ case 'credentials':
1182
+ return specSyncCredentials(dir, config, positional[0], flags, out)
1183
+ default:
1184
+ out.write(USAGE)
1185
+ return sub ? 1 : 0
1186
+ }
1187
+ }
1188
+
1189
+ // The kit's table parser, plus the one flag it cannot express portably:
1190
+ // `apply --all <bucket>` takes a value while `list --all` is bare, so `--all`
1191
+ // is resolved in a pre-pass keyed on the subcommand.
1192
+ function parseArgs(args, sub) {
1193
+ const pre = []
1194
+ let all
1195
+ for (let i = 0; i < args.length; i++) {
1196
+ if (args[i] === '--all') {
1197
+ all = sub === 'list' ? true : args[++i]
1198
+ continue
1199
+ }
1200
+ pre.push(args[i])
1201
+ }
1202
+ const table = {
1203
+ '--json': { kind: 'boolean' },
1204
+ '--dir': { kind: 'path', name: 'dirOverride' },
1205
+ '--plan': { kind: 'path' },
1206
+ '--stored': { kind: 'path' },
1207
+ '--remote': { kind: 'path' },
1208
+ '--text': { kind: 'path' },
1209
+ '--workspace-states': { kind: 'path', name: 'workspaceStates' },
1210
+ '--skip-state-check': { kind: 'boolean', name: 'skipStateCheck' },
1211
+ '--via': { kind: 'string' },
1212
+ '--force': { kind: 'boolean' },
1213
+ '--force-new': { kind: 'boolean', name: 'forceNew' },
1214
+ '--issue': { kind: 'string' },
1215
+ '--url': { kind: 'value' },
1216
+ '--sub': { kind: 'list', name: 'subs' },
1217
+ '--limit': { kind: 'number' },
1218
+ '--email': { kind: 'string' },
1219
+ '--stdin': { kind: 'boolean' },
1220
+ '--check-remote': { kind: 'boolean', name: 'remoteCheck' },
1221
+ '--site-url': { kind: 'string', name: 'siteUrl' },
1222
+ '--project-key': { kind: 'string', name: 'projectKey' },
1223
+ '--issue-type': { kind: 'string', name: 'issueType' },
1224
+ '--subtask-type': { kind: 'string', name: 'subtaskType' },
1225
+ '--state': {
1226
+ kind: 'custom',
1227
+ handle: (flags, value) => {
1228
+ // Two readings, kept apart: `init-config --state complete=Shipped` is
1229
+ // a bucket=name PAIR; `list --state "In Progress"` a bare status name.
1230
+ const raw = String(value || '').trim()
1231
+ const [bucket, ...rest] = raw.split('=')
1232
+ if (rest.length) {
1233
+ flags.stateNames = { ...(flags.stateNames || {}), [bucket.trim()]: rest.join('=').trim() }
1234
+ }
1235
+ if (raw) (flags.stateArgs = flags.stateArgs || []).push(rest.length ? rest.join('=').trim() : raw)
1236
+ },
1237
+ },
1238
+ '--key': {
1239
+ kind: 'custom',
1240
+ handle: (flags) => {
1241
+ // Consumed and DELIBERATELY DISCARDED — a secret in argv is refused,
1242
+ // but it must still be swallowed or it would print back in usage.
1243
+ flags.keyArgGiven = true
1244
+ },
1245
+ },
1246
+ }
1247
+ const parsed = kit.parseFlags(pre, table)
1248
+ if (all !== undefined) parsed.flags.all = all
1249
+ return parsed
1250
+ }
1251
+
1252
+ module.exports = { specSync, USAGE, stateCheckFailure, applyOps, PRESERVE_HEADING, PRESERVE_MARKER }