@skitterbyte/skitterspec-linear 8.0.5 → 9.1.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.
@@ -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: 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,
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 first-class Milestones whenever `milestones` is in the pushed
356
- // projection, so strip the `## Phases` index from the description to avoid
357
- // duplicating it (as prose AND as milestones) in the Linear mirror.
358
- 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)
359
379
  const extracted = {
360
380
  description: buildDescription(
361
381
  title,
362
382
  sections,
363
383
  config.sync.localOnlySections,
364
- milestonesProjected ? ['Phases'] : [],
384
+ phasesProjected ? ['Phases'] : [],
365
385
  ),
366
- // Milestone projection items. `ref` is the phase-file basename — the local
367
- // handle the push skill stamps a newly-created milestone id back into.
368
- 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
369
392
  .filter((p) => p.name)
370
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal })),
371
- // Issue projection items across all phases. `title` is the first-sentence
372
- // Linear title; `description` is the full task text (the mirror keeps both).
373
- // `ref` = the collapsed text the push skill matches to stamp a new id in;
374
- // `milestoneRef` links the issue to its milestone (id if linked, else phase).
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 projection carries the project's workflow state in `status`
426
- // (an object `{ name, type }`); accept a bare string / legacy `state` too.
427
- function remoteStateName(project) {
428
- 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
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 state,
435
- // mapped to the local lifecycle bucket, so `/spec-status` can report a drift
436
- // ("Linear says Done, your spec says In Progress"). Read-only — it never writes.
437
- function remoteWorkflowState(project, config) {
438
- 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 || {})
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 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
 
@@ -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
  }