@skitterbyte/skitterspec-linear 8.0.5 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -16
- package/assets/core/SETUP.md +47 -37
- package/assets/core/linear.config.json.example +10 -7
- package/assets/core/linear.config.md +80 -38
- package/assets/rules/spec-planning.md +3 -0
- package/assets/skills/spec/SKILL.md +106 -10
- package/assets/skills/spec-bug/SKILL.md +61 -0
- package/assets/skills/spec-cancel/SKILL.md +18 -3
- package/assets/skills/spec-complete/SKILL.md +26 -8
- package/assets/skills/spec-go/SKILL.md +16 -6
- package/assets/skills/spec-hotfix/SKILL.md +1 -0
- package/assets/skills/spec-push/SKILL.md +71 -28
- package/assets/skills/spec-status/SKILL.md +13 -12
- package/package.json +1 -1
- package/src/cli.js +18 -6
- package/src/env/provision.js +39 -3
- package/src/env/resolve.js +1 -0
- package/src/vendor/linear/cli-sync.js +87 -12
- package/src/vendor/linear/config.js +41 -17
- package/src/vendor/linear/mcp.js +57 -50
- package/src/vendor/sync-core/index.js +4 -3
- package/src/vendor/sync-core/src/base.js +1 -1
- package/src/vendor/sync-core/src/compare.js +29 -41
- package/src/vendor/sync-core/src/normalize.js +61 -43
- package/src/vendor/sync-core/src/push.js +5 -7
- package/src/vendor/sync-core/src/write.js +14 -13
|
@@ -253,6 +253,16 @@ function phaseTitle(body) {
|
|
|
253
253
|
return t || null
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// A phase's status (from its heading emoji ⬜/🔄/✅) mapped to a state bucket the
|
|
257
|
+
// `states` table understands, so a sub-issue lands in the matching Linear issue
|
|
258
|
+
// state. Unknown/absent → backlog.
|
|
259
|
+
const PHASE_STATE_BUCKET = { 'not-started': 'backlog', 'in-progress': 'in-progress', done: 'complete' }
|
|
260
|
+
function phaseStateBucket(body) {
|
|
261
|
+
const h1 = /^#\s+(.*)$/m.exec(body)
|
|
262
|
+
const emoji = h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
|
|
263
|
+
return PHASE_STATE_BUCKET[EMOJI_STATUS[emoji]] || 'backlog'
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
// Parse a task line (already stripped of its leading "- ") into a keyed item:
|
|
257
267
|
// its checkbox state, its text, and the inline Linear issue identifier if present
|
|
258
268
|
// (`… (SKI-123)`). Returns null for a non-task line.
|
|
@@ -297,9 +307,11 @@ function readPhaseFiles(snapshotDir) {
|
|
|
297
307
|
return {
|
|
298
308
|
phase: file.replace(/\.md$/, ''),
|
|
299
309
|
file,
|
|
300
|
-
id
|
|
310
|
+
// The sub-issue id, stamped back into the phase file on first push.
|
|
311
|
+
id: data.linear_issue_id != null ? String(data.linear_issue_id) : null,
|
|
301
312
|
name: phaseTitle(body),
|
|
302
313
|
goal: goal.trim(),
|
|
314
|
+
state: phaseStateBucket(body),
|
|
303
315
|
tasks,
|
|
304
316
|
}
|
|
305
317
|
})
|
|
@@ -350,47 +362,39 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
350
362
|
/**
|
|
351
363
|
* Normalize a local spec snapshot into the configured field set.
|
|
352
364
|
*/
|
|
365
|
+
// The spec's lifecycle bucket from its folder — the source of truth for status
|
|
366
|
+
// (specs live in specs/<bucket>/<name>/). Maps directly to a `states` key.
|
|
367
|
+
const LIFECYCLE_BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
|
|
368
|
+
function bucketFromPath(snapshotDir) {
|
|
369
|
+
const parent = path.basename(path.dirname(snapshotDir))
|
|
370
|
+
return LIFECYCLE_BUCKETS.includes(parent) ? parent : null
|
|
371
|
+
}
|
|
372
|
+
|
|
353
373
|
function normalizeLocal(snapshotDir, config) {
|
|
354
374
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
355
|
-
// Phases sync as
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
const
|
|
375
|
+
// Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
|
|
376
|
+
// so strip the `## Phases` index from the description to avoid duplicating it
|
|
377
|
+
// (as prose AND as sub-issues) in the Linear mirror.
|
|
378
|
+
const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
|
|
359
379
|
const extracted = {
|
|
360
380
|
description: buildDescription(
|
|
361
381
|
title,
|
|
362
382
|
sections,
|
|
363
383
|
config.sync.localOnlySections,
|
|
364
|
-
|
|
384
|
+
phasesProjected ? ['Phases'] : [],
|
|
365
385
|
),
|
|
366
|
-
//
|
|
367
|
-
// handle the push skill stamps a newly-created
|
|
368
|
-
|
|
386
|
+
// Sub-issue projection: one per phase. `ref` is the phase-file basename — the
|
|
387
|
+
// local handle the push skill stamps a newly-created sub-issue id back into.
|
|
388
|
+
// `state` is the phase's status bucket (from its heading emoji), mapped to a
|
|
389
|
+
// Linear issue state via `config.states` at push time. Tasks are NOT
|
|
390
|
+
// projected — they live only in the repo phase files.
|
|
391
|
+
subIssues: phases
|
|
369
392
|
.filter((p) => p.name)
|
|
370
|
-
.map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal })),
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
tasks: phases.flatMap((p) =>
|
|
376
|
-
p.tasks
|
|
377
|
-
.map(parseTaskLine)
|
|
378
|
-
.filter(Boolean)
|
|
379
|
-
.map((t) => ({
|
|
380
|
-
id: t.id,
|
|
381
|
-
ref: t.text,
|
|
382
|
-
title: titleFromText(t.text),
|
|
383
|
-
description: t.text,
|
|
384
|
-
done: t.done,
|
|
385
|
-
milestoneRef: p.id || p.phase,
|
|
386
|
-
})),
|
|
387
|
-
),
|
|
388
|
-
phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
|
|
389
|
-
acceptanceCriteria: sections['Acceptance criteria'] || null,
|
|
390
|
-
taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
|
|
391
|
-
workflowState: frontmatter.spec_status != null ? String(frontmatter.spec_status) : null,
|
|
392
|
-
priority: frontmatter.priority != null ? frontmatter.priority : null,
|
|
393
|
-
labels: Array.isArray(frontmatter.labels) ? frontmatter.labels : [],
|
|
393
|
+
.map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal, state: p.state })),
|
|
394
|
+
// Status is the spec's lifecycle bucket. The folder is the source of truth;
|
|
395
|
+
// an explicit `spec_status` frontmatter key overrides it if present.
|
|
396
|
+
workflowState:
|
|
397
|
+
frontmatter.spec_status != null ? String(frontmatter.spec_status) : bucketFromPath(snapshotDir),
|
|
394
398
|
}
|
|
395
399
|
return toFieldSet(extracted, config)
|
|
396
400
|
}
|
|
@@ -422,23 +426,36 @@ function canonicalRemoteStatus(state) {
|
|
|
422
426
|
return s
|
|
423
427
|
}
|
|
424
428
|
|
|
425
|
-
// The real Linear
|
|
426
|
-
//
|
|
427
|
-
function remoteStateName(
|
|
428
|
-
const st =
|
|
429
|
+
// The real Linear issue carries its workflow state in `state` (an object
|
|
430
|
+
// `{ name, type }`); accept `status` / a bare string too for robustness.
|
|
431
|
+
function remoteStateName(issue) {
|
|
432
|
+
const st = issue.state != null ? issue.state : issue.status
|
|
429
433
|
if (st == null) return null
|
|
430
434
|
if (typeof st === 'object') return st.name != null ? st.name : st.type != null ? st.type : null
|
|
431
435
|
return st
|
|
432
436
|
}
|
|
433
437
|
|
|
434
|
-
// The ONE thing one-way sync reads back: the mirror's current workflow
|
|
435
|
-
// mapped to the local lifecycle bucket, so `/spec-status` can report a
|
|
436
|
-
// ("Linear says Done, your spec says In Progress"). Read-only —
|
|
437
|
-
function remoteWorkflowState(
|
|
438
|
-
const name = remoteStateName(
|
|
438
|
+
// The ONE thing one-way sync reads back: the mirror issue's current workflow
|
|
439
|
+
// state, mapped to the local lifecycle bucket, so `/spec-status` can report a
|
|
440
|
+
// drift ("Linear says Done, your spec says In Progress"). Read-only — never writes.
|
|
441
|
+
function remoteWorkflowState(issue, config) {
|
|
442
|
+
const name = remoteStateName(issue || {})
|
|
439
443
|
return name != null ? bucketForState(name, config) : null
|
|
440
444
|
}
|
|
441
445
|
|
|
446
|
+
// A Linear issue title is plain text, so markdown emphasis is noise there — and
|
|
447
|
+
// worse, an emphasis run cut mid-title (or a bold LABEL like `**1. Foo**`) can
|
|
448
|
+
// leave a dangling `**`. Strip `*` emphasis markers and unwrap `[text](url)` to
|
|
449
|
+
// `text`. Backticks and `_` are KEPT: task labels lean on inline code
|
|
450
|
+
// (`` `DbFoo` ``) and identifiers use snake_case, and neither breaks a title.
|
|
451
|
+
function stripTitleMarkup(t) {
|
|
452
|
+
return t
|
|
453
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) -> text
|
|
454
|
+
.replace(/\*/g, '') // bold/italic markers (incl. a dangling ** from a cut)
|
|
455
|
+
.replace(/\s+/g, ' ')
|
|
456
|
+
.trim()
|
|
457
|
+
}
|
|
458
|
+
|
|
442
459
|
// Trim a trailing parenthetical whose opener has no matching close — so a cut
|
|
443
460
|
// title never ends on a dangling `(`/`[`.
|
|
444
461
|
function dropUnclosedBracket(t) {
|
|
@@ -469,6 +486,7 @@ function titleFromText(text, max = 100) {
|
|
|
469
486
|
const after = s[i + 1]
|
|
470
487
|
if (after !== undefined && !/\s/.test(after)) continue // not a sentence end
|
|
471
488
|
if (/\d/.test(s[i - 1] || '') && /\d/.test(after || '')) continue // 7.0.2, 3.14
|
|
489
|
+
if (/(?:^|[^\w])\d+$/.test(s.slice(0, i))) continue // list ordinal "1." "2."
|
|
472
490
|
if (/(^|\s)(e\.g|i\.e|etc|vs|no|fig|cf)$/i.test(s.slice(0, i))) continue // abbrev
|
|
473
491
|
title = s.slice(0, i) // drop the terminator
|
|
474
492
|
break
|
|
@@ -494,7 +512,7 @@ function titleFromText(text, max = 100) {
|
|
|
494
512
|
}
|
|
495
513
|
title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
|
|
496
514
|
}
|
|
497
|
-
return title
|
|
515
|
+
return stripTitleMarkup(title)
|
|
498
516
|
}
|
|
499
517
|
|
|
500
518
|
// Which configured state NAMES are absent from the live workspace. The skill
|
|
@@ -19,18 +19,16 @@ const { normalizeLocal } = require('./normalize.js')
|
|
|
19
19
|
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase } = require('./base.js')
|
|
21
21
|
|
|
22
|
-
// Build the one-way projection from a local snapshot: the
|
|
23
|
-
// and
|
|
24
|
-
// skill maps it to the Linear
|
|
22
|
+
// Build the one-way projection from a local snapshot: the spec issue's prose +
|
|
23
|
+
// status, and its phase sub-issues. `status` is the local lifecycle bucket; the
|
|
24
|
+
// skill maps it (and each sub-issue's `state`) to the Linear issue-state NAME via
|
|
25
|
+
// config.states at apply time.
|
|
25
26
|
function projectionOf(snapshotDir, config) {
|
|
26
27
|
const local = normalizeLocal(snapshotDir, config)
|
|
27
28
|
return {
|
|
28
29
|
description: local.description ?? null,
|
|
29
30
|
status: local.workflowState ?? null,
|
|
30
|
-
|
|
31
|
-
labels: Array.isArray(local.labels) ? local.labels : [],
|
|
32
|
-
milestones: Array.isArray(local.milestones) ? local.milestones : [],
|
|
33
|
-
issues: Array.isArray(local.tasks) ? local.tasks : [],
|
|
31
|
+
subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Push-side writeback: after `push` creates
|
|
5
|
-
* skill stamps each returned id back into the repo so the next push
|
|
6
|
-
* rather than recreates. Everything here edits the repo in place:
|
|
4
|
+
* Push-side writeback: after `push` creates the spec issue + phase sub-issues in
|
|
5
|
+
* Linear, the skill stamps each returned id back into the repo so the next push
|
|
6
|
+
* updates rather than recreates. Everything here edits the repo in place:
|
|
7
7
|
*
|
|
8
|
-
* - `
|
|
9
|
-
*
|
|
10
|
-
* - `stampIssueId(dir, text, id)` — append `(ID)` to the matching task line,
|
|
11
|
-
* re-wrapped in the file's own style.
|
|
8
|
+
* - `stampSubIssueId(dir, file, id)` — add/update `linear_issue_id` in a phase
|
|
9
|
+
* file's frontmatter (locate the file with `findPhaseFileByTitle`).
|
|
12
10
|
* - `writeFrontmatter(dir, config, patch)` — patch `00-overview.md` frontmatter
|
|
13
|
-
* (e.g. `last_synced_at`).
|
|
11
|
+
* (e.g. the spec issue's `spec_identifier`, `last_synced_at`).
|
|
12
|
+
* - `stampIssueId(dir, text, id)` — legacy: append `(ID)` to a task line
|
|
13
|
+
* (tasks are no longer synced; kept for the sanitise/util paths).
|
|
14
14
|
*
|
|
15
15
|
* No remote read, no pull writeback — the repo is the source of truth.
|
|
16
16
|
*/
|
|
@@ -90,7 +90,7 @@ function listPhaseFiles(snapshotDir) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Find the phase file whose h1 title matches `name` (used to stamp a freshly
|
|
93
|
-
// created
|
|
93
|
+
// created sub-issue's id back into the phase it came from).
|
|
94
94
|
function findPhaseFileByTitle(snapshotDir, name) {
|
|
95
95
|
const want = String(name).trim()
|
|
96
96
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
@@ -106,12 +106,13 @@ function findPhaseFileByTitle(snapshotDir, name) {
|
|
|
106
106
|
return null
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// Add/update
|
|
110
|
-
|
|
109
|
+
// Add/update linear_issue_id in a phase file's frontmatter (in place) — the
|
|
110
|
+
// sub-issue id for that phase.
|
|
111
|
+
function stampSubIssueId(snapshotDir, file, id) {
|
|
111
112
|
const p = path.join(snapshotDir, file)
|
|
112
113
|
const raw = fs.readFileSync(p, 'utf-8')
|
|
113
114
|
const { fmLines, body, had } = splitFrontmatter(raw)
|
|
114
|
-
const patched = patchFrontmatterLines(fmLines, {
|
|
115
|
+
const patched = patchFrontmatterLines(fmLines, { linear_issue_id: String(id) })
|
|
115
116
|
const fm = `---\n${patched.join('\n')}\n---\n`
|
|
116
117
|
fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
|
|
117
118
|
}
|
|
@@ -144,6 +145,6 @@ module.exports = {
|
|
|
144
145
|
serialize,
|
|
145
146
|
listPhaseFiles,
|
|
146
147
|
findPhaseFileByTitle,
|
|
147
|
-
|
|
148
|
+
stampSubIssueId,
|
|
148
149
|
stampIssueId,
|
|
149
150
|
}
|