@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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs')
|
|
18
18
|
const path = require('node:path')
|
|
19
|
+
const { flattenNestedTables } = require('./tables.js')
|
|
19
20
|
const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
|
|
20
21
|
|
|
21
22
|
// --- markdown / frontmatter parsing -----------------------------------------
|
|
@@ -306,7 +307,10 @@ function phaseStateBucket(body) {
|
|
|
306
307
|
// its checkbox state, its text, and the inline Linear issue identifier if present
|
|
307
308
|
// (`… (SKI-123)`). Returns null for a non-task line.
|
|
308
309
|
function parseTaskLine(line) {
|
|
309
|
-
|
|
310
|
+
// Any single-character mark, matching the parser (`TASK_START_RE`). Only
|
|
311
|
+
// `x`/`X` is done — every other mark (`~`, `>`, `-` …) is a project's own
|
|
312
|
+
// vocabulary, carried through verbatim rather than coerced to unchecked.
|
|
313
|
+
const m = /^\[([^\]])\]\s*(.*)$/.exec(line)
|
|
310
314
|
if (!m) return null
|
|
311
315
|
const done = m[1].toLowerCase() === 'x'
|
|
312
316
|
let text = m[2].trim()
|
|
@@ -319,44 +323,31 @@ function parseTaskLine(line) {
|
|
|
319
323
|
return { id, text, done }
|
|
320
324
|
}
|
|
321
325
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
* criterion written under `## Acceptance` arrived in the mirror as an ordinary
|
|
327
|
-
* open task. Nothing was lost — it was just unreadable.
|
|
328
|
-
*
|
|
329
|
-
* Grouping is done by MAPPING blocks onto headings, not by re-parsing the body
|
|
330
|
-
* section by section: `findTaskBlocks` tracks open task subtrees across a
|
|
331
|
-
* continuous body, and slicing that body at every heading would change what a
|
|
332
|
-
* block claims at a section boundary. A heading inside a fence is not a heading
|
|
333
|
-
* (`fenceMask`), and the phase file's own `#` H1 is not a section.
|
|
334
|
-
*
|
|
335
|
-
* @returns {Array<{heading:string|null, tasks:string[]}>} in source order;
|
|
336
|
-
* `heading` is null for blocks that precede every heading, and a heading with
|
|
337
|
-
* no blocks under it never appears.
|
|
338
|
-
*/
|
|
339
|
-
function groupTasksByHeading(lines, blocks, renderTask) {
|
|
340
|
-
const inFence = fenceMask(lines)
|
|
341
|
-
const headings = []
|
|
342
|
-
for (let i = 0; i < lines.length; i++) {
|
|
343
|
-
if (inFence[i]) continue
|
|
344
|
-
const m = /^(#{2,6})\s+(.*\S)\s*$/.exec(lines[i])
|
|
345
|
-
if (m) headings.push({ line: i, heading: `${m[1]} ${m[2]}` })
|
|
346
|
-
}
|
|
326
|
+
// Tasks used to be grouped onto their source headings here, because the body was
|
|
327
|
+
// rebuilt from harvested task lines and the headings had to be put back. The
|
|
328
|
+
// projection now emits the phase file itself, so the headings never leave in the
|
|
329
|
+
// first place (bug-phase-content-dropped) and the grouping is gone with them.
|
|
347
330
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
331
|
+
// The phase's stated purpose, in either shape people actually write it: an
|
|
332
|
+
// inline `**Goal:** …` paragraph, or a `## Goal` section. Only the inline form
|
|
333
|
+
// was recognised — so the phase files written the other way (4 of the 96 in this
|
|
334
|
+
// repo, all of them recent) projected an empty goal.
|
|
335
|
+
function phaseGoal(body) {
|
|
336
|
+
const inline = /\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body)
|
|
337
|
+
if (inline) return inline[1]
|
|
338
|
+
return parseSections(body).sections.Goal || ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// One task block back to a single markdown line. `findTaskBlocks` also returns
|
|
342
|
+
// the plain sub-bullets written underneath a task; they carry no checkbox, so
|
|
343
|
+
// they render as the bullet their author used — emitting `- [ ]` here would
|
|
344
|
+
// invent a task that does not exist in the repo. Any inline `(KEY-123)` stamped
|
|
345
|
+
// on a legacy task line is stripped: those ids were per-task issues we no longer
|
|
346
|
+
// create, and they read as noise in the mirror.
|
|
347
|
+
function renderTaskBlockLine(b) {
|
|
348
|
+
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
349
|
+
const text = parsed ? parsed.text : b.text
|
|
350
|
+
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
360
351
|
}
|
|
361
352
|
|
|
362
353
|
// Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
|
|
@@ -381,25 +372,13 @@ function readPhaseFiles(snapshotDir) {
|
|
|
381
372
|
// Collapsed, not just captured: the goal becomes a milestone description,
|
|
382
373
|
// and Linear may canonicalize a soft line break away on save. Collapsing
|
|
383
374
|
// both sides keeps a wrapped goal from diffing forever.
|
|
384
|
-
const goal = collapseHyphenAware((
|
|
375
|
+
const goal = collapseHyphenAware(phaseGoal(body))
|
|
385
376
|
// Rendered as markdown checklist lines, ready to drop into a sub-issue
|
|
386
377
|
// description: indentation kept so nesting survives, each bullet keeping
|
|
387
|
-
// the marker its author wrote
|
|
388
|
-
// line stripped — those ids were per-task issues we no longer create, and
|
|
389
|
-
// they read as noise in the mirror.
|
|
378
|
+
// the marker its author wrote.
|
|
390
379
|
const lines = body.split('\n')
|
|
391
|
-
const renderTask = (b) => {
|
|
392
|
-
// `findTaskBlocks` also returns the plain sub-bullets written underneath
|
|
393
|
-
// a task. They carry no checkbox, so they render as the bullet their
|
|
394
|
-
// author used — emitting `- [ ]` here would invent a task that does not
|
|
395
|
-
// exist in the repo.
|
|
396
|
-
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
397
|
-
const text = parsed ? parsed.text : b.text
|
|
398
|
-
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
399
|
-
}
|
|
400
380
|
const blocks = findTaskBlocks(lines)
|
|
401
|
-
const tasks = blocks.map(
|
|
402
|
-
const taskGroups = groupTasksByHeading(lines, blocks, renderTask)
|
|
381
|
+
const tasks = blocks.map(renderTaskBlockLine)
|
|
403
382
|
return {
|
|
404
383
|
phase: file.replace(/\.md$/, ''),
|
|
405
384
|
file,
|
|
@@ -414,7 +393,11 @@ function readPhaseFiles(snapshotDir) {
|
|
|
414
393
|
emoji: headingEmoji(body),
|
|
415
394
|
statusLine: phaseStatusLine(body),
|
|
416
395
|
tasks,
|
|
417
|
-
|
|
396
|
+
// The raw body and the parsed blocks, so `subIssueBody` can project the
|
|
397
|
+
// file rather than rebuild it from fragments. Not part of the pushed
|
|
398
|
+
// field set — `toFieldSet` picks the projection's keys explicitly.
|
|
399
|
+
lines,
|
|
400
|
+
blocks,
|
|
418
401
|
}
|
|
419
402
|
})
|
|
420
403
|
}
|
|
@@ -527,7 +510,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
527
510
|
if (skip.has(heading)) continue
|
|
528
511
|
parts.push(`## ${heading}\n\n${content}`.trim())
|
|
529
512
|
}
|
|
530
|
-
return canonicalizeMarkdown(parts.join('\n\n')) || null
|
|
513
|
+
return flattenNestedTables(canonicalizeMarkdown(parts.join('\n\n'))) || null
|
|
531
514
|
}
|
|
532
515
|
|
|
533
516
|
// A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
|
|
@@ -538,19 +521,127 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
538
521
|
// push. Without it a sub-issue is a title and one sentence, which is too thin to
|
|
539
522
|
// act on; with it the phase is legible to someone working in the tracker without
|
|
540
523
|
// tasks becoming individually-synced objects again.
|
|
541
|
-
function subIssueBody(phase, tasksMode) {
|
|
542
|
-
if (tasksMode !== 'checklist'
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
524
|
+
function subIssueBody(phase, tasksMode, localOnlySections) {
|
|
525
|
+
if (tasksMode !== 'checklist') return flattenNestedTables(phase.goal)
|
|
526
|
+
return flattenNestedTables(projectPhaseBody(phase, localOnlySections))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Project a phase file into its sub-issue body: the file as written, with each
|
|
531
|
+
* task bullet replaced by its rendered single-line form.
|
|
532
|
+
*
|
|
533
|
+
* This used to REBUILD the body from two harvested fragments — the `**Goal:**`
|
|
534
|
+
* paragraph and the task lines `findTaskBlocks` claimed — and silently dropped
|
|
535
|
+
* everything neither covered: prose, whole `##` sections, and any table or
|
|
536
|
+
* fenced block nested under a task. `buildDescription` never had that problem
|
|
537
|
+
* because it projects the overview section by section, so the fix is to make the
|
|
538
|
+
* phase body work the same way — lossless by construction rather than by
|
|
539
|
+
* enumerating the shapes we remembered.
|
|
540
|
+
*
|
|
541
|
+
* Only two things are deliberately left out, and both are pushed as their own
|
|
542
|
+
* field, so keeping them would duplicate:
|
|
543
|
+
*
|
|
544
|
+
* - the `#` h1, projected as the sub-issue's `name`;
|
|
545
|
+
* - the `> **Status:**` line, projected as its `state`.
|
|
546
|
+
*
|
|
547
|
+
* Local-only sections are stripped exactly as they are from the description.
|
|
548
|
+
*/
|
|
549
|
+
function projectPhaseBody(phase, localOnlySections) {
|
|
550
|
+
const skip = new Set(localOnlySections || [])
|
|
551
|
+
const lines = phase.lines || []
|
|
552
|
+
const inFence = fenceMask(lines)
|
|
553
|
+
// Where each task block starts, so the walk can emit its rendered form and
|
|
554
|
+
// jump the lines it claims. Line-indexed splicing is what `task-block.js` is
|
|
555
|
+
// built for; everything it does NOT claim is content, and passes through.
|
|
556
|
+
const blockAt = new Map()
|
|
557
|
+
for (const b of phase.blocks || []) blockAt.set(b.start, b)
|
|
558
|
+
|
|
559
|
+
const out = []
|
|
560
|
+
let dropping = false // inside a local-only section
|
|
561
|
+
let seenTitle = false
|
|
562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
563
|
+
const block = blockAt.get(i)
|
|
564
|
+
if (block) {
|
|
565
|
+
if (!dropping) out.push(renderTaskBlockLine(block))
|
|
566
|
+
i = block.end - 1
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const line = lines[i]
|
|
570
|
+
if (!inFence[i]) {
|
|
571
|
+
const h2 = /^##\s+(.*\S)\s*$/.exec(line)
|
|
572
|
+
if (h2) dropping = skip.has(h2[1].trim())
|
|
573
|
+
if (!seenTitle && /^#\s+/.test(line)) {
|
|
574
|
+
seenTitle = true
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
if (/^>\s*\*\*Status:\*\*/.test(line)) continue
|
|
578
|
+
}
|
|
579
|
+
if (!dropping) out.push(line)
|
|
580
|
+
}
|
|
581
|
+
// Collapse the blank runs the removals leave behind, and trim the ends.
|
|
582
|
+
return out
|
|
583
|
+
.join('\n')
|
|
584
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
585
|
+
.trim()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Phases inline as `###`, hanging under the `## Phases` index that lists them.
|
|
589
|
+
// The body they carry is a whole document in its own right and starts at `##`,
|
|
590
|
+
// so it demotes to sit under that heading rather than break out of it.
|
|
591
|
+
const INLINE_PHASE_LEVEL = 3
|
|
592
|
+
const MAX_HEADING_LEVEL = 6
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Shift every heading in a projected phase body `by` levels, so an inlined phase
|
|
596
|
+
* nests under the `###` that introduces it.
|
|
597
|
+
*
|
|
598
|
+
* Projected for a sub-issue the body is the whole description and its `## Tasks`
|
|
599
|
+
* is correctly top-level. Inlined it is a subsection, and an undemoted `## Tasks`
|
|
600
|
+
* would read as a sibling of `## Problem` — pulling every following phase under
|
|
601
|
+
* it in the outline. Only the `#` run length changes; the content is the same
|
|
602
|
+
* bytes either way, which is the point of sharing one composer.
|
|
603
|
+
*/
|
|
604
|
+
function demoteHeadings(body, by) {
|
|
605
|
+
const lines = body.split('\n')
|
|
606
|
+
const inFence = fenceMask(lines)
|
|
607
|
+
return lines
|
|
608
|
+
.map((line, i) => {
|
|
609
|
+
if (inFence[i]) return line
|
|
610
|
+
const h = /^(#{1,6})(\s)/.exec(line)
|
|
611
|
+
if (!h) return line
|
|
612
|
+
return '#'.repeat(Math.min(h[1].length + by, MAX_HEADING_LEVEL)) + line.slice(h[1].length)
|
|
613
|
+
})
|
|
614
|
+
.join('\n')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// The phase file's h1 exactly as written — `Phase 2 — The inline projection 🔄`.
|
|
618
|
+
// `projectPhaseBody` drops it because a sub-issue projects the title as `name`
|
|
619
|
+
// and the emoji as `state`; inlined there are no such fields, so this heading is
|
|
620
|
+
// the only place either can live, and the raw line is where both still are.
|
|
621
|
+
function phaseHeading(phase) {
|
|
622
|
+
for (const line of phase.lines || []) {
|
|
623
|
+
const h1 = /^#\s+(.*\S)\s*$/.exec(line)
|
|
624
|
+
if (h1) return h1[1]
|
|
625
|
+
}
|
|
626
|
+
return phase.name
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Append the inlined phases to the spec issue's description as `###` sections.
|
|
631
|
+
*
|
|
632
|
+
* The body is `subIssueBody`'s — the SAME composer the sub-issue form uses, so
|
|
633
|
+
* `inline` inherits its fidelity guarantee instead of growing a second extractor
|
|
634
|
+
* that drops content, which is exactly what the reporting project had to
|
|
635
|
+
* hand-roll before this existed.
|
|
636
|
+
*/
|
|
637
|
+
function withInlinePhases(description, inlined, tasksMode, localOnlySections) {
|
|
638
|
+
if (!inlined.length) return description
|
|
639
|
+
const sections = inlined.map((phase) => {
|
|
640
|
+
const body = demoteHeadings(subIssueBody(phase, tasksMode, localOnlySections) || '', INLINE_PHASE_LEVEL - 1)
|
|
641
|
+
const heading = '#'.repeat(INLINE_PHASE_LEVEL) + ` ${phaseHeading(phase)}`
|
|
642
|
+
return body ? `${heading}\n\n${body}` : heading
|
|
552
643
|
})
|
|
553
|
-
return
|
|
644
|
+
return [description, ...sections].filter(Boolean).join('\n\n') || null
|
|
554
645
|
}
|
|
555
646
|
|
|
556
647
|
/**
|
|
@@ -564,19 +655,119 @@ function bucketFromPath(snapshotDir) {
|
|
|
564
655
|
return LIFECYCLE_BUCKETS.includes(parent) ? parent : null
|
|
565
656
|
}
|
|
566
657
|
|
|
658
|
+
// Lifecycle buckets in which a spec's work has not begun: never started, or
|
|
659
|
+
// abandoned without ever starting. Under `mapping.phases: 'deferred'` these are
|
|
660
|
+
// the states in which phases are not yet worth minting as sub-issues.
|
|
661
|
+
const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
|
|
662
|
+
|
|
663
|
+
// The mode a bucket gets when a per-bucket `mapping.phases` map omits it. Adding
|
|
664
|
+
// a bucket to the map is therefore an EXCEPTION for that bucket, not a switch
|
|
665
|
+
// that silently suppresses phases everywhere the map is silent. Matches the
|
|
666
|
+
// scalar default in the provider's config so both forms start from one place.
|
|
667
|
+
const DEFAULT_PHASE_MODE = 'subissue'
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The phase mode configured for one lifecycle bucket.
|
|
671
|
+
*
|
|
672
|
+
* `mapping.phases` is EITHER a scalar — one mode for the whole repo, which is
|
|
673
|
+
* what every config was before per-bucket mapping — or a map keyed by lifecycle
|
|
674
|
+
* bucket (`{ backlog: 'subissue', complete: 'inline' }`), because a repo can
|
|
675
|
+
* legitimately want assignable sub-issues for work in flight and none at all for
|
|
676
|
+
* 250 finished specs. A scalar resolves to itself for every bucket, so existing
|
|
677
|
+
* configs are unchanged.
|
|
678
|
+
*
|
|
679
|
+
* The SINGLE place a mode is decided: the projection and the description's
|
|
680
|
+
* `## Phases` index both read it, so they cannot disagree about one spec.
|
|
681
|
+
*/
|
|
682
|
+
function phaseModeFor(bucket, config) {
|
|
683
|
+
const configured = config && config.mapping && config.mapping.phases
|
|
684
|
+
if (typeof configured === 'string') return configured
|
|
685
|
+
if (configured && typeof configured === 'object' && !Array.isArray(configured)) {
|
|
686
|
+
const mode = configured[bucket]
|
|
687
|
+
return typeof mode === 'string' ? mode : DEFAULT_PHASE_MODE
|
|
688
|
+
}
|
|
689
|
+
return DEFAULT_PHASE_MODE
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Which phases the projection sends, and how many the `deferred` mapping is
|
|
694
|
+
* holding back. Pure — split out so both the projection and the CLI's "N phases
|
|
695
|
+
* deferred" line read the SAME predicate rather than two copies of it.
|
|
696
|
+
*
|
|
697
|
+
* Only UNLINKED phases are withheld. A phase that already carries an id keeps
|
|
698
|
+
* projecting whatever the mode: one-way sync has no delete op, so withholding a
|
|
699
|
+
* live sub-issue would not remove it from the tracker — it would freeze it there,
|
|
700
|
+
* never updated again. That makes switching a project to `deferred` safe.
|
|
701
|
+
*/
|
|
702
|
+
// The spec's lifecycle status as projected: its folder bucket, unless the
|
|
703
|
+
// overview frontmatter pins `spec_status`. Shared by the projection and by
|
|
704
|
+
// `phasesWithheld` so the two can never disagree about whether work has started.
|
|
705
|
+
function specStatus(snapshotDir, frontmatter) {
|
|
706
|
+
return frontmatter && frontmatter.spec_status != null
|
|
707
|
+
? String(frontmatter.spec_status)
|
|
708
|
+
: bucketFromPath(snapshotDir)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function phaseProjection(phases, workflowState, config) {
|
|
712
|
+
const named = phases.filter((p) => p.name)
|
|
713
|
+
const mode = phaseModeFor(workflowState, config)
|
|
714
|
+
// `deferred` and `inline` both send only phases that ALREADY carry an id, and
|
|
715
|
+
// for the same reason: one-way sync has no delete op, so withholding a live
|
|
716
|
+
// sub-issue would freeze it in the tracker rather than remove it (Decision 4).
|
|
717
|
+
// They differ only in where an unlinked phase goes — nowhere yet, or into the
|
|
718
|
+
// spec issue's own description.
|
|
719
|
+
const deferring = mode === 'deferred' && UNSTARTED_BUCKETS.includes(workflowState)
|
|
720
|
+
const projected = deferring || mode === 'inline' ? named.filter((p) => p.id != null) : named
|
|
721
|
+
const unlinked = named.filter((p) => p.id == null)
|
|
722
|
+
return {
|
|
723
|
+
projected,
|
|
724
|
+
// Held back until the work starts. Nothing carries them meanwhile, which is
|
|
725
|
+
// why the `## Phases` index stays and the CLI says how many.
|
|
726
|
+
withheld: deferring ? unlinked.length : 0,
|
|
727
|
+
// Rendered into the description instead. Deliberately NOT counted as
|
|
728
|
+
// withheld: nothing is missing from the mirror, so a report that said "N
|
|
729
|
+
// phases deferred" would be describing the opposite of what happened.
|
|
730
|
+
inlined: mode === 'inline' ? unlinked : [],
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
567
734
|
function normalizeLocal(snapshotDir, config) {
|
|
568
735
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
569
|
-
// Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
|
|
570
|
-
// so strip the `## Phases` index from the description to avoid duplicating it
|
|
571
|
-
// (as prose AND as sub-issues) in the Linear mirror.
|
|
572
|
-
const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
|
|
573
736
|
const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
|
|
737
|
+
// Status is the spec's lifecycle bucket. The folder is the source of truth; an
|
|
738
|
+
// explicit `spec_status` frontmatter key overrides it if present. Resolved
|
|
739
|
+
// BEFORE the sub-issue projection because deferral withholds phases by this
|
|
740
|
+
// status — so the issue's state and its sub-issues always agree on whether the
|
|
741
|
+
// work has started, however that status was arrived at.
|
|
742
|
+
const workflowState = specStatus(snapshotDir, frontmatter)
|
|
743
|
+
const { projected, withheld, inlined } = phaseProjection(phases, workflowState, config)
|
|
744
|
+
|
|
745
|
+
// Strip the `## Phases` index only when the SUB-ISSUES replace it: they carry
|
|
746
|
+
// every phase, so keeping the index would duplicate the list as prose AND as
|
|
747
|
+
// objects. It stays whenever they do not — while deferral holds a phase back
|
|
748
|
+
// the index is the only place that phase appears, and under `inline` there are
|
|
749
|
+
// no new sub-issues at all, so it is the issue's only table of contents
|
|
750
|
+
// (Decision 3).
|
|
751
|
+
//
|
|
752
|
+
// Keyed on the resolved MODE rather than on `subIssues` being an owned field:
|
|
753
|
+
// ownership says WHICH fields sync, which is a different question from how
|
|
754
|
+
// phases are shaped, and `inline` answers the second one per spec (Decision 5).
|
|
755
|
+
// Ownership still gets a say — an unowned `subIssues` pushes no sub-issues at
|
|
756
|
+
// all, so the index has to stay there too.
|
|
757
|
+
const syncsSubIssues = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
|
|
758
|
+
const phasesCarriedBySubIssues =
|
|
759
|
+
syncsSubIssues && phaseModeFor(workflowState, config) !== 'inline' && withheld === 0
|
|
574
760
|
const extracted = {
|
|
575
|
-
description:
|
|
576
|
-
|
|
577
|
-
|
|
761
|
+
description: withInlinePhases(
|
|
762
|
+
buildDescription(
|
|
763
|
+
title,
|
|
764
|
+
sections,
|
|
765
|
+
config.sync.localOnlySections,
|
|
766
|
+
phasesCarriedBySubIssues ? ['Phases'] : [],
|
|
767
|
+
),
|
|
768
|
+
inlined,
|
|
769
|
+
tasksMode,
|
|
578
770
|
config.sync.localOnlySections,
|
|
579
|
-
phasesProjected ? ['Phases'] : [],
|
|
580
771
|
),
|
|
581
772
|
// Sub-issue projection: one per phase. `ref` is the phase-file basename — the
|
|
582
773
|
// local handle the push skill stamps a newly-created sub-issue id back into.
|
|
@@ -584,17 +775,32 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
584
775
|
// Linear issue state via `config.states` at push time. Tasks ride along in
|
|
585
776
|
// the description as a read-only checklist (`mapping.tasks`), never as
|
|
586
777
|
// individually-synced objects.
|
|
587
|
-
subIssues:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
778
|
+
subIssues: projected.map((p) => ({
|
|
779
|
+
id: p.id,
|
|
780
|
+
ref: p.phase,
|
|
781
|
+
name: p.name,
|
|
782
|
+
goal: subIssueBody(p, tasksMode, config.sync.localOnlySections),
|
|
783
|
+
state: p.state,
|
|
784
|
+
})),
|
|
785
|
+
workflowState,
|
|
594
786
|
}
|
|
595
787
|
return toFieldSet(extracted, config)
|
|
596
788
|
}
|
|
597
789
|
|
|
790
|
+
/**
|
|
791
|
+
* How many phases `mapping.phases: 'deferred'` is currently holding back for
|
|
792
|
+
* this spec — 0 in every other mode.
|
|
793
|
+
*
|
|
794
|
+
* Deliberately NOT a key on `normalizeLocal`'s return: that is the configured
|
|
795
|
+
* field set and nothing else, so a reporting-only value cannot drift into the
|
|
796
|
+
* synced shape (or a hash). Callers that want to SAY "N phases deferred" ask for
|
|
797
|
+
* it, at the cost of a second read of a handful of small files.
|
|
798
|
+
*/
|
|
799
|
+
function phasesWithheld(snapshotDir, config) {
|
|
800
|
+
const { frontmatter, phases } = readSnapshot(snapshotDir, config)
|
|
801
|
+
return phaseProjection(phases, specStatus(snapshotDir, frontmatter), config).withheld
|
|
802
|
+
}
|
|
803
|
+
|
|
598
804
|
// --- remote projection ------------------------------------------------------
|
|
599
805
|
|
|
600
806
|
// Map a remote workflow-state name back to the local lifecycle bucket (the
|
|
@@ -758,6 +964,9 @@ function stateSuggestions(config, workspaceStates) {
|
|
|
758
964
|
module.exports = {
|
|
759
965
|
stateSuggestions,
|
|
760
966
|
normalizeLocal,
|
|
967
|
+
phaseProjection,
|
|
968
|
+
phaseModeFor,
|
|
969
|
+
phasesWithheld,
|
|
761
970
|
lintPhases,
|
|
762
971
|
readSnapshot,
|
|
763
972
|
parseFrontmatter,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const { normalizeLocal } = require('./normalize.js')
|
|
18
|
+
const { normalizeLocal, phasesWithheld, phaseModeFor } = require('./normalize.js')
|
|
19
19
|
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase } = require('./base.js')
|
|
21
21
|
const { detectLegacyMirror } = require('./legacy.js')
|
|
@@ -30,6 +30,15 @@ function projectionOf(snapshotDir, config) {
|
|
|
30
30
|
description: local.description ?? null,
|
|
31
31
|
status: local.workflowState ?? null,
|
|
32
32
|
subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
|
|
33
|
+
// How many phases `mapping.phases: 'deferred'` is holding back. Reporting
|
|
34
|
+
// only — `snapshotOf`/`specIssueHash` read named fields, so this never
|
|
35
|
+
// reaches a hash and cannot make an unchanged spec look edited.
|
|
36
|
+
phasesWithheld: phasesWithheld(snapshotDir, config),
|
|
37
|
+
// The phase mode that resolved for THIS spec's bucket. Reporting only, on
|
|
38
|
+
// the same terms: a spec with no sub-issues has to read as deliberate rather
|
|
39
|
+
// than as phase files that failed to parse, and with `mapping.phases` now a
|
|
40
|
+
// per-bucket map, which mode applied is no longer readable off the config.
|
|
41
|
+
phaseMode: phaseModeFor(local.workflowState, config),
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -43,6 +52,14 @@ function push({ dir, snapshotDir, identifier, config }) {
|
|
|
43
52
|
// skill that applies this plan is exactly the consumer that would miss them.
|
|
44
53
|
const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
|
|
45
54
|
if (legacy) plan.legacy = legacy
|
|
55
|
+
// Same reasoning as `legacy`: carried ON THE PLAN, not as a stderr warning,
|
|
56
|
+
// because `--json` routes warnings to stderr and the skill applying the plan
|
|
57
|
+
// is the consumer that most needs to know the missing sub-issues are deliberate.
|
|
58
|
+
if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
|
|
59
|
+
// Always set, unlike the two above: the skill relaying this should not have to
|
|
60
|
+
// know that an absent field means `subissue`. `isEmptyPlan` and `snapshotOf`
|
|
61
|
+
// both read named fields, so an extra key cannot make a spec look edited.
|
|
62
|
+
plan.phaseMode = projection.phaseMode
|
|
46
63
|
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
47
64
|
}
|
|
48
65
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten markdown tables that sit INSIDE a list item, because Linear corrupts
|
|
5
|
+
* them.
|
|
6
|
+
*
|
|
7
|
+
* Measured on probe SKI-28 (2026-08-28): when Linear renders a table nested in a
|
|
8
|
+
* list item, every **data** cell loses its first N characters, where N is the
|
|
9
|
+
* list-content indent Linear renders at — 3 per ordered-list level, 2 per bullet
|
|
10
|
+
* level — regardless of the indent the source used. Source indents 3, 4 and 6
|
|
11
|
+
* all lose exactly 3. The header row is never touched, column-0 tables never
|
|
12
|
+
* corrupt, and the column count is irrelevant. Real damage from the field: the
|
|
13
|
+
* auth header `X-Extraction-Key` was stored as `Extraction-Key`.
|
|
14
|
+
*
|
|
15
|
+
* The engine passes the table through byte-identically — this is Linear's
|
|
16
|
+
* parser, not ours — but the projection is the only place that can stop the
|
|
17
|
+
* markdown reaching it in a shape it mangles. So nested tables are re-emitted as
|
|
18
|
+
* shapes SKI-28 proved survive nesting unchanged:
|
|
19
|
+
*
|
|
20
|
+
* - 2 columns → a bullet list (`- a — b`), the key/value case, and the shape
|
|
21
|
+
* the reporter hand-repaired in production
|
|
22
|
+
* - otherwise → a fenced code block wrapping the original rows verbatim
|
|
23
|
+
*
|
|
24
|
+
* This shapes the PROJECTION only. Repo files are never rewritten: the source
|
|
25
|
+
* markdown is valid and renders correctly in GitHub and every editor.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { fenceMask } = require('./task-block.js')
|
|
29
|
+
|
|
30
|
+
// A table row: optional indent, then a `|`-delimited line. We only ever act on
|
|
31
|
+
// indented ones — a column-0 table (the `## Phases` index, every Impact map) is
|
|
32
|
+
// rendered correctly by Linear and must project byte-identically.
|
|
33
|
+
const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
|
|
34
|
+
// The separator under the header — `|---|:--:|`. Its presence is what makes the
|
|
35
|
+
// block a table rather than prose that happens to contain pipes.
|
|
36
|
+
const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
|
|
37
|
+
|
|
38
|
+
// Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
|
|
39
|
+
// documented `` `a | b` `` alternation stays one cell instead of splitting.
|
|
40
|
+
function splitCells(body) {
|
|
41
|
+
const cells = []
|
|
42
|
+
let cur = ''
|
|
43
|
+
let code = false
|
|
44
|
+
for (const ch of body) {
|
|
45
|
+
if (ch === '`') code = !code
|
|
46
|
+
if (ch === '|' && !code) {
|
|
47
|
+
cells.push(cur.trim())
|
|
48
|
+
cur = ''
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
cur += ch
|
|
52
|
+
}
|
|
53
|
+
cells.push(cur.trim())
|
|
54
|
+
return cells
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rewrite every indented table in `md`. Returns the text unchanged when there is
|
|
59
|
+
* nothing nested to flatten.
|
|
60
|
+
* @param {string} md
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function flattenNestedTables(md) {
|
|
64
|
+
if (md == null) return md
|
|
65
|
+
const lines = String(md).split('\n')
|
|
66
|
+
const inFence = fenceMask(lines)
|
|
67
|
+
const out = []
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const header = ROW_RE.exec(lines[i])
|
|
71
|
+
// A table shown as an EXAMPLE inside a ``` block is documentation — often of
|
|
72
|
+
// this very bug — so it is left exactly as written.
|
|
73
|
+
if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
|
|
74
|
+
out.push(lines[i])
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const indent = header[1]
|
|
79
|
+
const rows = [splitCells(header[2])]
|
|
80
|
+
const raw = [lines[i], lines[i + 1]]
|
|
81
|
+
let j = i + 2
|
|
82
|
+
for (; j < lines.length && !inFence[j]; j++) {
|
|
83
|
+
const row = ROW_RE.exec(lines[j])
|
|
84
|
+
if (!row) break
|
|
85
|
+
rows.push(splitCells(row[2]))
|
|
86
|
+
raw.push(lines[j])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (rows[0].length === 2) {
|
|
90
|
+
// Header first, bolded: dropping it would lose content and inventing a
|
|
91
|
+
// caption would invent it.
|
|
92
|
+
out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
|
|
93
|
+
for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
|
|
94
|
+
} else {
|
|
95
|
+
out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
|
|
96
|
+
}
|
|
97
|
+
i = j - 1
|
|
98
|
+
}
|
|
99
|
+
return out.join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { flattenNestedTables, splitCells }
|
|
@@ -16,15 +16,23 @@ const DEFAULT_WIDTH = 80
|
|
|
16
16
|
|
|
17
17
|
// Start of a task bullet. The continuation lines that follow are any indented,
|
|
18
18
|
// non-empty lines that are not themselves a bullet or heading.
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// The mark is ANY single character, not just ` `/`x`. Projects use `[~]` for
|
|
21
|
+
// in-progress, `[>]` for deferred, `[-]` for dropped, and a parser that only
|
|
22
|
+
// knew ` xX` matched none of them — so the whole bullet was claimed by no block
|
|
23
|
+
// and vanished from the mirror (bug-phase-content-dropped). What the mark MEANS
|
|
24
|
+
// is nobody's business here; it is carried through verbatim and re-emitted as
|
|
25
|
+
// written. Only `x`/`X` counts as done (see `parseTaskLine`).
|
|
26
|
+
const TASK_START_RE = /^([ \t]*)-\s*\[([^\]])\]\s*(.*)$/
|
|
20
27
|
const CONTINUATION_RE = /^[ \t]+\S/
|
|
21
28
|
const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
|
|
22
29
|
// A list-marker line (unordered or ordered). Distinguished from other block
|
|
23
30
|
// breaks because a wrapped continuation can legitimately begin with one.
|
|
24
31
|
const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
|
|
25
32
|
// 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
|
-
|
|
33
|
+
// at any indent. (A bare list marker is ambiguous; a checkbox never is.) Any
|
|
34
|
+
// mark, matching TASK_START_RE.
|
|
35
|
+
const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[^\]]\]/
|
|
28
36
|
// A bare list bullet — no checkbox — captured with its marker so a sub-bullet
|
|
29
37
|
// is re-rendered as the `-`/`*`/`1.` its author wrote.
|
|
30
38
|
const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
|
|
@@ -181,7 +189,9 @@ function findTaskBlocks(lines) {
|
|
|
181
189
|
indent: t[1],
|
|
182
190
|
marker: '-',
|
|
183
191
|
checkbox: true,
|
|
184
|
-
|
|
192
|
+
// Case-folded for `x` (the one mark whose meaning we act on), otherwise
|
|
193
|
+
// verbatim — a project's `~`/`>`/`-` must round-trip as written.
|
|
194
|
+
mark: t[2].toLowerCase() === 'x' ? 'x' : t[2],
|
|
185
195
|
text: collapseHyphenAware(parts.join('\n')),
|
|
186
196
|
})
|
|
187
197
|
open.push(indent)
|
|
@@ -189,9 +199,10 @@ function findTaskBlocks(lines) {
|
|
|
189
199
|
continue
|
|
190
200
|
}
|
|
191
201
|
|
|
192
|
-
// A bare bullet is claimed ONLY inside an open task's subtree
|
|
193
|
-
//
|
|
194
|
-
//
|
|
202
|
+
// A bare bullet is claimed ONLY inside an open task's subtree — there it is
|
|
203
|
+
// part of the task and must be re-rendered with it. Outside one it is
|
|
204
|
+
// ordinary prose, which the projection now passes through verbatim, so
|
|
205
|
+
// claiming it here would only re-wrap a list nobody asked us to touch.
|
|
195
206
|
if (!open.length) continue
|
|
196
207
|
const b = BULLET_RE.exec(line)
|
|
197
208
|
if (!b) continue
|