@skitterbyte/skitterspec-linear 10.0.1 → 10.2.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/README.md +76 -15
- package/assets/core/SETUP.md +73 -13
- package/assets/core/linear.config.json.example +8 -1
- package/assets/core/linear.config.md +243 -8
- package/assets/rules/spec-planning.md +17 -11
- package/assets/skills/spec/SKILL.md +106 -66
- package/assets/skills/spec-bug/SKILL.md +99 -17
- package/assets/skills/spec-cancel/SKILL.md +34 -0
- package/assets/skills/spec-complete/SKILL.md +70 -15
- package/assets/skills/spec-go/SKILL.md +10 -3
- package/assets/skills/spec-hotfix/SKILL.md +157 -4
- package/assets/skills/spec-linear-setup/SKILL.md +172 -0
- package/assets/skills/spec-push/SKILL.md +142 -30
- package/assets/skills/spec-review/SKILL.md +34 -0
- package/assets/skills/spec-status/SKILL.md +9 -0
- package/bin/skitterspec-linear.js +19 -0
- package/package.json +1 -1
- package/src/cli.js +30 -19
- package/src/env/resolve.js +7 -2
- package/src/env/teardown.js +23 -9
- package/src/init.js +11 -1
- package/src/vendor/linear/api.js +246 -0
- package/src/vendor/linear/cli-sync.js +788 -3
- package/src/vendor/linear/config.js +127 -7
- package/src/vendor/sync-core/index.js +8 -1
- package/src/vendor/sync-core/src/normalize.js +291 -82
- package/src/vendor/sync-core/src/push.js +18 -1
- package/src/vendor/sync-core/src/tables.js +102 -0
- package/src/vendor/sync-core/src/task-block.js +18 -7
- package/src/vendor/sync-core/src/verify.js +83 -0
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Shape (see assets/core/linear.config.md for field docs):
|
|
18
18
|
* {
|
|
19
19
|
* linear: { teamKey, teamId, projectId },
|
|
20
|
-
* intake: { label, bugLabels },
|
|
20
|
+
* intake: { label, bugLabels, hotfixLabels },
|
|
21
21
|
* mapping: { specFolder, phases, tasks },
|
|
22
22
|
* states: { backlog, "in-progress", complete, cancelled },
|
|
23
23
|
* snapshot: { overviewFile },
|
|
@@ -44,6 +44,40 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
|
|
|
44
44
|
// box ticked in the tracker is overwritten by the next push.
|
|
45
45
|
const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
|
|
46
46
|
|
|
47
|
+
// When a phase becomes a sub-issue.
|
|
48
|
+
// subissue — always, from the spec's first push (default)
|
|
49
|
+
// deferred — only once the work starts: a spec sitting in `backlog` (or
|
|
50
|
+
// `cancelled` without ever having started) projects the issue
|
|
51
|
+
// alone, so adopting sync on a long backlog costs one call per
|
|
52
|
+
// spec instead of one per spec PLUS one per phase. A phase that
|
|
53
|
+
// already carries an id keeps projecting either way — one-way sync
|
|
54
|
+
// has no delete, so withholding a LINKED sub-issue would freeze it
|
|
55
|
+
// in the tracker rather than remove it.
|
|
56
|
+
// inline — never: each phase becomes a SECTION of the spec issue's own
|
|
57
|
+
// description instead, and the `## Phases` index stays as its table
|
|
58
|
+
// of contents. For work nobody will pick up phase by phase — 250
|
|
59
|
+
// finished specs are 250 issues worth reading and 669 sub-issues
|
|
60
|
+
// worth nobody's attention. Keeps an already-linked phase's
|
|
61
|
+
// sub-issue for the same reason `deferred` does.
|
|
62
|
+
//
|
|
63
|
+
// `mapping.phases` takes one of these as a scalar (one mode for the whole repo)
|
|
64
|
+
// OR a map keyed by lifecycle bucket — `{ "backlog": "subissue", "complete":
|
|
65
|
+
// "deferred" }` — because a repo can want assignable sub-issues for work in
|
|
66
|
+
// flight and something else entirely for work that finished long ago. A bucket
|
|
67
|
+
// the map omits gets DEFAULT_PHASE_MODE, so a partial map adds an exception
|
|
68
|
+
// rather than silently suppressing phases everywhere it is silent.
|
|
69
|
+
const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred', 'inline'])
|
|
70
|
+
const DEFAULT_PHASE_MODE = 'subissue'
|
|
71
|
+
|
|
72
|
+
// How `spec-sync apply` reaches Linear. `api` talks to the GraphQL API directly;
|
|
73
|
+
// `mcp` prints the plan for the skill to apply over MCP, as it always has.
|
|
74
|
+
const TRANSPORTS = Object.freeze(['api', 'mcp'])
|
|
75
|
+
|
|
76
|
+
// The environment variable a Linear personal API key is read from, unless
|
|
77
|
+
// `auth.keyEnv` names another. The config names the VARIABLE, never the key —
|
|
78
|
+
// nothing secret is ever written to the repo.
|
|
79
|
+
const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
|
|
80
|
+
|
|
47
81
|
const DEFAULT_CONFIG = Object.freeze({
|
|
48
82
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
49
83
|
// first `/spec-push` offer the team's projects and pre-select this one; empty
|
|
@@ -54,14 +88,18 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
54
88
|
linear: Object.freeze({ teamKey: '', teamId: '', projectId: '' }),
|
|
55
89
|
// Issue intake (`/spec <ISSUE-REF>`, `/spec --from-issue`). `label` is the
|
|
56
90
|
// inbox filter — issues carrying it are what the web app files; `bugLabels`
|
|
57
|
-
// route an issue to `/spec-bug` instead of `/spec
|
|
58
|
-
//
|
|
59
|
-
|
|
91
|
+
// route an issue to `/spec-bug` instead of `/spec`, and `hotfixLabels` route it
|
|
92
|
+
// to `/spec-hotfix` — a bug that has to be patched on the released version, not
|
|
93
|
+
// fixed on main. All empty = no inbox to browse (a bare issue ref still works)
|
|
94
|
+
// and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
|
|
95
|
+
// both: production is the more specific destination, and the cost of getting it
|
|
96
|
+
// wrong is asymmetric — a fix that lands only on main never reaches prod.
|
|
97
|
+
intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]) }),
|
|
60
98
|
// A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
|
|
61
99
|
// synced (they live only in the repo phase files).
|
|
62
100
|
// A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
|
|
63
101
|
// phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
|
|
64
|
-
mapping: Object.freeze({ specFolder: 'issue', phases:
|
|
102
|
+
mapping: Object.freeze({ specFolder: 'issue', phases: DEFAULT_PHASE_MODE, tasks: 'checklist' }),
|
|
65
103
|
// Linear ISSUE workflow-state names — the spec issue's state (from the folder
|
|
66
104
|
// bucket) and each sub-issue's state (from the phase emoji) both map through
|
|
67
105
|
// this one table. They must match the workspace's issue states exactly;
|
|
@@ -74,6 +112,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
74
112
|
}),
|
|
75
113
|
snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
|
|
76
114
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
115
|
+
// `keyEnv` names the env var holding the personal API key. It is a NAME, not a
|
|
116
|
+
// key: putting the secret itself here would commit it.
|
|
117
|
+
auth: Object.freeze({ keyEnv: DEFAULT_KEY_ENV }),
|
|
118
|
+
// `transport` is the default for `spec-sync apply --via`. Empty means "decide
|
|
119
|
+
// at run time": use the API when a key is present, MCP when it isn't.
|
|
120
|
+
apply: Object.freeze({ transport: '' }),
|
|
77
121
|
sync: Object.freeze({
|
|
78
122
|
baseDir: 'specs/.core/linear-base',
|
|
79
123
|
backupDir: 'specs/.core/linear-backups',
|
|
@@ -97,6 +141,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
97
141
|
}),
|
|
98
142
|
})
|
|
99
143
|
|
|
144
|
+
// The lifecycle buckets a per-bucket `mapping.phases` map may key on. Derived
|
|
145
|
+
// from `states` rather than restated: both maps key on the spec's folder bucket,
|
|
146
|
+
// so they cannot drift apart.
|
|
147
|
+
const LIFECYCLE_BUCKETS = Object.freeze(Object.keys(DEFAULT_CONFIG.states))
|
|
148
|
+
|
|
100
149
|
function isObject(value) {
|
|
101
150
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
102
151
|
}
|
|
@@ -105,11 +154,17 @@ function isObject(value) {
|
|
|
105
154
|
function defaults() {
|
|
106
155
|
return {
|
|
107
156
|
linear: { ...DEFAULT_CONFIG.linear },
|
|
108
|
-
intake: {
|
|
157
|
+
intake: {
|
|
158
|
+
label: DEFAULT_CONFIG.intake.label,
|
|
159
|
+
bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
|
|
160
|
+
hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
|
|
161
|
+
},
|
|
109
162
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
110
163
|
states: { ...DEFAULT_CONFIG.states },
|
|
111
164
|
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
112
165
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
166
|
+
auth: { ...DEFAULT_CONFIG.auth },
|
|
167
|
+
apply: { ...DEFAULT_CONFIG.apply },
|
|
113
168
|
sync: {
|
|
114
169
|
baseDir: DEFAULT_CONFIG.sync.baseDir,
|
|
115
170
|
backupDir: DEFAULT_CONFIG.sync.backupDir,
|
|
@@ -153,6 +208,47 @@ function mergeFieldOwnership(base, parsed) {
|
|
|
153
208
|
}
|
|
154
209
|
}
|
|
155
210
|
|
|
211
|
+
// Merge (and validate) `mapping.phases` in either of its two forms: a scalar
|
|
212
|
+
// mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
|
|
213
|
+
// key or value, like fieldOwnership and mapping.tasks — a misspelt bucket would
|
|
214
|
+
// otherwise read as a deliberate default and go on minting the sub-issues the
|
|
215
|
+
// config was written to stop.
|
|
216
|
+
function mergePhaseMapping(base, parsed) {
|
|
217
|
+
const value = parsed.phases
|
|
218
|
+
if (typeof value === 'string') {
|
|
219
|
+
if (value.trim()) base.phases = value.trim()
|
|
220
|
+
} else if (isObject(value)) {
|
|
221
|
+
const byBucket = {}
|
|
222
|
+
for (const [bucket, mode] of Object.entries(value)) {
|
|
223
|
+
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Invalid ${CONFIG_FILE}: mapping.phases.${bucket} is not a lifecycle bucket ` +
|
|
226
|
+
`(expected one of ${LIFECYCLE_BUCKETS.join('|')})`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
if (!PHASE_MAPPINGS.includes(mode)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Invalid ${CONFIG_FILE}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
|
|
232
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')})`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
byBucket[bucket] = mode
|
|
236
|
+
}
|
|
237
|
+
base.phases = byBucket
|
|
238
|
+
} else if (value !== undefined) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(value)} ` +
|
|
241
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
if (typeof base.phases === 'string' && !PHASE_MAPPINGS.includes(base.phases)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.phases)} ` +
|
|
247
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
156
252
|
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
157
253
|
// name (a non-empty string); a field listed here is compared per item.
|
|
158
254
|
function mergeKeyedFields(base, parsed) {
|
|
@@ -186,11 +282,13 @@ function mergeConfig(base, parsed) {
|
|
|
186
282
|
if (Array.isArray(parsed.intake.bugLabels)) {
|
|
187
283
|
base.intake.bugLabels = stringList(parsed.intake.bugLabels)
|
|
188
284
|
}
|
|
285
|
+
if (Array.isArray(parsed.intake.hotfixLabels)) {
|
|
286
|
+
base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
|
|
287
|
+
}
|
|
189
288
|
}
|
|
190
289
|
|
|
191
290
|
if (isObject(parsed.mapping)) {
|
|
192
291
|
assign(base.mapping, parsed.mapping, 'specFolder', 'string')
|
|
193
|
-
assign(base.mapping, parsed.mapping, 'phases', 'string')
|
|
194
292
|
assign(base.mapping, parsed.mapping, 'tasks', 'string')
|
|
195
293
|
// Loud on a typo, like fieldOwnership above. Quietly falling back would make
|
|
196
294
|
// a misspelt value look like a deliberate `none` — the same silent
|
|
@@ -201,6 +299,7 @@ function mergeConfig(base, parsed) {
|
|
|
201
299
|
`(expected one of ${TASK_MAPPINGS.join('|')})`,
|
|
202
300
|
)
|
|
203
301
|
}
|
|
302
|
+
mergePhaseMapping(base.mapping, parsed.mapping)
|
|
204
303
|
}
|
|
205
304
|
|
|
206
305
|
if (isObject(parsed.states)) {
|
|
@@ -217,6 +316,22 @@ function mergeConfig(base, parsed) {
|
|
|
217
316
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
218
317
|
}
|
|
219
318
|
|
|
319
|
+
if (isObject(parsed.auth)) {
|
|
320
|
+
assign(base.auth, parsed.auth, 'keyEnv', 'string')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (isObject(parsed.apply)) {
|
|
324
|
+
assign(base.apply, parsed.apply, 'transport', 'string?')
|
|
325
|
+
// Loud on a typo, like the mapping enums: a misspelt transport must not
|
|
326
|
+
// quietly fall back to MCP and look like a deliberate choice.
|
|
327
|
+
if (base.apply.transport && !TRANSPORTS.includes(base.apply.transport)) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Invalid ${CONFIG_FILE}: apply.transport = ${JSON.stringify(base.apply.transport)} ` +
|
|
330
|
+
`(expected one of ${TRANSPORTS.join('|')})`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
220
335
|
if (isObject(parsed.sync)) {
|
|
221
336
|
assign(base.sync, parsed.sync, 'baseDir', 'string')
|
|
222
337
|
assign(base.sync, parsed.sync, 'backupDir', 'string')
|
|
@@ -262,8 +377,13 @@ function loadLinearConfig(dir = process.cwd()) {
|
|
|
262
377
|
module.exports = {
|
|
263
378
|
loadLinearConfig,
|
|
264
379
|
mergeConfig,
|
|
380
|
+
defaults,
|
|
265
381
|
DEFAULT_CONFIG,
|
|
266
382
|
CONFIG_FILE,
|
|
267
383
|
OWNERSHIP,
|
|
268
384
|
TASK_MAPPINGS,
|
|
385
|
+
PHASE_MAPPINGS,
|
|
386
|
+
LIFECYCLE_BUCKETS,
|
|
387
|
+
TRANSPORTS,
|
|
388
|
+
DEFAULT_KEY_ENV,
|
|
269
389
|
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* about any specific tracker. The repo is the source of truth: the engine builds
|
|
8
8
|
* a local projection, diffs it against a committed last-pushed snapshot
|
|
9
9
|
* (`planChanges`), and returns a create/update plan the provider skill applies
|
|
10
|
-
* over its API. No remote content is read
|
|
10
|
+
* over its API. No remote content is read for CONTENT: nothing the tracker holds
|
|
11
|
+
* ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
|
|
12
|
+
* one function that looks at a tracker value, and it only checks that what was
|
|
13
|
+
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
|
|
@@ -17,6 +20,8 @@ const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
|
17
20
|
const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
18
21
|
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
19
22
|
const { detectLegacyMirror } = require('./src/legacy.js')
|
|
23
|
+
const { compareStored } = require('./src/verify.js')
|
|
24
|
+
const { flattenNestedTables } = require('./src/tables.js')
|
|
20
25
|
|
|
21
26
|
module.exports = {
|
|
22
27
|
normalizeLocal,
|
|
@@ -44,4 +49,6 @@ module.exports = {
|
|
|
44
49
|
listPhaseFiles,
|
|
45
50
|
sanitizeSpecMarkdown,
|
|
46
51
|
detectLegacyMirror,
|
|
52
|
+
compareStored,
|
|
53
|
+
flattenNestedTables,
|
|
47
54
|
}
|