@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.
- 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 +69 -47
- package/src/vendor/sync-core/src/push.js +5 -7
- package/src/vendor/sync-core/src/task-block.js +48 -8
- package/src/vendor/sync-core/src/write.js +14 -13
|
@@ -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 (
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
|
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
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
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)
|
|
355
379
|
const extracted = {
|
|
356
380
|
description: buildDescription(
|
|
357
381
|
title,
|
|
358
382
|
sections,
|
|
359
383
|
config.sync.localOnlySections,
|
|
360
|
-
|
|
384
|
+
phasesProjected ? ['Phases'] : [],
|
|
361
385
|
),
|
|
362
|
-
//
|
|
363
|
-
// handle the push skill stamps a newly-created
|
|
364
|
-
|
|
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
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
422
|
-
//
|
|
423
|
-
function remoteStateName(
|
|
424
|
-
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
|
|
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
|
|
431
|
-
// mapped to the local lifecycle bucket, so `/spec-status` can report a
|
|
432
|
-
// ("Linear says Done, your spec says In Progress"). Read-only —
|
|
433
|
-
function remoteWorkflowState(
|
|
434
|
-
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 || {})
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
|
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
|
|
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
|
}
|