@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
@@ -20,6 +20,7 @@
20
20
  * intake: { label, bugLabels, hotfixLabels },
21
21
  * mapping: { specFolder, phases, tasks },
22
22
  * states: { backlog, "in-progress", complete, cancelled },
23
+ * release: { stages: [{ key, state }] },
23
24
  * snapshot: { overviewFile },
24
25
  * branch: { pattern },
25
26
  * sync: {
@@ -78,6 +79,19 @@ const TRANSPORTS = Object.freeze(['api', 'mcp'])
78
79
  // nothing secret is ever written to the repo.
79
80
  const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
80
81
 
82
+ // The project's OWN deployment ladder: where a ticket goes AFTER its spec is
83
+ // complete — deployed to test, approved for demo, live in prod. Deliberately an
84
+ // open, ORDERED list in the project's vocabulary rather than a fixed set: one
85
+ // team's `On Test`/`Ready for Demo` is another's `staging`, and most projects
86
+ // have none at all. Empty (the default) means the project declared no ladder,
87
+ // and every stage-aware path is simply unused.
88
+ //
89
+ // It is NOT keyed by lifecycle bucket, unlike `states` and `mapping.phases`.
90
+ // A deployment stage is a fact about an ENVIRONMENT, and no folder under
91
+ // `specs/` can ever derive one — which is why it must not join
92
+ // LIFECYCLE_BUCKETS.
93
+ const DEFAULT_RELEASE_STAGES = Object.freeze([])
94
+
81
95
  const DEFAULT_CONFIG = Object.freeze({
82
96
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
83
97
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -111,6 +125,10 @@ const DEFAULT_CONFIG = Object.freeze({
111
125
  cancelled: 'Canceled',
112
126
  }),
113
127
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
128
+ // See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
129
+ // for reporting and doctor's ladder check, and deliberately NOT enforced — a
130
+ // rollback from test and a hotfix going straight to prod are both legitimate.
131
+ release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES }),
114
132
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
115
133
  // `keyEnv` names the env var holding the personal API key. It is a NAME, not a
116
134
  // key: putting the secret itself here would commit it.
@@ -162,6 +180,7 @@ function defaults() {
162
180
  mapping: { ...DEFAULT_CONFIG.mapping },
163
181
  states: { ...DEFAULT_CONFIG.states },
164
182
  snapshot: { ...DEFAULT_CONFIG.snapshot },
183
+ release: { stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })) },
165
184
  branch: { ...DEFAULT_CONFIG.branch },
166
185
  auth: { ...DEFAULT_CONFIG.auth },
167
186
  apply: { ...DEFAULT_CONFIG.apply },
@@ -249,6 +268,55 @@ function mergePhaseMapping(base, parsed) {
249
268
  }
250
269
  }
251
270
 
271
+ // Merge (and validate) `release.stages` — the project's deployment ladder.
272
+ //
273
+ // Loud on a malformed entry, like fieldOwnership and mapping.phases above: this
274
+ // list names Linear states, and Linear SILENTLY IGNORES an unknown state. A
275
+ // half-typed ladder would push clean and move nothing, which is exactly the
276
+ // failure `validateStates` exists to stop — so a bad shape fails here, at load,
277
+ // rather than at the first deploy nobody is watching.
278
+ //
279
+ // An ABSENT `release` block is not an error: it is the opt-out, and the whole
280
+ // feature is unused without it.
281
+ function mergeReleaseStages(base, parsed) {
282
+ const value = parsed.stages
283
+ if (value === undefined) return
284
+ if (!Array.isArray(value)) {
285
+ throw new Error(
286
+ `Invalid ${CONFIG_FILE}: release.stages = ${JSON.stringify(value)} ` +
287
+ '(expected an array of { key, state })',
288
+ )
289
+ }
290
+ const stages = []
291
+ const seen = new Set()
292
+ value.forEach((entry, i) => {
293
+ if (!isObject(entry)) {
294
+ throw new Error(
295
+ `Invalid ${CONFIG_FILE}: release.stages[${i}] = ${JSON.stringify(entry)} ` +
296
+ '(expected { key, state })',
297
+ )
298
+ }
299
+ for (const field of ['key', 'state']) {
300
+ if (typeof entry[field] !== 'string' || !entry[field].trim()) {
301
+ throw new Error(
302
+ `Invalid ${CONFIG_FILE}: release.stages[${i}].${field} = ${JSON.stringify(entry[field])} ` +
303
+ '(expected a non-empty string)',
304
+ )
305
+ }
306
+ }
307
+ const key = entry.key.trim()
308
+ if (seen.has(key)) {
309
+ throw new Error(
310
+ `Invalid ${CONFIG_FILE}: release.stages[${i}].key = ${JSON.stringify(key)} is a duplicate ` +
311
+ '(a stage key is how CI names the rung, so it must be unique)',
312
+ )
313
+ }
314
+ seen.add(key)
315
+ stages.push({ key, state: entry.state.trim() })
316
+ })
317
+ base.stages = stages
318
+ }
319
+
252
320
  // Merge (and validate) sync.keyedFields. Each value is the item's id property
253
321
  // name (a non-empty string); a field listed here is compared per item.
254
322
  function mergeKeyedFields(base, parsed) {
@@ -312,6 +380,10 @@ function mergeConfig(base, parsed) {
312
380
  assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
313
381
  }
314
382
 
383
+ if (isObject(parsed.release)) {
384
+ mergeReleaseStages(base.release, parsed.release)
385
+ }
386
+
315
387
  if (isObject(parsed.branch)) {
316
388
  assign(base.branch, parsed.branch, 'pattern', 'string')
317
389
  }
@@ -350,7 +422,8 @@ function mergeConfig(base, parsed) {
350
422
  * Returns `{ config, present }`:
351
423
  * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
352
424
  * - present → `{ config: merged, present: true }`
353
- * Malformed JSON or a bad `fieldOwnership` enum throws a clear Error.
425
+ * Malformed JSON, a bad `fieldOwnership` enum, or a malformed `release.stages`
426
+ * entry → throws a clear Error.
354
427
  */
355
428
  function loadLinearConfig(dir = process.cwd()) {
356
429
  const base = defaults()
@@ -374,8 +447,25 @@ function loadLinearConfig(dir = process.cwd()) {
374
447
  return { config: mergeConfig(base, parsed), present: true }
375
448
  }
376
449
 
450
+ /**
451
+ * The project's declared deployment ladder, always an array (empty = none
452
+ * declared). Callers read this rather than reaching into the config, so an
453
+ * older config object without a `release` block cannot throw.
454
+ */
455
+ function releaseStages(config) {
456
+ const stages = config && config.release && config.release.stages
457
+ return Array.isArray(stages) ? stages : []
458
+ }
459
+
460
+ /** The ladder rung with this key, or null. */
461
+ function stageFor(config, key) {
462
+ return releaseStages(config).find((s) => s.key === key) || null
463
+ }
464
+
377
465
  module.exports = {
378
466
  loadLinearConfig,
467
+ releaseStages,
468
+ stageFor,
379
469
  mergeConfig,
380
470
  defaults,
381
471
  DEFAULT_CONFIG,
@@ -29,7 +29,11 @@
29
29
  * by construction rather than by convention.
30
30
  */
31
31
 
32
- const STATES = ['ok', 'missing', 'broken', 'skipped']
32
+ // `warn` sits between `ok` and `broken`: worth a human's attention, but not a
33
+ // failure. A ladder whose last rung never closes an issue is a real problem for
34
+ // most projects and a deliberate choice for some, and only the project knows
35
+ // which — so it is said, and the exit code is left alone.
36
+ const STATES = ['ok', 'missing', 'broken', 'skipped', 'warn']
33
37
 
34
38
  // A row is a check. `fix` is the exact command to run, or null when there is
35
39
  // nothing to fix.
@@ -57,6 +61,7 @@ function runChecks(state = {}) {
57
61
  projectCheck(state.project, state.tracker, state.remote),
58
62
  keyCheck(state.key, state.tracker),
59
63
  remoteCheck(state.remote),
64
+ ladderCheck(state.ladder, state.tracker),
60
65
  mcpCheck(state.mcp, state.tracker, state.project, state.remote),
61
66
  ]
62
67
  // `missing` is a declined opt-in, so it must not fail the run. Only a
@@ -64,6 +69,67 @@ function runChecks(state = {}) {
64
69
  return { ok: !checks.some((c) => c.state === 'broken'), checks }
65
70
  }
66
71
 
72
+ /**
73
+ * The deployment ladder's shape, when one is declared.
74
+ *
75
+ * WHAT COULD FOOL THIS CHECK, and why it is a `warn` rather than a refusal: a
76
+ * workspace can close an issue by automation (a merged PR, a linked release)
77
+ * rather than by the last rung's own type, and a ladder that stops at a
78
+ * `started` state is then perfectly healthy. Only the project knows which it is.
79
+ *
80
+ * It needs the workspace's state TYPES, which only the API transport returns —
81
+ * `--workspace-states` and the MCP path carry names alone. Without them the row
82
+ * is `skipped`: not knowing a type is not evidence of a bad one.
83
+ */
84
+ function ladderCheck(s = {}, tracker = {}) {
85
+ if (!tracker.present) return row('ladder', 'ladder', 'skipped', 'no tracker configured')
86
+ const stages = Array.isArray(s.stages) ? s.stages : []
87
+ if (!stages.length) {
88
+ return row('ladder', 'ladder', 'skipped', 'no deployment ladder declared')
89
+ }
90
+
91
+ const summary = stages.map((st) => `${st.key} -> ${st.state}`).join(', ')
92
+ const states = Array.isArray(s.workspaceStates) ? s.workspaceStates : null
93
+ if (!states) {
94
+ return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
95
+ }
96
+
97
+ const byName = new Map(
98
+ states.filter((st) => st && typeof st.name === 'string').map((st) => [st.name.toLowerCase().trim(), st]),
99
+ )
100
+ // A list that arrived EMPTY, or held nothing with a name, cannot prove a rung
101
+ // is absent — it is a lookup that saw nothing, not a workspace without these
102
+ // states. Accusing every rung off that would be the exact absence-as-evidence
103
+ // mistake the negative-checks rule exists for.
104
+ if (!byName.size) {
105
+ return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
106
+ }
107
+ const unknown = stages.filter((st) => !byName.has(String(st.state).toLowerCase().trim()))
108
+ if (unknown.length) {
109
+ return row(
110
+ 'ladder',
111
+ 'ladder',
112
+ 'broken',
113
+ `rung state(s) not in this workspace: ${unknown.map((st) => `${st.key} -> "${st.state}"`).join(', ')}` +
114
+ ' — Linear silently ignores an unknown state, so these would move nothing',
115
+ '/spec-linear-setup',
116
+ )
117
+ }
118
+
119
+ const last = stages[stages.length - 1]
120
+ const type = (byName.get(String(last.state).toLowerCase().trim()) || {}).type
121
+ if (type !== 'completed') {
122
+ return row(
123
+ 'ladder',
124
+ 'ladder',
125
+ 'warn',
126
+ `ends at "${last.state}" (type: ${type || 'unknown'}) — an issue finishing the ladder never reaches a ` +
127
+ 'completed state. Add a final rung, or ignore this if Linear automation closes them.',
128
+ )
129
+ }
130
+ return row('ladder', 'ladder', 'ok', `${stages.length} rung(s): ${summary}`)
131
+ }
132
+
67
133
  function scaffoldCheck(s = {}) {
68
134
  if (!s.specsDir) {
69
135
  return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
@@ -77,4 +77,88 @@ function ticketsInRange(commits) {
77
77
  }
78
78
  }
79
79
 
80
- module.exports = { ticketsInRange, refsInBody }
80
+ /**
81
+ * Split a release's tickets into the ones a stage move may touch and the ones it
82
+ * must not, with a reason for every exclusion.
83
+ *
84
+ * Three reasons to leave a ticket alone, and each is a case where acting would
85
+ * be worse than not acting:
86
+ *
87
+ * - **foreign** — the ref is not this repo's team. A range can carry another
88
+ * team's ref (a shared dependency, a quoted ticket), and writing to a team
89
+ * this repo was never configured for is the worst failure available here.
90
+ * - **unlinked** — the ref is this team's, but no spec in the repo claims it.
91
+ * It may be tracker-only work, or a typo in a trailer. Either way nothing
92
+ * here knows what it is, so it is reported, not moved.
93
+ * - **unfinished** — a spec that has not reached the ceded bucket. Its code
94
+ * really is in the release (it landed via `/spec-to-main`), but push still
95
+ * owns its workflow state and would bounce it straight back. Exactly one
96
+ * writer per issue at any moment beats a visible flip-flop.
97
+ *
98
+ * Pure: it takes the spec list as data, so the rules are testable without a repo.
99
+ */
100
+ function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
101
+ const bucketOf = new Map()
102
+ for (const spec of specs || []) {
103
+ if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
104
+ }
105
+ const key = String(teamKey || '').trim().toUpperCase()
106
+ const movable = []
107
+ const foreign = []
108
+ const unlinked = []
109
+ const unfinished = []
110
+ for (const ticket of tickets || []) {
111
+ const ref = ticket && ticket.ref
112
+ if (!ref) continue
113
+ if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
114
+ foreign.push(ticket)
115
+ } else if (!bucketOf.has(ref)) {
116
+ unlinked.push(ticket)
117
+ } else if (bucketOf.get(ref) !== cededBucket) {
118
+ unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
119
+ } else {
120
+ movable.push(ticket)
121
+ }
122
+ }
123
+ return { movable, foreign, unlinked, unfinished }
124
+ }
125
+
126
+ /**
127
+ * Whether moving from `fromState` to the rung `toKey` runs against the declared
128
+ * order — and if so, how to say it.
129
+ *
130
+ * WARNS, never refuses. A rollback from test and a hotfix going straight to prod
131
+ * are both legitimate, and a check that blocked either would be wrong on healthy
132
+ * input. An issue that is on no rung yet is entering the ladder, which is the
133
+ * normal case and says nothing.
134
+ *
135
+ * `lifecycleStates` is the bucket map's own state names, and a state among them
136
+ * means the issue is at its LIFECYCLE position, not on a rung — even when the
137
+ * two names coincide. They routinely do: `states.complete` and a final `prod`
138
+ * rung are both naturally "Done", and without this a spec that had merely been
139
+ * completed would read as already deployed to prod, so its first real deploy
140
+ * would be warned about as a move backwards. Bucket wins over ladder here for
141
+ * the same reason it wins in `bucketForState`.
142
+ *
143
+ * @returns {string|null}
144
+ */
145
+ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
146
+ const list = Array.isArray(stages) ? stages : []
147
+ const to = list.findIndex((s) => s && s.key === toKey)
148
+ if (to < 0) return null
149
+ const want = String(fromState || '').toLowerCase().trim()
150
+ if (!want) return null
151
+ const lifecycle = new Set(
152
+ (Array.isArray(lifecycleStates) ? lifecycleStates : [])
153
+ .filter((n) => typeof n === 'string')
154
+ .map((n) => n.toLowerCase().trim()),
155
+ )
156
+ if (lifecycle.has(want)) return null
157
+ const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
158
+ if (from < 0) return null
159
+ if (to < from) return `moves back from "${list[from].key}"`
160
+ if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
161
+ return null
162
+ }
163
+
164
+ module.exports = { ticketsInRange, refsInBody, partitionStageMoves, stageOrderWarning }
@@ -13,7 +13,7 @@
13
13
  * stored matches what was sent — it merges nothing (see `src/verify.js`).
14
14
  */
15
15
 
16
- const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
16
+ const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
17
  const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
18
18
  const { readBase, writeBase } = require('./src/base.js')
19
19
  const { push, recordPush, projectionOf } = require('./src/push.js')
@@ -39,6 +39,9 @@ module.exports = {
39
39
  titleFromText,
40
40
  validateStates,
41
41
  stateSuggestions,
42
+ stageForState,
43
+ remoteStage,
44
+ LADDER_ORIGIN_BUCKET,
42
45
  hashField,
43
46
  stableStringify,
44
47
  readBase,
@@ -43,9 +43,28 @@ function hashField(value) {
43
43
  // The spec ISSUE fields the repo owns and pushes: prose + workflow state.
44
44
  // Priority, labels, cycles and comments are Linear-native triage — one-way sync
45
45
  // neither pushes nor reads them, so a PM's triage is never clobbered.
46
+ //
47
+ // The COMBINED hash, retained for snapshots written before the fields were split
48
+ // (and read by any older CLI still pointed at this repo). New pushes diff
49
+ // `issueFields` below; this stays so neither direction breaks on the other.
46
50
  function specIssueHash(p) {
47
51
  return hashField({ description: p.description ?? null, state: p.status ?? null })
48
52
  }
53
+
54
+ // Per-field hashes of the same two values.
55
+ //
56
+ // They are hashed SEPARATELY because they have different owners once a spec is
57
+ // finished. The repo owns the description forever, but the workflow state is
58
+ // handed off: a deploy pipeline (see `release.stages`) moves the issue past
59
+ // `complete`, and welding the two meant any prose edit re-emitted the state and
60
+ // dragged the issue back. Diffing them apart is what lets a push touch the
61
+ // description without re-asserting a state someone else now owns.
62
+ function specIssueFieldHashes(p) {
63
+ return {
64
+ description: hashField(p.description ?? null),
65
+ state: hashField(p.status ?? null),
66
+ }
67
+ }
49
68
  // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
50
69
  const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
51
70
 
@@ -63,7 +82,11 @@ function snapshotOf(projection) {
63
82
  return out
64
83
  }
65
84
  return {
85
+ // Both shapes are written: `issueFields` is what a current push diffs, and
86
+ // `issue` keeps a snapshot readable by anything still expecting the combined
87
+ // hash. Cheap insurance — two SHA-1s of text already in hand.
66
88
  issue: specIssueHash(p),
89
+ issueFields: specIssueFieldHashes(p),
67
90
  subIssues: byId(p.subIssues, subIssueHash),
68
91
  }
69
92
  }
@@ -92,12 +115,43 @@ function planChanges(projection, snapshot) {
92
115
  }
93
116
 
94
117
  const plan = { subIssues }
95
- if (snap.issue !== specIssueHash(p)) {
96
- plan.issue = { description: p.description ?? null, state: p.status ?? null }
97
- }
118
+ const issue = issueChanges(p, snap)
119
+ if (issue) plan.issue = issue
98
120
  return plan
99
121
  }
100
122
 
123
+ /**
124
+ * What changed about the spec issue itself — `{description}`, `{state}`, or
125
+ * both, or null when neither did.
126
+ *
127
+ * Three states, not two: a snapshot may carry the split hashes, only the old
128
+ * combined one, or nothing at all. Only the first can say which field moved.
129
+ *
130
+ * The other two are UNKNOWN, and route to the harmless branch — today's welded
131
+ * behaviour, sending both fields. Sending a state that did not change is
132
+ * redundant; withholding one that did would leave the mirror silently stale, and
133
+ * that is the failure worth avoiding. The push rewrites the snapshot in the new
134
+ * shape, so a spec passes through `unknown` exactly once.
135
+ */
136
+ function issueChanges(projection, snapshot) {
137
+ const p = projection || {}
138
+ const snap = snapshot || {}
139
+ const both = () => ({ description: p.description ?? null, state: p.status ?? null })
140
+
141
+ const fields = snap.issueFields
142
+ if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
143
+ // No split hashes recorded: an old snapshot, or no snapshot at all (a
144
+ // create, which needs both fields anyway).
145
+ return snap.issue === specIssueHash(p) ? null : both()
146
+ }
147
+
148
+ const want = specIssueFieldHashes(p)
149
+ const changed = {}
150
+ if (fields.description !== want.description) changed.description = p.description ?? null
151
+ if (fields.state !== want.state) changed.state = p.status ?? null
152
+ return Object.keys(changed).length ? changed : null
153
+ }
154
+
101
155
  // True when a plan would push nothing.
102
156
  function isEmptyPlan(plan) {
103
157
  return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
@@ -105,6 +159,8 @@ function isEmptyPlan(plan) {
105
159
 
106
160
  module.exports = {
107
161
  planChanges,
162
+ issueChanges,
163
+ specIssueFieldHashes,
108
164
  snapshotOf,
109
165
  isEmptyPlan,
110
166
  hashField,
@@ -818,10 +818,31 @@ function phasesWithheld(snapshotDir, config) {
818
818
 
819
819
  // --- remote projection ------------------------------------------------------
820
820
 
821
+ // The lifecycle bucket a deployment ladder descends FROM. A spec is handed over
822
+ // to the deploy pipeline once it is finished, so every rung sits downstream of
823
+ // `complete` — a ticket on "On Test" is a completed spec that has been deployed,
824
+ // not a spec that moved somewhere else.
825
+ const LADDER_ORIGIN_BUCKET = 'complete'
826
+
827
+ // The declared ladder rung with this state name, or null.
828
+ function stageForState(state, config) {
829
+ if (state == null) return null
830
+ const want = String(state).toLowerCase().trim()
831
+ const stages = (config && config.release && config.release.stages) || []
832
+ if (!Array.isArray(stages)) return null
833
+ return stages.find((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want) || null
834
+ }
835
+
821
836
  // Map a remote workflow-state name back to the local lifecycle bucket (the
822
837
  // vocabulary `spec_status` uses) via config.states, so local and remote
823
838
  // workflowState hash equal when semantically equal. Falls back to a lowercased
824
839
  // raw value when the state isn't one of the configured names.
840
+ //
841
+ // A DECLARED DEPLOYMENT STAGE reads as `complete`, deliberately. Without this
842
+ // the fallback lowercases it — "On Test" becomes "on test", which equals no
843
+ // bucket — and every deployed spec reports as drifted forever, for the whole
844
+ // time it sits in the pipeline. The project told us these states are downstream
845
+ // of a finished spec, so they are not a disagreement about where the spec is.
825
846
  function bucketForState(state, config) {
826
847
  if (state == null) return null
827
848
  const states = (config && config.states) || {}
@@ -829,6 +850,7 @@ function bucketForState(state, config) {
829
850
  for (const [bucket, name] of Object.entries(states)) {
830
851
  if (typeof name === 'string' && name.toLowerCase().trim() === want) return bucket
831
852
  }
853
+ if (stageForState(state, config)) return LADDER_ORIGIN_BUCKET
832
854
  return want
833
855
  }
834
856
 
@@ -860,6 +882,13 @@ function remoteWorkflowState(issue, config) {
860
882
  return name != null ? bucketForState(name, config) : null
861
883
  }
862
884
 
885
+ // The declared ladder rung a remote issue is currently sitting on, or null.
886
+ // Pairs with `remoteWorkflowState`: that says which bucket the issue maps to,
887
+ // this says whether it got there by being deployed. Read-only.
888
+ function remoteStage(issue, config) {
889
+ return stageForState(remoteStateName(issue || {}), config)
890
+ }
891
+
863
892
  // A Linear issue title is plain text, so markdown emphasis is noise there — and
864
893
  // worse, an emphasis run cut mid-title (or a bold LABEL like `**1. Foo**`) can
865
894
  // leave a dangling `**`. Strip `*` emphasis markers and unwrap `[text](url)` to
@@ -932,11 +961,26 @@ function titleFromText(text, max = 100) {
932
961
  return stripTitleMarkup(title)
933
962
  }
934
963
 
964
+ // Every state NAME the config points at: the lifecycle bucket map, plus the
965
+ // project's deployment ladder when it declares one. Both reach Linear the same
966
+ // way and fail the same way, so both are checked by the same lookup.
967
+ function configuredStateNames(config) {
968
+ const names = Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
969
+ const stages = (config && config.release && config.release.stages) || []
970
+ for (const stage of Array.isArray(stages) ? stages : []) {
971
+ if (stage && typeof stage.state === 'string') names.push(stage.state)
972
+ }
973
+ return names
974
+ }
975
+
935
976
  // Which configured state NAMES are absent from the live workspace. The skill
936
977
  // fetches the workspace's project-status names over MCP and passes them here;
937
978
  // a non-empty result means a typo/rename that Linear would silently no-op.
979
+ //
980
+ // A project with no `release.stages` contributes nothing here, so the ladder
981
+ // cannot make this accuse a config that was fine before it existed.
938
982
  function validateStates(config, workspaceStates) {
939
- const configured = Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
983
+ const configured = configuredStateNames(config)
940
984
  const have = new Set((workspaceStates || []).map((s) => String(s).toLowerCase().trim()))
941
985
  return configured.filter((name) => !have.has(name.toLowerCase().trim()))
942
986
  }
@@ -971,13 +1015,29 @@ function stateSuggestions(config, workspaceStates) {
971
1015
  if (have.has(configured.toLowerCase().trim())) continue
972
1016
  const words = BUCKET_WORDS[bucket] || []
973
1017
  const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
974
- out.push({ bucket, configured, suggestion })
1018
+ out.push({ bucket, configured, suggestion, label: `states.${bucket}` })
1019
+ }
1020
+ // Deployment-ladder rungs, reported the same way. No suggestion is offered:
1021
+ // BUCKET_WORDS describes lifecycle vocabulary, and a stage name is the
1022
+ // project's own — guessing "On Test" meant "In Progress" would be worse than
1023
+ // saying nothing.
1024
+ const stages = (config && config.release && config.release.stages) || []
1025
+ for (const stage of Array.isArray(stages) ? stages : []) {
1026
+ if (!stage || typeof stage.state !== 'string') continue
1027
+ if (have.has(stage.state.toLowerCase().trim())) continue
1028
+ out.push({
1029
+ bucket: null,
1030
+ configured: stage.state,
1031
+ suggestion: null,
1032
+ label: `release.stages[${stage.key}]`,
1033
+ })
975
1034
  }
976
1035
  return out
977
1036
  }
978
1037
 
979
1038
  module.exports = {
980
1039
  stateSuggestions,
1040
+ configuredStateNames,
981
1041
  normalizeLocal,
982
1042
  phaseProjection,
983
1043
  phaseModeFor,
@@ -994,5 +1054,8 @@ module.exports = {
994
1054
  canonicalizeMarkdown,
995
1055
  joinEmphasisAcrossBreaks,
996
1056
  bucketForState,
1057
+ stageForState,
1058
+ remoteStage,
997
1059
  remoteWorkflowState,
1060
+ LADDER_ORIGIN_BUCKET,
998
1061
  }
@@ -1,59 +0,0 @@
1
- ---
2
- name: spec-connect
3
- description: Point your local canonical origin (localhost:3000/:8080) at a spec's running dev servers so you can test a worktree's UI/API changes at the normal URL — or `spec-connect main` to hand the ports back to your main checkout. Runs `skitterspec spec-env connect` (a small bundled reverse proxy). Opt-in — needs specs/.core/env.config.json with a `dev` block. Use when the user says "/spec-connect", "test <spec> locally", "point local at <spec>", or "connect to <spec>".
4
- ---
5
-
6
- # /spec-connect — expose one spec on the canonical ports
7
-
8
- Make `http://localhost:<frontPort>` serve a **spec's** warm dev servers instead of
9
- your main checkout's, so you can test a worktree's UI/API at the exact URL you
10
- always use — no bookmark, base-URL, or OAuth-callback changes. **Exclusive:** one
11
- spec is exposed at a time. `spec-connect main` stops the proxy and hands the ports
12
- back to your primary checkout.
13
-
14
- This skill is **opt-in**: it needs `specs/.core/env.config.json` with a `dev`
15
- block (host dev servers + their `frontPort`s). If isolation or `dev` is absent,
16
- say so and stop.
17
-
18
- **Lighter alternative for a code-only spec:** `/spec-live` reuses the dev server
19
- you already have running (it branch-switches the primary checkout) instead of
20
- starting a second stack — no proxy, one process. Prefer it for code-only specs;
21
- use `/spec-connect` when a spec has its own Docker stack, or to run several stacks
22
- in parallel.
23
-
24
- ## 1. Identify the target
25
-
26
- - Use the spec named as an argument. The literal `main` means **disconnect**
27
- (hand the ports back to the primary checkout). Else use the spec **currently in
28
- context**; if unclear, ask.
29
-
30
- ## 2. Make sure the spec's dev servers are running
31
-
32
- `connect` proxies to a spec's dev servers on its reserved port block — it does
33
- **not** start them. If they aren't up yet, start them first:
34
-
35
- ```
36
- skitterspec spec-env dev up <spec>
37
- ```
38
-
39
- (This is automatic under `/spec-go`; run it by hand only when connecting a spec
40
- whose servers you stopped.)
41
-
42
- ## 3. Connect (or disconnect)
43
-
44
- ```
45
- skitterspec spec-env connect <spec> # expose <spec> on the canonical ports
46
- skitterspec spec-env connect main # stop the proxy — main owns the ports
47
- ```
48
-
49
- The engine (re)starts a small bundled Node reverse proxy and **prints** the
50
- canonical URL → spec-port mapping. **If it reports a canonical port is in use**,
51
- your **main dev server still holds it** — stop main on that port, then re-run
52
- (the proxy can't share a port main is bound to). Relay the printed message.
53
-
54
- ## 4. Report
55
-
56
- Echo which spec is now on the canonical ports (and the URLs), or that the proxy
57
- was stopped and main owns them again. Switching to a different spec is just
58
- `spec-connect <other>` — the dev servers stay warm, so it's a near-instant
59
- re-point.