@skitterbyte/skitterspec-linear 10.6.0 → 10.7.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 (34) hide show
  1. package/assets/claude-md-section.md +19 -9
  2. package/assets/commands/spec-connect.md +13 -0
  3. package/assets/commands/spec-live.md +14 -0
  4. package/assets/core/ci-stages.md +110 -0
  5. package/assets/core/env.config.md +18 -0
  6. package/assets/core/linear.config.json.example +3 -0
  7. package/assets/core/linear.config.md +46 -0
  8. package/assets/rules/commit-trailers.md +37 -5
  9. package/assets/rules/spec-planning.md +17 -3
  10. package/assets/skills/spec/SKILL.md +20 -0
  11. package/assets/skills/spec-bug/SKILL.md +16 -2
  12. package/assets/skills/spec-cancel/SKILL.md +9 -0
  13. package/assets/skills/spec-complete/SKILL.md +12 -1
  14. package/assets/skills/spec-go/SKILL.md +10 -8
  15. package/assets/skills/spec-hotfix/SKILL.md +17 -3
  16. package/assets/skills/spec-linear-setup/SKILL.md +38 -2
  17. package/assets/skills/spec-status/SKILL.md +1 -0
  18. package/assets/skills/spec-sync/SKILL.md +1 -0
  19. package/assets/skills/spec-to-main/SKILL.md +2 -1
  20. package/bin/skitterspec-linear.js +10 -0
  21. package/package.json +1 -1
  22. package/src/cli.js +176 -39
  23. package/src/env/config.js +19 -0
  24. package/src/env/teardown.js +62 -4
  25. package/src/init.js +120 -2
  26. package/src/vendor/linear/cli-sync.js +402 -33
  27. package/src/vendor/linear/config.js +91 -1
  28. package/src/vendor/linear/doctor.js +67 -1
  29. package/src/vendor/linear/released.js +85 -1
  30. package/src/vendor/sync-core/index.js +4 -1
  31. package/src/vendor/sync-core/src/compare.js +59 -3
  32. package/src/vendor/sync-core/src/normalize.js +65 -2
  33. package/assets/skills/spec-connect/SKILL.md +0 -59
  34. package/assets/skills/spec-live/SKILL.md +0 -73
@@ -25,7 +25,7 @@ const path = require('node:path')
25
25
 
26
26
  const { BUCKETS, findSpecFolder, branchFor, splitPrefix, currentBranch } = require('../../env/resolve.js')
27
27
  const { loadEnvConfig } = require('../../env/config.js')
28
- const { ticketsInRange } = require('./released.js')
28
+ const { ticketsInRange, partitionStageMoves, stageOrderWarning } = require('./released.js')
29
29
  const { execFileSync } = require('node:child_process')
30
30
  const {
31
31
  normalizeLocal,
@@ -38,6 +38,8 @@ const {
38
38
  planChanges,
39
39
  isEmptyPlan,
40
40
  remoteWorkflowState,
41
+ remoteStage,
42
+ LADDER_ORIGIN_BUCKET,
41
43
  validateStates,
42
44
  stateSuggestions,
43
45
  lintPhases,
@@ -52,7 +54,15 @@ const {
52
54
  dirtyPaths,
53
55
  } = require('../sync-core')
54
56
 
55
- const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
57
+ const {
58
+ loadLinearConfig,
59
+ mergeConfig,
60
+ defaults: configDefaults,
61
+ CONFIG_FILE,
62
+ LIFECYCLE_BUCKETS,
63
+ releaseStages,
64
+ stageFor,
65
+ } = require('./config.js')
56
66
  const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
57
67
  const { runChecks } = require('./doctor.js')
58
68
  const {
@@ -326,8 +336,8 @@ function stateCheckFailure(config, flags) {
326
336
  // Say what IS available, and what to use instead. "Done is not a state" sends
327
337
  // you to the Linear UI to go and look; naming the replacement does not.
328
338
  const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
329
- for (const { bucket, configured, suggestion } of stateSuggestions(config, list)) {
330
- lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
339
+ for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
340
+ lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
331
341
  if (suggestion) lines.push(` use "${suggestion}" instead`)
332
342
  }
333
343
  lines.push(
@@ -477,7 +487,15 @@ function specSyncStatus(dir, config, specArg, flags, out) {
477
487
  const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
478
488
  const rState = remoteWorkflowState(remote, config)
479
489
  const lState = projection.status
480
- if (rState && lState && rState !== lState) {
490
+ // A declared deployment stage is POSITION, not disagreement: the spec is
491
+ // complete and the pipeline has moved it on. Reporting that as drift would
492
+ // accuse every deployed spec, for as long as it sits in the pipeline — and
493
+ // "repo wins on next push" would be a lie, since a finished spec's state no
494
+ // longer re-pushes (see compare.js `issueChanges`).
495
+ const stage = remoteStage(remote, config)
496
+ if (stage) {
497
+ lines.push(` stage: Linear is at "${stage.state}" (release stage "${stage.key}", past ${lState})`)
498
+ } else if (rState && lState && rState !== lState) {
481
499
  lines.push(` drift: Linear workflow-state is "${rState}" but the spec is "${lState}" (repo wins on next push)`)
482
500
  } else {
483
501
  lines.push(' drift: none — Linear workflow-state matches the spec')
@@ -616,6 +634,7 @@ async function specSyncDoctor(dir, flags, out) {
616
634
  state.mcp = read.facts
617
635
  }
618
636
  if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
637
+ if (state.remote && state.remote.issueStates) state.ladder.workspaceStates = state.remote.issueStates
619
638
 
620
639
  const report = runChecks(state)
621
640
 
@@ -635,7 +654,7 @@ async function specSyncDoctor(dir, flags, out) {
635
654
  // Everything not `ok`/`skipped` needs a human's attention — including a
636
655
  // `missing` one, which is why the count and the EXIT CODE differ: a declined
637
656
  // opt-in is worth reporting but must not fail the run (see `runChecks`).
638
- const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
657
+ const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing' || c.state === 'warn').length
639
658
  lines.push('')
640
659
  lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
641
660
 
@@ -725,7 +744,20 @@ async function checkRemote(state, flags) {
725
744
  }
726
745
  }
727
746
 
728
- return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project }
747
+ // Only fetched when a ladder is declared: the rows that do not need state
748
+ // types must not pay for a call that answers a question nobody asked.
749
+ let issueStates = null
750
+ if (releaseStages(state._config).length && typeof adapter.listIssueStates === 'function') {
751
+ try {
752
+ issueStates = await adapter.listIssueStates(state.tracker.teamId)
753
+ } catch {
754
+ // Unexamined, on the same rule as the project read: a transport failure is
755
+ // not evidence about the ladder's shape.
756
+ issueStates = null
757
+ }
758
+ }
759
+
760
+ return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project, issueStates }
729
761
  }
730
762
 
731
763
  /**
@@ -847,6 +879,9 @@ function gatherState(dir, flags) {
847
879
  }
848
880
 
849
881
  state._config = config
882
+ // Declared here, checked only if `--check-remote` fetches the state TYPES the
883
+ // shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
884
+ state.ladder = { stages: releaseStages(config) }
850
885
  return state
851
886
  }
852
887
 
@@ -877,7 +912,44 @@ function countSkills(dir) {
877
912
  * `scripts/`, which is not shipped. The chosen range is always printed, so a
878
913
  * wrong guess is visible rather than silent.
879
914
  */
880
- async function specSyncReleased(dir, config, rangeArg, flags, out) {
915
+ /**
916
+ * The base of a two-dot range that git cannot actually reach from the head, or
917
+ * null when the range is trustworthy (or is not a shape we can check).
918
+ *
919
+ * Deliberately NOT "is this repository shallow?". A shallow clone deep enough to
920
+ * contain the base has a COMPLETE range and is perfectly healthy — refusing it
921
+ * would accuse a working setup, which is the failure the stays-silent test in
922
+ * `cli-shallow-range.test.js` pins down. `merge-base --is-ancestor` asks the
923
+ * narrower question the range actually depends on.
924
+ *
925
+ * A range that is not `A..B` (a bare ref, a `...` symmetric difference, a
926
+ * revision expression) has no single base to check, so it is left alone: a guard
927
+ * that cannot see the shape must not pretend to have checked it. The shallow
928
+ * warning in the caller covers that case instead.
929
+ */
930
+ function unreachableBase(git, range) {
931
+ const text = String(range || '')
932
+ if (text.includes('...')) return null
933
+ const parts = text.split('..')
934
+ if (parts.length !== 2) return null
935
+ const [base, head] = [parts[0].trim(), parts[1].trim() || 'HEAD']
936
+ if (!base) return null
937
+ // `is-ancestor` answers with its EXIT CODE, so a null return (non-zero) is the
938
+ // "not an ancestor" answer, not a missing tool. A base git cannot resolve at
939
+ // all falls through to the existing `git log` refusal, which names it better.
940
+ if (git(['rev-parse', '-q', '--verify', `${base}^{commit}`]) === null) return null
941
+ if (git(['rev-parse', '-q', '--verify', `${head}^{commit}`]) === null) return null
942
+ return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
943
+ }
944
+
945
+ /**
946
+ * Resolve a commit range and read it, shared by `released` (which reports on it)
947
+ * and `stage` (which acts on it). Both must agree on what a release contains,
948
+ * and a second copy of this would be a second answer.
949
+ *
950
+ * @returns {{range:string, commits:Array}|{error:string[]}}
951
+ */
952
+ function readCommitRange(dir, rangeArg, verb) {
881
953
  const git = (argv) => {
882
954
  try {
883
955
  return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()
@@ -890,21 +962,40 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
890
962
  if (!range) {
891
963
  const tag = (git(['describe', '--tags', '--abbrev=0']) || '').trim()
892
964
  if (!tag) {
893
- out.write(
894
- 'spec-sync released: no range given and no tag to default from.\n' +
895
- ' Pass one explicitly, e.g. spec-sync released v1.2.0..HEAD\n',
896
- )
897
- return 1
965
+ return {
966
+ error: [
967
+ `spec-sync ${verb}: no range given and no tag to default from.`,
968
+ ` Pass one explicitly, e.g. spec-sync ${verb} v1.2.0..HEAD`,
969
+ ],
970
+ }
898
971
  }
899
972
  range = `${tag}..HEAD`
900
973
  }
901
974
 
975
+ // WHAT WOULD FOOL THIS: `git log` reports a TRUNCATED history as a successful
976
+ // one. On a shallow clone whose depth stops before the base, `git log
977
+ // base..HEAD` exits 0 and returns only the commits it happens to hold, so the
978
+ // range silently loses tickets. The tag itself resolves — `rev-parse --verify`
979
+ // passes — so "does the base exist?" is not the question. The question is
980
+ // whether the base is in THIS history, which is what `merge-base` answers.
981
+ const unreachable = unreachableBase(git, range)
982
+ if (unreachable) {
983
+ return {
984
+ error: [
985
+ `spec-sync ${verb}: refusing — "${unreachable}" is not reachable in this clone's history.`,
986
+ ' The range would silently report fewer commits than it contains.',
987
+ ' This is usually a shallow clone: fetching tags makes the tag resolve',
988
+ ' without deepening the history. Fetch full history (CI: fetch-depth: 0),',
989
+ ' or pass a range whose base is present.',
990
+ ],
991
+ }
992
+ }
993
+
902
994
  // NUL-delimited so a subject or body containing the separator cannot split a
903
995
  // record; RS between commits for the same reason.
904
996
  const raw = git(['log', '--format=%H%x00%s%x00%b%x1e', range])
905
997
  if (raw === null) {
906
- out.write(`spec-sync released: git could not resolve the range "${range}".\n`)
907
- return 1
998
+ return { error: [`spec-sync ${verb}: git could not resolve the range "${range}".`] }
908
999
  }
909
1000
 
910
1001
  const commits = raw
@@ -916,6 +1007,17 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
916
1007
  return { sha, subject, body: rest.join('\x00') }
917
1008
  })
918
1009
 
1010
+ return { range, commits }
1011
+ }
1012
+
1013
+ async function specSyncReleased(dir, config, rangeArg, flags, out) {
1014
+ const read = readCommitRange(dir, rangeArg, 'released')
1015
+ if (read.error) {
1016
+ out.write(read.error.join('\n') + '\n')
1017
+ return 1
1018
+ }
1019
+ const { range, commits } = read
1020
+
919
1021
  const report = ticketsInRange(commits)
920
1022
 
921
1023
  // Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
@@ -964,13 +1066,216 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
964
1066
  }
965
1067
 
966
1068
  /**
967
- * `spec-sync ref [--json]` — the ticket this branch's work belongs to.
1069
+ * `spec-sync stage <key> [<range>] [--apply] [--json]` — move a release's
1070
+ * tickets onto a declared deployment rung.
1071
+ *
1072
+ * The counterpart to `released`, which reports and cannot write. This is the
1073
+ * write, and it is deliberately a separate verb: someone typing `released` in a
1074
+ * terminal should never be able to move anything.
1075
+ *
1076
+ * **Dry run is the default.** `--apply` is required to touch Linear, and the
1077
+ * resolved range, the target state and the full plan print either way — a wrong
1078
+ * range is then visible before it is acted on rather than after.
1079
+ *
1080
+ * What it refuses to move is as important as what it moves: see
1081
+ * `partitionStageMoves`. Every excluded ref is named with its reason, because a
1082
+ * silent exclusion and a successful move look identical in a pipeline log.
1083
+ */
1084
+ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
1085
+ const stages = releaseStages(config)
1086
+ if (!stages.length) {
1087
+ out.write(
1088
+ 'spec-sync stage: no deployment ladder is declared.\n' +
1089
+ ` Add release.stages to ${CONFIG_FILE} — see its docs for the shape.\n`,
1090
+ )
1091
+ return 1
1092
+ }
1093
+ if (!stageKey) {
1094
+ out.write(`spec-sync stage: no stage given. Declared: ${stages.map((s) => s.key).join(', ')}\n`)
1095
+ return 1
1096
+ }
1097
+ const stage = stageFor(config, stageKey)
1098
+ if (!stage) {
1099
+ out.write(
1100
+ `spec-sync stage: no stage named ${JSON.stringify(stageKey)}.\n` +
1101
+ ` Declared: ${stages.map((s) => s.key).join(', ')}\n`,
1102
+ )
1103
+ return 1
1104
+ }
1105
+
1106
+ const read = readCommitRange(dir, rangeArg, 'stage')
1107
+ if (read.error) {
1108
+ out.write(read.error.join('\n') + '\n')
1109
+ return 1
1110
+ }
1111
+ const { range, commits } = read
1112
+
1113
+ const report = ticketsInRange(commits)
1114
+ const teamKey = (config.linear && config.linear.teamKey) || ''
1115
+ const parts = partitionStageMoves({
1116
+ tickets: report.tickets,
1117
+ teamKey,
1118
+ specs: listSpecs(dir, config),
1119
+ cededBucket: LADDER_ORIGIN_BUCKET,
1120
+ })
1121
+
1122
+ const key = resolveApiKey(config, flags.env || process.env)
1123
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
1124
+ const applying = Boolean(flags.apply)
1125
+
1126
+ if (applying && transport !== 'api') {
1127
+ out.write(
1128
+ `spec-sync stage: refusing to apply over ${transport}.\n` +
1129
+ ` ${key.ok ? '--via api is required' : key.error}\n` +
1130
+ ' Re-run without --apply for the plan.\n',
1131
+ )
1132
+ return 1
1133
+ }
1134
+
1135
+ // Read each movable issue BEFORE any write: it yields the id the update needs,
1136
+ // its title for the report, and its current state for the order warning. A ref
1137
+ // that cannot be read is dropped from the move rather than guessed at.
1138
+ const adapter = transport === 'api' && key.ok ? flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch }) : null
1139
+ const moves = []
1140
+ const unreadable = []
1141
+ for (const ticket of parts.movable) {
1142
+ if (!adapter) {
1143
+ moves.push({ ...ticket, title: null, from: null, warning: null })
1144
+ continue
1145
+ }
1146
+ let issue = null
1147
+ try {
1148
+ issue = await adapter.readIssue(ticket.ref)
1149
+ } catch {
1150
+ issue = null
1151
+ }
1152
+ if (!issue || !issue.id) {
1153
+ unreadable.push(ticket)
1154
+ continue
1155
+ }
1156
+ const from = issue.state && issue.state.name ? issue.state.name : null
1157
+ moves.push({
1158
+ ...ticket,
1159
+ id: issue.id,
1160
+ title: issue.title || null,
1161
+ from,
1162
+ warning: stageOrderWarning(stages, from, stage.key, Object.values(config.states || {})),
1163
+ })
1164
+ }
1165
+
1166
+ let moved = []
1167
+ let failed = []
1168
+ if (applying && moves.length) {
1169
+ let states
1170
+ try {
1171
+ states = await adapter.listIssueStates((config.linear && config.linear.teamId) || null)
1172
+ } catch (error) {
1173
+ out.write(`spec-sync stage: could not read the workspace states — ${error.message}\n`)
1174
+ return 1
1175
+ }
1176
+ // Resolve the target id BEFORE the first write, mirroring `apply`: a state
1177
+ // name the workspace lacks must fail with nothing moved, not halfway through.
1178
+ const target = states.find((st) => st && st.name && st.name.toLowerCase().trim() === stage.state.toLowerCase().trim())
1179
+ if (!target) {
1180
+ out.write(
1181
+ `spec-sync stage: refusing — no issue state named ${JSON.stringify(stage.state)} in this workspace.\n` +
1182
+ ` available: ${states.map((st) => st.name).join(', ') || '(none reported)'}\n` +
1183
+ ' Linear silently ignores an unknown state, so this would have moved nothing.\n',
1184
+ )
1185
+ return 1
1186
+ }
1187
+ for (const move of moves) {
1188
+ try {
1189
+ await adapter.updateIssue(move.id, { stateId: target.id })
1190
+ moved.push(move)
1191
+ } catch (error) {
1192
+ failed.push({ ...move, error: error.message })
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ if (flags.json) {
1198
+ out.write(
1199
+ JSON.stringify(
1200
+ {
1201
+ range,
1202
+ stage: { key: stage.key, state: stage.state },
1203
+ applied: applying,
1204
+ moves: moves.map((m) => ({ ref: m.ref, title: m.title, from: m.from, warning: m.warning })),
1205
+ moved: moved.map((m) => m.ref),
1206
+ failed: failed.map((m) => ({ ref: m.ref, error: m.error })),
1207
+ skipped: {
1208
+ foreign: parts.foreign.map((t) => t.ref),
1209
+ unlinked: parts.unlinked.map((t) => t.ref),
1210
+ unfinished: parts.unfinished.map((t) => ({ ref: t.ref, bucket: t.bucket })),
1211
+ unreadable: unreadable.map((t) => t.ref),
1212
+ },
1213
+ unreferencedCommits: report.unreferenced,
1214
+ totalCommits: report.total,
1215
+ },
1216
+ null,
1217
+ 2,
1218
+ ) + '\n',
1219
+ )
1220
+ return failed.length ? 1 : 0
1221
+ }
1222
+
1223
+ const lines = [`spec-sync stage: ${stage.key} -> "${stage.state}" (${range})`, '']
1224
+ if (!moves.length) {
1225
+ lines.push(applying ? ' moved nothing' : ' would move nothing')
1226
+ } else {
1227
+ lines.push(` ${applying ? 'moved' : 'would move'} ${applying ? moved.length : moves.length} ticket(s)`)
1228
+ for (const m of moves) {
1229
+ const title = m.title ? ` ${m.title}` : ''
1230
+ lines.push(` ${m.ref}${title}`)
1231
+ if (m.warning) lines.push(` warning: ${m.warning}`)
1232
+ }
1233
+ }
1234
+ for (const { label, items } of [
1235
+ { label: 'not team ' + (teamKey || '(unset)'), items: parts.foreign.map((t) => t.ref) },
1236
+ { label: 'no spec in this repo claims it', items: parts.unlinked.map((t) => t.ref) },
1237
+ {
1238
+ label: 'spec not complete — push still owns its state',
1239
+ items: parts.unfinished.map((t) => `${t.ref} (${t.bucket})`),
1240
+ },
1241
+ { label: 'could not be read from Linear', items: unreadable.map((t) => t.ref) },
1242
+ ]) {
1243
+ if (items.length) lines.push(` skipped ${items.length} — ${label}: ${items.join(', ')}`)
1244
+ }
1245
+ for (const f of failed) lines.push(` FAILED ${f.ref}: ${f.error}`)
1246
+ lines.push('')
1247
+ // Said even when zero, for the reason `released` says it: a chore commit
1248
+ // legitimately carries no ref and a MISSED trailer looks identical, so silence
1249
+ // would read as "every commit is accounted for".
1250
+ lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
1251
+ if (!applying) lines.push(' dry run — pass --apply to move them')
1252
+ out.write(lines.join('\n') + '\n')
1253
+ return failed.length ? 1 : 0
1254
+ }
1255
+
1256
+ /**
1257
+ * `spec-sync ref [<spec>] [--json]` — the ticket a commit's work belongs to.
968
1258
  *
969
1259
  * Exists so neither a person nor a model has to go spelunking for the id when
970
1260
  * writing a commit: `Refs: $(spec-sync ref)`. With a fast-forward-only history
971
1261
  * the commit message is the ONLY place a ticket survives into the range a
972
1262
  * release scans — branch names never reach it.
973
1263
  *
1264
+ * **Bare, it answers from the branch**, which is the same thing as "the ticket
1265
+ * this commit belongs to" only while you are committing that branch's own
1266
+ * implementation work. `/spec` requires no particular branch, so authoring a
1267
+ * backlog spec part-way through another spec makes the two diverge: the commit
1268
+ * is entirely the new spec's and the branch answer is a wrong ref stamped on it.
1269
+ * **Naming the spec resolves it directly** — the branch is then not consulted at
1270
+ * all, so it also works from `main`.
1271
+ *
1272
+ * The default deliberately stays branch-derived rather than inferring the spec
1273
+ * from staged paths. Inference would change what gets stamped based on a lookup
1274
+ * that `git commit -a`, partial staging, or a spec touching shared code each
1275
+ * blind — silently, and in the destructive direction (a plausible wrong ref
1276
+ * beats no ref for looking correct). An explicit override cannot be blinded, and
1277
+ * needs no answer for mixed staging.
1278
+ *
974
1279
  * The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
975
1280
  * so it is computed by running `branchFor` over each spec and matching, rather
976
1281
  * than by re-deriving the pattern here. A second implementation of the naming
@@ -979,11 +1284,25 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
979
1284
  * **Every no-ref case prints nothing on stdout** and exits non-zero. A commit on
980
1285
  * `main`, or on a spec kept deliberately local, has no ticket — and a command
981
1286
  * that wrote an error message to stdout would see a shell splice it straight
982
- * into the commit body via `$(…)`.
1287
+ * into the commit body via `$(…)`. That contract covers the named form too: an
1288
+ * unknown spec name must FAIL rather than quietly falling back to the branch,
1289
+ * which would turn a typo into a confidently wrong ref.
983
1290
  */
984
- function specSyncRef(dir, config, flags, out, err) {
1291
+ function specSyncRef(dir, config, specArg, flags, out, err) {
985
1292
  const say = (msg) => err.write(`spec-sync ref: ${msg}\n`)
986
1293
 
1294
+ // Shared tail: an unlinked spec is a no-ref case like any other, so it keeps
1295
+ // stdout empty whichever form asked.
1296
+ const emitRef = (spec, branch) => {
1297
+ const identifier = linkedIdentifier(path.join(spec.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
1298
+ if (!identifier) {
1299
+ say(`${spec.folder} is not linked to Linear — /spec-push to mirror it`)
1300
+ return 1
1301
+ }
1302
+ out.write(flags.json ? JSON.stringify({ ref: identifier, spec: spec.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
1303
+ return 0
1304
+ }
1305
+
987
1306
  const git = (argv) => {
988
1307
  try {
989
1308
  return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
@@ -992,6 +1311,18 @@ function specSyncRef(dir, config, flags, out, err) {
992
1311
  }
993
1312
  }
994
1313
  const branch = currentBranch(git)
1314
+
1315
+ // The named form answers about the SPEC, so a missing branch is irrelevant to
1316
+ // it — resolve before the branch is required at all.
1317
+ if (specArg) {
1318
+ const named = findSpecFolder(specArg, dir)
1319
+ if (!named) {
1320
+ say(`no spec folder named "${specArg}" in specs/ — check the name`)
1321
+ return 1
1322
+ }
1323
+ return emitRef(named, branch || null)
1324
+ }
1325
+
995
1326
  if (!branch) {
996
1327
  say('not on a git branch (detached HEAD, or not a git repository)')
997
1328
  return 1
@@ -1023,14 +1354,7 @@ function specSyncRef(dir, config, flags, out, err) {
1023
1354
  return 1
1024
1355
  }
1025
1356
 
1026
- const identifier = linkedIdentifier(path.join(match.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
1027
- if (!identifier) {
1028
- say(`${match.folder} is not linked to Linear — /spec-push to mirror it`)
1029
- return 1
1030
- }
1031
-
1032
- out.write(flags.json ? JSON.stringify({ ref: identifier, spec: match.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
1033
- return 0
1357
+ return emitRef(match, branch)
1034
1358
  }
1035
1359
 
1036
1360
  /**
@@ -1250,13 +1574,41 @@ async function specSyncStates(dir, config, flags, out) {
1250
1574
  }
1251
1575
  if (flags.json) {
1252
1576
  // The bare array `--workspace-states` takes, so this can be piped into it.
1577
+ // Deliberately NOT widened to carry the configured vocabulary: every caller
1578
+ // pipes this straight into that flag, and an object here would break them.
1253
1579
  out.write(JSON.stringify(names, null, 2) + '\n')
1254
1580
  return 0
1255
1581
  }
1256
- out.write(`spec-sync states: transport = api\n ${names.join(', ')}\n`)
1582
+ const lines = ['spec-sync states: transport = api', ` workspace: ${names.join(', ')}`]
1583
+ lines.push(...configuredVocabularyLines(config, names))
1584
+ out.write(lines.join('\n') + '\n')
1257
1585
  return 0
1258
1586
  }
1259
1587
 
1588
+ // The state names this project's config points at, shown against what the
1589
+ // workspace actually has — the bucket map, then the deployment ladder in its
1590
+ // declared order. One command then shows the whole vocabulary, instead of the
1591
+ // ladder living only in a file nobody re-reads.
1592
+ //
1593
+ // A name the workspace lacks is flagged here but nothing is refused: `states`
1594
+ // is a report. The push path is where that is fatal.
1595
+ function configuredVocabularyLines(config, names) {
1596
+ const have = new Set((names || []).map((n) => String(n).toLowerCase().trim()))
1597
+ const mark = (name) => (have.has(String(name).toLowerCase().trim()) ? '' : ' <- not in the workspace')
1598
+ const lines = ['', ' configured:']
1599
+ for (const [bucket, name] of Object.entries((config && config.states) || {})) {
1600
+ lines.push(` states.${bucket}: ${name}${mark(name)}`)
1601
+ }
1602
+ const stages = releaseStages(config)
1603
+ if (stages.length) {
1604
+ lines.push(` release.stages: ${stages.length} rung(s), in order`)
1605
+ stages.forEach((stage, i) => {
1606
+ lines.push(` ${i + 1}. ${stage.key} -> ${stage.state}${mark(stage.state)}`)
1607
+ })
1608
+ }
1609
+ return lines
1610
+ }
1611
+
1260
1612
  /**
1261
1613
  * `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
1262
1614
  *
@@ -1729,6 +2081,10 @@ function specSyncInitConfig(dir, flags, out) {
1729
2081
  if (flags.hotfixLabels.length) intake.hotfixLabels = flags.hotfixLabels
1730
2082
  if (Object.keys(intake).length) draft.intake = intake
1731
2083
  if (Object.keys(flags.stateNames).length) draft.states = { ...flags.stateNames }
2084
+ // The ladder, in the order given. `mergeConfig` below is what validates the
2085
+ // shape — a blank key or a duplicate fails there, in one place, rather than
2086
+ // being re-checked here and drifting from the loader.
2087
+ if (flags.stages.length) draft.release = { stages: flags.stages }
1732
2088
 
1733
2089
  for (const bucket of Object.keys(flags.stateNames)) {
1734
2090
  if (!LIFECYCLE_BUCKETS.includes(bucket)) {
@@ -1768,9 +2124,11 @@ function specSyncInitConfig(dir, flags, out) {
1768
2124
  const missing = validateStates(effective, workspace)
1769
2125
  if (missing.length) {
1770
2126
  const lines = ['spec-sync init-config: refusing — configured state name(s) not in the workspace', '']
1771
- for (const { bucket, configured, suggestion } of stateSuggestions(effective, workspace)) {
1772
- lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
1773
- if (suggestion) lines.push(` pass --state ${bucket}="${suggestion}"`)
2127
+ for (const { bucket, label, configured, suggestion } of stateSuggestions(effective, workspace)) {
2128
+ lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
2129
+ // `--state <bucket>=<name>` only addresses the bucket map; a ladder rung
2130
+ // is edited in the config, and never carries a suggestion anyway.
2131
+ if (suggestion && bucket) lines.push(` pass --state ${bucket}="${suggestion}"`)
1774
2132
  }
1775
2133
  lines.push(
1776
2134
  '',
@@ -2083,10 +2441,13 @@ async function specSync(rest, io = {}) {
2083
2441
  // after the loop.
2084
2442
  const unknownFlags = []
2085
2443
  const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
2086
- mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
2444
+ mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null, stages: [] }
2087
2445
  for (let i = 0; i < args.length; i++) {
2088
2446
  if (args[i] === '--dir') dir = path.resolve(args[++i])
2089
2447
  else if (args[i] === '--json') flags.json = true
2448
+ // `stage` writes only on --apply: the dry run is the default so a wrong
2449
+ // range is seen before it is acted on, not after.
2450
+ else if (args[i] === '--apply') flags.apply = true
2090
2451
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
2091
2452
  else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
2092
2453
  else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
@@ -2123,6 +2484,11 @@ async function specSync(rest, io = {}) {
2123
2484
  // actually renamed get written; the rest keep the defaults.
2124
2485
  const [bucket, ...rest] = String(args[++i] || '').split('=')
2125
2486
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
2487
+ } else if (args[i] === '--stage') {
2488
+ // `--stage test="On Test"`, repeatable. Order matters and is the order
2489
+ // given, so the ladder is written exactly as the operator listed it.
2490
+ const [key, ...rest] = String(args[++i] || '').split('=')
2491
+ flags.stages.push({ key: String(key).trim(), state: rest.join('=').trim() })
2126
2492
  } else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
2127
2493
  else if (args[i].startsWith('--')) unknownFlags.push(args[i])
2128
2494
  else positional.push(args[i])
@@ -2200,8 +2566,10 @@ async function specSync(rest, io = {}) {
2200
2566
  return (await specSyncStates(dir, config, flags, out)) || 0
2201
2567
  case 'released':
2202
2568
  return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
2569
+ case 'stage':
2570
+ return (await specSyncStage(dir, config, positional[0], positional[1], flags, out)) || 0
2203
2571
  case 'ref':
2204
- return specSyncRef(dir, config, flags, out, err) || 0
2572
+ return specSyncRef(dir, config, positional[0], flags, out, err) || 0
2205
2573
  case 'retarget':
2206
2574
  return (await specSyncRetarget(dir, config, flags, out)) || 0
2207
2575
  case 'apply':
@@ -2224,8 +2592,9 @@ async function specSync(rest, io = {}) {
2224
2592
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
2225
2593
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
2226
2594
  ' skitterspec spec-sync linked [--json]\n' +
2227
- ' skitterspec spec-sync ref [--json]\n' +
2595
+ ' skitterspec spec-sync ref [<spec>] [--json]\n' +
2228
2596
  ' skitterspec spec-sync released [<range>] [--json]\n' +
2597
+ ' skitterspec spec-sync stage <key> [<range>] [--apply] [--json]\n' +
2229
2598
  ' skitterspec spec-sync retarget [--yes]\n' +
2230
2599
  ' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
2231
2600
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +