@skitterbyte/skitterspec-linear 10.1.0 → 10.3.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.
@@ -307,7 +307,10 @@ function phaseStateBucket(body) {
307
307
  // its checkbox state, its text, and the inline Linear issue identifier if present
308
308
  // (`… (SKI-123)`). Returns null for a non-task line.
309
309
  function parseTaskLine(line) {
310
- const m = /^\[([ xX])\]\s*(.*)$/.exec(line)
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)
311
314
  if (!m) return null
312
315
  const done = m[1].toLowerCase() === 'x'
313
316
  let text = m[2].trim()
@@ -320,44 +323,31 @@ function parseTaskLine(line) {
320
323
  return { id, text, done }
321
324
  }
322
325
 
323
- /**
324
- * Split a phase's task blocks into the `##` sections they were written under.
325
- *
326
- * The checklist used to be one flat list under a hardcoded `## Tasks`, so a
327
- * criterion written under `## Acceptance` arrived in the mirror as an ordinary
328
- * open task. Nothing was lost — it was just unreadable.
329
- *
330
- * Grouping is done by MAPPING blocks onto headings, not by re-parsing the body
331
- * section by section: `findTaskBlocks` tracks open task subtrees across a
332
- * continuous body, and slicing that body at every heading would change what a
333
- * block claims at a section boundary. A heading inside a fence is not a heading
334
- * (`fenceMask`), and the phase file's own `#` H1 is not a section.
335
- *
336
- * @returns {Array<{heading:string|null, tasks:string[]}>} in source order;
337
- * `heading` is null for blocks that precede every heading, and a heading with
338
- * no blocks under it never appears.
339
- */
340
- function groupTasksByHeading(lines, blocks, renderTask) {
341
- const inFence = fenceMask(lines)
342
- const headings = []
343
- for (let i = 0; i < lines.length; i++) {
344
- if (inFence[i]) continue
345
- const m = /^(#{2,6})\s+(.*\S)\s*$/.exec(lines[i])
346
- if (m) headings.push({ line: i, heading: `${m[1]} ${m[2]}` })
347
- }
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.
348
330
 
349
- const groups = []
350
- for (const b of blocks) {
351
- let heading = null
352
- for (const h of headings) {
353
- if (h.line >= b.start) break
354
- heading = h.heading
355
- }
356
- const last = groups[groups.length - 1]
357
- if (last && last.heading === heading) last.tasks.push(renderTask(b))
358
- else groups.push({ heading, tasks: [renderTask(b)] })
359
- }
360
- return groups
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}`
361
351
  }
362
352
 
363
353
  // Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
@@ -382,25 +372,13 @@ function readPhaseFiles(snapshotDir) {
382
372
  // Collapsed, not just captured: the goal becomes a milestone description,
383
373
  // and Linear may canonicalize a soft line break away on save. Collapsing
384
374
  // both sides keeps a wrapped goal from diffing forever.
385
- const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
375
+ const goal = collapseHyphenAware(phaseGoal(body))
386
376
  // Rendered as markdown checklist lines, ready to drop into a sub-issue
387
377
  // description: indentation kept so nesting survives, each bullet keeping
388
- // the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
389
- // line stripped — those ids were per-task issues we no longer create, and
390
- // they read as noise in the mirror.
378
+ // the marker its author wrote.
391
379
  const lines = body.split('\n')
392
- const renderTask = (b) => {
393
- // `findTaskBlocks` also returns the plain sub-bullets written underneath
394
- // a task. They carry no checkbox, so they render as the bullet their
395
- // author used — emitting `- [ ]` here would invent a task that does not
396
- // exist in the repo.
397
- const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
398
- const text = parsed ? parsed.text : b.text
399
- return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
400
- }
401
380
  const blocks = findTaskBlocks(lines)
402
- const tasks = blocks.map(renderTask)
403
- const taskGroups = groupTasksByHeading(lines, blocks, renderTask)
381
+ const tasks = blocks.map(renderTaskBlockLine)
404
382
  return {
405
383
  phase: file.replace(/\.md$/, ''),
406
384
  file,
@@ -415,7 +393,11 @@ function readPhaseFiles(snapshotDir) {
415
393
  emoji: headingEmoji(body),
416
394
  statusLine: phaseStatusLine(body),
417
395
  tasks,
418
- taskGroups,
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,
419
401
  }
420
402
  })
421
403
  }
@@ -539,19 +521,127 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
539
521
  // push. Without it a sub-issue is a title and one sentence, which is too thin to
540
522
  // act on; with it the phase is legible to someone working in the tracker without
541
523
  // tasks becoming individually-synced objects again.
542
- function subIssueBody(phase, tasksMode) {
543
- if (tasksMode !== 'checklist' || !phase.tasks.length) return flattenNestedTables(phase.goal)
544
- const parts = []
545
- if (phase.goal) parts.push(phase.goal, '')
546
- // One section per source heading, in source order. Checkboxes written before
547
- // any heading keep the `## Tasks` default, so a phase file with a single task
548
- // section every one in this repo's corpus but two projects unchanged.
549
- const groups = phase.taskGroups && phase.taskGroups.length ? phase.taskGroups : [{ heading: null, tasks: phase.tasks }]
550
- groups.forEach((group, i) => {
551
- if (i) parts.push('')
552
- parts.push(group.heading || '## Tasks', '', ...group.tasks)
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
553
643
  })
554
- return flattenNestedTables(parts.join('\n'))
644
+ return [description, ...sections].filter(Boolean).join('\n\n') || null
555
645
  }
556
646
 
557
647
  /**
@@ -570,6 +660,35 @@ function bucketFromPath(snapshotDir) {
570
660
  // the states in which phases are not yet worth minting as sub-issues.
571
661
  const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
572
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
+
573
692
  /**
574
693
  * Which phases the projection sends, and how many the `deferred` mapping is
575
694
  * holding back. Pure — split out so both the projection and the CLI's "N phases
@@ -591,10 +710,25 @@ function specStatus(snapshotDir, frontmatter) {
591
710
 
592
711
  function phaseProjection(phases, workflowState, config) {
593
712
  const named = phases.filter((p) => p.name)
594
- const deferring =
595
- (config.mapping && config.mapping.phases) === 'deferred' && UNSTARTED_BUCKETS.includes(workflowState)
596
- const projected = deferring ? named.filter((p) => p.id != null) : named
597
- return { projected, withheld: named.length - projected.length }
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
+ }
598
732
  }
599
733
 
600
734
  function normalizeLocal(snapshotDir, config) {
@@ -606,22 +740,34 @@ function normalizeLocal(snapshotDir, config) {
606
740
  // status — so the issue's state and its sub-issues always agree on whether the
607
741
  // work has started, however that status was arrived at.
608
742
  const workflowState = specStatus(snapshotDir, frontmatter)
609
- const { projected, withheld } = phaseProjection(phases, workflowState, config)
610
-
611
- // Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
612
- // so strip the `## Phases` index from the description to avoid duplicating it
613
- // (as prose AND as sub-issues) in the Linear mirror. While deferral is holding
614
- // a phase back, that index is the ONLY place the phase appears stripping it
615
- // too would leave a backlog issue with no phase breakdown at all so it stays
616
- // until the sub-issues arrive to replace it.
617
- const phasesProjected =
618
- !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership) && withheld === 0
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
619
760
  const extracted = {
620
- description: buildDescription(
621
- title,
622
- sections,
761
+ description: withInlinePhases(
762
+ buildDescription(
763
+ title,
764
+ sections,
765
+ config.sync.localOnlySections,
766
+ phasesCarriedBySubIssues ? ['Phases'] : [],
767
+ ),
768
+ inlined,
769
+ tasksMode,
623
770
  config.sync.localOnlySections,
624
- phasesProjected ? ['Phases'] : [],
625
771
  ),
626
772
  // Sub-issue projection: one per phase. `ref` is the phase-file basename — the
627
773
  // local handle the push skill stamps a newly-created sub-issue id back into.
@@ -633,7 +779,7 @@ function normalizeLocal(snapshotDir, config) {
633
779
  id: p.id,
634
780
  ref: p.phase,
635
781
  name: p.name,
636
- goal: subIssueBody(p, tasksMode),
782
+ goal: subIssueBody(p, tasksMode, config.sync.localOnlySections),
637
783
  state: p.state,
638
784
  })),
639
785
  workflowState,
@@ -819,6 +965,7 @@ module.exports = {
819
965
  stateSuggestions,
820
966
  normalizeLocal,
821
967
  phaseProjection,
968
+ phaseModeFor,
822
969
  phasesWithheld,
823
970
  lintPhases,
824
971
  readSnapshot,
@@ -15,7 +15,7 @@
15
15
  * Date.now(). `recordPush` writes the snapshot sidecar.
16
16
  */
17
17
 
18
- const { normalizeLocal, phasesWithheld } = 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')
@@ -34,6 +34,11 @@ function projectionOf(snapshotDir, config) {
34
34
  // only — `snapshotOf`/`specIssueHash` read named fields, so this never
35
35
  // reaches a hash and cannot make an unchanged spec look edited.
36
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),
37
42
  }
38
43
  }
39
44
 
@@ -51,6 +56,10 @@ function push({ dir, snapshotDir, identifier, config }) {
51
56
  // because `--json` routes warnings to stderr and the skill applying the plan
52
57
  // is the consumer that most needs to know the missing sub-issues are deliberate.
53
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
54
63
  return { ok: true, empty: isEmptyPlan(plan), plan, projection }
55
64
  }
56
65
 
@@ -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
- const TASK_START_RE = /^([ \t]*)-\s*\[([ xX])\]\s*(.*)$/
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
- const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
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
- mark: t[2].toLowerCase() === 'x' ? 'x' : ' ',
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. Outside one
193
- // it is ordinary prose in the phase file: the projection is the task list,
194
- // not the whole body, and widening it here would mirror a Notes section.
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