@skitterbyte/skitterspec-linear 8.0.4 → 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.
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs')
18
18
  const path = require('node:path')
19
- const { findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
19
+ const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
20
20
 
21
21
  // --- markdown / frontmatter parsing -----------------------------------------
22
22
 
@@ -57,6 +57,7 @@ function parseScalar(v) {
57
57
  // heading text → its content (until the next `## `). The H1 `# ` is the title.
58
58
  function parseSections(body) {
59
59
  const lines = body.split('\n')
60
+ const inFence = fenceMask(lines)
60
61
  let title = null
61
62
  const sections = {}
62
63
  let current = null
@@ -64,9 +65,12 @@ function parseSections(body) {
64
65
  const flush = () => {
65
66
  if (current !== null) sections[current] = buf.join('\n').trim()
66
67
  }
67
- for (const line of lines) {
68
- const h1 = /^#\s+(.*)$/.exec(line)
69
- const h2 = /^##\s+(.*)$/.exec(line)
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i]
70
+ // A `#`/`##` inside a fenced code block is example text, not a real heading —
71
+ // treat it as body content so it can't start a phantom section.
72
+ const h1 = inFence[i] ? null : /^#\s+(.*)$/.exec(line)
73
+ const h2 = inFence[i] ? null : /^##\s+(.*)$/.exec(line)
70
74
  if (h1 && title === null) {
71
75
  title = h1[1].trim()
72
76
  continue
@@ -249,6 +253,16 @@ function phaseTitle(body) {
249
253
  return t || null
250
254
  }
251
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
+
252
266
  // Parse a task line (already stripped of its leading "- ") into a keyed item:
253
267
  // its checkbox state, its text, and the inline Linear issue identifier if present
254
268
  // (`… (SKI-123)`). Returns null for a non-task line.
@@ -293,9 +307,11 @@ function readPhaseFiles(snapshotDir) {
293
307
  return {
294
308
  phase: file.replace(/\.md$/, ''),
295
309
  file,
296
- id: data.linear_milestone_id != null ? String(data.linear_milestone_id) : null,
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,
297
312
  name: phaseTitle(body),
298
313
  goal: goal.trim(),
314
+ state: phaseStateBucket(body),
299
315
  tasks,
300
316
  }
301
317
  })
@@ -346,47 +362,39 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
346
362
  /**
347
363
  * Normalize a local spec snapshot into the configured field set.
348
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
+
349
373
  function normalizeLocal(snapshotDir, config) {
350
374
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
351
- // Phases sync as first-class Milestones whenever `milestones` is in the pushed
352
- // projection, so strip the `## Phases` index from the description to avoid
353
- // duplicating it (as prose AND as milestones) in the Linear mirror.
354
- const milestonesProjected = !!(config.sync.fieldOwnership && 'milestones' in config.sync.fieldOwnership)
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)
355
379
  const extracted = {
356
380
  description: buildDescription(
357
381
  title,
358
382
  sections,
359
383
  config.sync.localOnlySections,
360
- milestonesProjected ? ['Phases'] : [],
384
+ phasesProjected ? ['Phases'] : [],
361
385
  ),
362
- // Milestone projection items. `ref` is the phase-file basename — the local
363
- // handle the push skill stamps a newly-created milestone id back into.
364
- milestones: phases
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
365
392
  .filter((p) => p.name)
366
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal })),
367
- // Issue projection items across all phases. `title` is the first-sentence
368
- // Linear title; `description` is the full task text (the mirror keeps both).
369
- // `ref` = the collapsed text the push skill matches to stamp a new id in;
370
- // `milestoneRef` links the issue to its milestone (id if linked, else phase).
371
- tasks: phases.flatMap((p) =>
372
- p.tasks
373
- .map(parseTaskLine)
374
- .filter(Boolean)
375
- .map((t) => ({
376
- id: t.id,
377
- ref: t.text,
378
- title: titleFromText(t.text),
379
- description: t.text,
380
- done: t.done,
381
- milestoneRef: p.id || p.phase,
382
- })),
383
- ),
384
- phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
385
- acceptanceCriteria: sections['Acceptance criteria'] || null,
386
- taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
387
- workflowState: frontmatter.spec_status != null ? String(frontmatter.spec_status) : null,
388
- priority: frontmatter.priority != null ? frontmatter.priority : null,
389
- 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),
390
398
  }
391
399
  return toFieldSet(extracted, config)
392
400
  }
@@ -418,23 +426,36 @@ function canonicalRemoteStatus(state) {
418
426
  return s
419
427
  }
420
428
 
421
- // The real Linear projection carries the project's workflow state in `status`
422
- // (an object `{ name, type }`); accept a bare string / legacy `state` too.
423
- function remoteStateName(project) {
424
- const st = project.status != null ? project.status : project.state
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
425
433
  if (st == null) return null
426
434
  if (typeof st === 'object') return st.name != null ? st.name : st.type != null ? st.type : null
427
435
  return st
428
436
  }
429
437
 
430
- // The ONE thing one-way sync reads back: the mirror's current workflow state,
431
- // mapped to the local lifecycle bucket, so `/spec-status` can report a drift
432
- // ("Linear says Done, your spec says In Progress"). Read-only — it never writes.
433
- function remoteWorkflowState(project, config) {
434
- const name = remoteStateName(project || {})
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 || {})
435
443
  return name != null ? bucketForState(name, config) : null
436
444
  }
437
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
+
438
459
  // Trim a trailing parenthetical whose opener has no matching close — so a cut
439
460
  // title never ends on a dangling `(`/`[`.
440
461
  function dropUnclosedBracket(t) {
@@ -465,6 +486,7 @@ function titleFromText(text, max = 100) {
465
486
  const after = s[i + 1]
466
487
  if (after !== undefined && !/\s/.test(after)) continue // not a sentence end
467
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."
468
490
  if (/(^|\s)(e\.g|i\.e|etc|vs|no|fig|cf)$/i.test(s.slice(0, i))) continue // abbrev
469
491
  title = s.slice(0, i) // drop the terminator
470
492
  break
@@ -490,7 +512,7 @@ function titleFromText(text, max = 100) {
490
512
  }
491
513
  title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
492
514
  }
493
- return title
515
+ return stripTitleMarkup(title)
494
516
  }
495
517
 
496
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 project prose + status,
23
- // and the milestone/issue items. `status` is the local lifecycle bucket; the
24
- // skill maps it to the Linear project-state NAME via config.states at apply time.
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
- priority: local.priority ?? null,
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
 
@@ -22,6 +22,38 @@ const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
22
22
  // A list-marker line (unordered or ordered). Distinguished from other block
23
23
  // breaks because a wrapped continuation can legitimately begin with one.
24
24
  const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
25
+ // A checkbox bullet — unambiguously a task, so it always starts its own block,
26
+ // at any indent. (A bare list marker is ambiguous; a checkbox never is.)
27
+ const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
28
+
29
+ // Mark every line that lies inside a fenced code block — the opening fence, its
30
+ // content, and the closing fence all count as `true`. Line scanners that hunt
31
+ // for markdown structure (task bullets, section headings) use this to ignore an
32
+ // EXAMPLE of that structure shown inside a fence: a spec that documents a
33
+ // checklist format in a ``` block must not have those example bullets harvested
34
+ // as real tasks (and pushed as real tracker issues). Supports ``` and ~~~ fences
35
+ // of any length ≥ 3; a closing fence is a bare marker (no info string) of the
36
+ // same character and at least the opening length.
37
+ function fenceMask(lines) {
38
+ const mask = new Array(lines.length).fill(false)
39
+ let open = null // the opening marker string (``` or ~~~ …), or null
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const trimmed = lines[i].trim()
42
+ if (open) {
43
+ mask[i] = true
44
+ if (/^(`{3,}|~{3,})$/.test(trimmed) && trimmed[0] === open[0] && trimmed.length >= open.length) {
45
+ open = null
46
+ }
47
+ continue
48
+ }
49
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(lines[i])
50
+ if (m) {
51
+ open = m[1]
52
+ mask[i] = true
53
+ }
54
+ }
55
+ return mask
56
+ }
25
57
 
26
58
  // Collapse a wrapped bullet's lines into the single logical line the rest of the
27
59
  // sync engine (and Linear) works in.
@@ -81,27 +113,34 @@ function spanMask(body) {
81
113
  */
82
114
  function findTaskBlocks(lines) {
83
115
  const blocks = []
116
+ const inFence = fenceMask(lines)
84
117
  for (let i = 0; i < lines.length; i++) {
118
+ if (inFence[i]) continue // an example bullet inside a ``` block is not a task
85
119
  const m = TASK_START_RE.exec(lines[i])
86
120
  if (!m) continue
87
121
  const parts = [m[3]]
88
122
  // The hanging indent: where the bullet's text starts (marker width). A
89
- // continuation aligns AT this column; a genuine nested bullet is SHALLOWER.
123
+ // wrapped continuation aligns exactly AT this column.
90
124
  const hang = lines[i].length - m[3].length
91
125
  let j = i + 1
92
126
  for (; j < lines.length; j++) {
93
127
  const l = lines[j]
94
128
  if (!l.trim()) break
129
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
95
130
  if (!CONTINUATION_RE.test(l)) break
96
131
  if (BLOCK_BREAK_RE.test(l)) {
97
- // A list-marker line is a real nested/sibling bullet only when indented
98
- // shallower than the hanging indent. AT/after it, a line beginning with
99
- // -/*/+/N. is wrapped continuation text, not a new bullet keep it (else
100
- // the task is truncated and its stamped id no longer matches, which makes
101
- // the next push create a duplicate issue). Headings, quotes, tables and
102
- // fences always break.
132
+ // Indent alone can't tell a nested child from a wrapped continuation —
133
+ // both sit at the hanging indent. The *marker* is the reliable signal:
134
+ // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
135
+ // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
136
+ // sits exactly at the hang; shallower or deeper it's a real sub/
137
+ // sibling list break. (Keeping the at-hang continuation preserves
138
+ // the task's stamped id, so the next push updates instead of
139
+ // creating a duplicate issue.)
140
+ // - headings, quotes, tables and fences always break.
141
+ if (CHECKBOX_RE.test(l)) break
103
142
  const indent = l.length - l.trimStart().length
104
- if (!LIST_MARKER_RE.test(l) || indent < hang) break
143
+ if (!LIST_MARKER_RE.test(l) || indent !== hang) break
105
144
  }
106
145
  parts.push(l.trim())
107
146
  }
@@ -192,6 +231,7 @@ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
192
231
  }
193
232
 
194
233
  module.exports = {
234
+ fenceMask,
195
235
  findTaskBlocks,
196
236
  renderTaskBlock,
197
237
  wrapEmphasisAware,
@@ -1,16 +1,16 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Push-side writeback: after `push` creates milestones/issues in Linear, the
5
- * skill stamps each returned id back into the repo so the next push updates
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
- * - `stampMilestoneId(dir, file, id)` — add/update `linear_milestone_id` in a
9
- * phase file's frontmatter (locate the file with `findPhaseFileByTitle`).
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 milestone's id back into the phase it came from).
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 linear_milestone_id in a phase file's frontmatter (in place).
110
- function stampMilestoneId(snapshotDir, file, id) {
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, { linear_milestone_id: String(id) })
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
- stampMilestoneId,
148
+ stampSubIssueId,
148
149
  stampIssueId,
149
150
  }