@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.
- package/assets/claude-md-section.md +19 -9
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/core/ci-stages.md +110 -0
- package/assets/core/env.config.md +18 -0
- package/assets/core/linear.config.json.example +3 -0
- package/assets/core/linear.config.md +46 -0
- package/assets/rules/commit-trailers.md +37 -5
- package/assets/rules/spec-planning.md +17 -3
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +16 -2
- package/assets/skills/spec-cancel/SKILL.md +9 -0
- package/assets/skills/spec-complete/SKILL.md +12 -1
- package/assets/skills/spec-go/SKILL.md +10 -8
- package/assets/skills/spec-hotfix/SKILL.md +17 -3
- package/assets/skills/spec-linear-setup/SKILL.md +38 -2
- package/assets/skills/spec-status/SKILL.md +1 -0
- package/assets/skills/spec-sync/SKILL.md +1 -0
- package/assets/skills/spec-to-main/SKILL.md +2 -1
- package/bin/skitterspec-linear.js +10 -0
- package/package.json +1 -1
- package/src/cli.js +176 -39
- package/src/env/config.js +19 -0
- package/src/env/teardown.js +62 -4
- package/src/init.js +120 -2
- package/src/vendor/linear/cli-sync.js +402 -33
- package/src/vendor/linear/config.js +91 -1
- package/src/vendor/linear/doctor.js +67 -1
- package/src/vendor/linear/released.js +85 -1
- package/src/vendor/sync-core/index.js +4 -1
- package/src/vendor/sync-core/src/compare.js +59 -3
- package/src/vendor/sync-core/src/normalize.js +65 -2
- package/assets/skills/spec-connect/SKILL.md +0 -59
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 =
|
|
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.
|