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