@skitterbyte/skitterspec-linear 9.1.0 → 10.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.
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Detect a spec whose mirror was created under the **pre-9.0 model**.
5
+ *
6
+ * v9 remapped the mirror: a spec is an issue (was a Project), a phase is a
7
+ * sub-issue (was a Milestone), and tasks are no longer objects at all. The
8
+ * frontmatter keys moved with it — `linear_project_id` → `linear_identifier`,
9
+ * `linear_milestone_id` → `linear_issue_id`.
10
+ *
11
+ * The failure this guards is silent and destructive: v9 looks for the new keys,
12
+ * finds nothing, and produces a perfectly ordinary **all-creates** plan. Applying
13
+ * it mints a fresh mirror and abandons the old one — in the field that would have
14
+ * been 17 new objects against 2 projects, 15 milestones and 145 task issues left
15
+ * orphaned, with nothing on screen suggesting a prior mirror existed. It was
16
+ * caught only because an all-creates plan looked wrong for specs synced an hour
17
+ * earlier.
18
+ *
19
+ * Pure reads; returns `null` for anything that is not demonstrably pre-9.0, so a
20
+ * never-pushed spec is never mistaken for a stranded one.
21
+ */
22
+
23
+ const fs = require('node:fs')
24
+ const path = require('node:path')
25
+
26
+ const { parseFrontmatter } = require('./normalize.js')
27
+ const { readBase } = require('./base.js')
28
+ const { listPhaseFiles } = require('./write.js')
29
+
30
+ // Frontmatter keys only the pre-9.0 model ever wrote.
31
+ const LEGACY_OVERVIEW_KEY = 'linear_project_id'
32
+ const LEGACY_PHASE_KEY = 'linear_milestone_id'
33
+
34
+ function frontmatterOf(file) {
35
+ try {
36
+ return parseFrontmatter(fs.readFileSync(file, 'utf-8')).data || {}
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+
42
+ // A pre-9.0 snapshot recorded `{project, milestones, issues}`; v9 records
43
+ // `{issue, subIssues}`. Counting what it holds turns the warning from "this
44
+ // looks old" into "this many live objects would be abandoned".
45
+ function countOrphans(snapshot) {
46
+ if (!snapshot || typeof snapshot !== 'object') return null
47
+ const size = (v) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0)
48
+ const projects = snapshot.project ? 1 : 0
49
+ const milestones = size(snapshot.milestones)
50
+ const issues = size(snapshot.issues)
51
+ if (!projects && !milestones && !issues) return null
52
+ return { projects, milestones, issues, total: projects + milestones + issues }
53
+ }
54
+
55
+ /**
56
+ * @returns {null|{keys:string[], files:string[], orphans:object|null, orphanCount:number}}
57
+ */
58
+ function detectLegacyMirror({ dir, snapshotDir, identifier, config }) {
59
+ const keys = []
60
+ const files = []
61
+
62
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
63
+ const overview = frontmatterOf(path.join(snapshotDir, overviewFile))
64
+ if (overview[LEGACY_OVERVIEW_KEY] != null) {
65
+ keys.push(LEGACY_OVERVIEW_KEY)
66
+ files.push(overviewFile)
67
+ }
68
+
69
+ for (const file of listPhaseFiles(snapshotDir)) {
70
+ if (frontmatterOf(path.join(snapshotDir, file))[LEGACY_PHASE_KEY] != null) {
71
+ if (!keys.includes(LEGACY_PHASE_KEY)) keys.push(LEGACY_PHASE_KEY)
72
+ files.push(file)
73
+ }
74
+ }
75
+
76
+ let orphans = null
77
+ try {
78
+ orphans = countOrphans(readBase(dir, identifier, config))
79
+ } catch {
80
+ /* an unreadable snapshot is not evidence either way */
81
+ }
82
+
83
+ // A legacy snapshot alone is enough: the spec may have had its frontmatter
84
+ // hand-cleaned while the mirror it names is still live.
85
+ if (!keys.length && !orphans) return null
86
+
87
+ return { keys, files, orphans, orphanCount: orphans ? orphans.total : 0 }
88
+ }
89
+
90
+ module.exports = { detectLegacyMirror }
@@ -253,14 +253,53 @@ function phaseTitle(body) {
253
253
  return t || null
254
254
  }
255
255
 
256
+ // The raw ⬜/🔄/✅ on a phase file's h1, or undefined when it carries none.
257
+ // Kept separate from `phaseStateBucket` because the LINT has to tell "absent"
258
+ // apart from "not-started" — the projection deliberately cannot (see below).
259
+ function headingEmoji(body) {
260
+ const h1 = /^#\s+(.*)$/m.exec(body)
261
+ return h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
262
+ }
263
+
264
+ // The `> **Status:** …` line's value, or null when the file has none. This is
265
+ // the human mirror of the heading emoji, not a source of truth — `lintPhases`
266
+ // cross-checks it, and nothing else reads it.
267
+ function phaseStatusLine(body) {
268
+ const m = /^>.*\*\*Status:\*\*\s*(.+?)\s*$/m.exec(body)
269
+ return m ? m[1] : null
270
+ }
271
+
272
+ // Read a `> **Status:**` value leniently into the canonical vocabulary: an emoji
273
+ // if it carries one, else a word we recognise. Returns null for anything else —
274
+ // the line is free prose, and warning on an unrecognised phrasing would train
275
+ // the warning away.
276
+ // `pending` is deliberately NOT here: "pending review" matches as not-started
277
+ // but means roughly the opposite, and a false positive is worse than a missed
278
+ // check — it teaches the reader to ignore the warning.
279
+ const STATUS_WORDS = [
280
+ [/\b(not[\s-]?started|todo|to[\s-]do|planned)\b/i, 'not-started'],
281
+ [/\b(in[\s-]?progress|started|doing|wip)\b/i, 'in-progress'],
282
+ [/\b(done|complete[d]?|finished|shipped)\b/i, 'done'],
283
+ ]
284
+ function statusLineValue(line) {
285
+ if (!line) return null
286
+ const emoji = (line.match(/[⬜🔄✅]/u) || [])[0]
287
+ if (emoji) return EMOJI_STATUS[emoji]
288
+ for (const [re, status] of STATUS_WORDS) if (re.test(line)) return status
289
+ return null
290
+ }
291
+
256
292
  // A phase's status (from its heading emoji ⬜/🔄/✅) mapped to a state bucket the
257
293
  // `states` table understands, so a sub-issue lands in the matching Linear issue
258
294
  // state. Unknown/absent → backlog.
295
+ //
296
+ // That fallback conflates "author marked it not-started" with "author used a
297
+ // format we don't parse", which is silent corruption: the wrong state pushes
298
+ // cleanly and `record` then commits it as the INTENDED value. The fix is not
299
+ // leniency here — one convention beats two — it is `lintPhases`, which warns.
259
300
  const PHASE_STATE_BUCKET = { 'not-started': 'backlog', 'in-progress': 'in-progress', done: 'complete' }
260
301
  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'
302
+ return PHASE_STATE_BUCKET[EMOJI_STATUS[headingEmoji(body)]] || 'backlog'
264
303
  }
265
304
 
266
305
  // Parse a task line (already stripped of its leading "- ") into a keyed item:
@@ -303,7 +342,20 @@ function readPhaseFiles(snapshotDir) {
303
342
  // and Linear may canonicalize a soft line break away on save. Collapsing
304
343
  // both sides keeps a wrapped goal from diffing forever.
305
344
  const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
306
- const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
345
+ // Rendered as markdown checklist lines, ready to drop into a sub-issue
346
+ // description: indentation kept so nesting survives, each bullet keeping
347
+ // the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
348
+ // line stripped — those ids were per-task issues we no longer create, and
349
+ // they read as noise in the mirror.
350
+ const tasks = findTaskBlocks(body.split('\n')).map((b) => {
351
+ // `findTaskBlocks` also returns the plain sub-bullets written underneath
352
+ // a task. They carry no checkbox, so they render as the bullet their
353
+ // author used — emitting `- [ ]` here would invent a task that does not
354
+ // exist in the repo.
355
+ const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
356
+ const text = parsed ? parsed.text : b.text
357
+ return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
358
+ })
307
359
  return {
308
360
  phase: file.replace(/\.md$/, ''),
309
361
  file,
@@ -312,11 +364,85 @@ function readPhaseFiles(snapshotDir) {
312
364
  name: phaseTitle(body),
313
365
  goal: goal.trim(),
314
366
  state: phaseStateBucket(body),
367
+ // Lint-only signals. `emoji` is undefined when the heading carries none
368
+ // — the distinction `state` throws away; `statusLine` is the raw
369
+ // `> **Status:**` value. Neither affects the projection.
370
+ emoji: headingEmoji(body),
371
+ statusLine: phaseStatusLine(body),
315
372
  tasks,
316
373
  }
317
374
  })
318
375
  }
319
376
 
377
+ // --- phase-status lint ------------------------------------------------------
378
+
379
+ /**
380
+ * Warn where a phase's status signals are absent or disagree.
381
+ *
382
+ * A spec carries the same status in three places — the phase file's h1 emoji,
383
+ * its `> **Status:**` line, and the `00-overview.md` phase-index row — and only
384
+ * the h1 is load-bearing. Writing the other two correctly while leaving the h1
385
+ * bare projects a finished phase as `backlog`, pushes cleanly, and records the
386
+ * wrong value as intended. Nothing looks wrong anywhere.
387
+ *
388
+ * Returns `[{ file, code, message }]`, `code` being `missing-status-emoji` or
389
+ * `status-disagreement`. Pure aside from reads; callers decide how loud to be
390
+ * (today: printed, never fatal).
391
+ */
392
+ function lintPhases(snapshotDir, config) {
393
+ const phases = readPhaseFiles(snapshotDir)
394
+ if (!phases.length) return []
395
+
396
+ // The overview may be absent (a legacy bare `<name>.md` spec) — that is not
397
+ // itself a lint failure, it just removes one of the three cross-checks.
398
+ let indexRows = []
399
+ try {
400
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
401
+ const raw = fs.readFileSync(path.join(snapshotDir, overviewFile), 'utf-8')
402
+ const { sections } = parseSections(parseFrontmatter(raw).body)
403
+ indexRows = parsePhaseIndex(sections.Phases)
404
+ } catch {
405
+ indexRows = []
406
+ }
407
+
408
+ const warnings = []
409
+ phases.forEach((phase, i) => {
410
+ if (!phase.emoji) {
411
+ warnings.push({
412
+ file: phase.file,
413
+ code: 'missing-status-emoji',
414
+ message: `no ⬜/🔄/✅ in the heading — projecting as not-started`,
415
+ })
416
+ // Without a heading emoji there is nothing to disagree WITH: the other two
417
+ // signals can't be checked against a value that was never expressed.
418
+ return
419
+ }
420
+
421
+ const heading = EMOJI_STATUS[phase.emoji]
422
+
423
+ const fromLine = statusLineValue(phase.statusLine)
424
+ if (fromLine && fromLine !== heading) {
425
+ warnings.push({
426
+ file: phase.file,
427
+ code: 'status-disagreement',
428
+ message: `heading says ${heading} but its Status line says ${fromLine}`,
429
+ })
430
+ }
431
+
432
+ // Match the index row by phase title, falling back to position — a renamed
433
+ // phase shouldn't silently drop the check.
434
+ const row = indexRows.find((r) => r.name === phase.name) || indexRows[i]
435
+ if (row && row.status !== heading) {
436
+ warnings.push({
437
+ file: phase.file,
438
+ code: 'status-disagreement',
439
+ message: `heading says ${heading} but the overview phase-index row says ${row.status}`,
440
+ })
441
+ }
442
+ })
443
+ return warnings
444
+ }
445
+
320
446
  // --- ownership-driven field set ---------------------------------------------
321
447
 
322
448
  // Reduce an `extracted` map to exactly the configured field keys, defaulting a
@@ -359,6 +485,22 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
359
485
  return canonicalizeMarkdown(parts.join('\n\n')) || null
360
486
  }
361
487
 
488
+ // A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
489
+ // list as a markdown checklist when `mapping.tasks` is `checklist`.
490
+ //
491
+ // The checklist is a READ-ONLY mirror like everything else here — the repo is
492
+ // the source of truth, so a box ticked in the tracker is overwritten on the next
493
+ // push. Without it a sub-issue is a title and one sentence, which is too thin to
494
+ // act on; with it the phase is legible to someone working in the tracker without
495
+ // tasks becoming individually-synced objects again.
496
+ function subIssueBody(phase, tasksMode) {
497
+ if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
498
+ const parts = []
499
+ if (phase.goal) parts.push(phase.goal, '')
500
+ parts.push('## Tasks', '', ...phase.tasks)
501
+ return parts.join('\n')
502
+ }
503
+
362
504
  /**
363
505
  * Normalize a local spec snapshot into the configured field set.
364
506
  */
@@ -376,6 +518,7 @@ function normalizeLocal(snapshotDir, config) {
376
518
  // so strip the `## Phases` index from the description to avoid duplicating it
377
519
  // (as prose AND as sub-issues) in the Linear mirror.
378
520
  const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
521
+ const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
379
522
  const extracted = {
380
523
  description: buildDescription(
381
524
  title,
@@ -386,11 +529,12 @@ function normalizeLocal(snapshotDir, config) {
386
529
  // Sub-issue projection: one per phase. `ref` is the phase-file basename — the
387
530
  // local handle the push skill stamps a newly-created sub-issue id back into.
388
531
  // `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.
532
+ // Linear issue state via `config.states` at push time. Tasks ride along in
533
+ // the description as a read-only checklist (`mapping.tasks`), never as
534
+ // individually-synced objects.
391
535
  subIssues: phases
392
536
  .filter((p) => p.name)
393
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal, state: p.state })),
537
+ .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: subIssueBody(p, tasksMode), state: p.state })),
394
538
  // Status is the spec's lifecycle bucket. The folder is the source of truth;
395
539
  // an explicit `spec_status` frontmatter key overrides it if present.
396
540
  workflowState:
@@ -524,8 +668,45 @@ function validateStates(config, workspaceStates) {
524
668
  return configured.filter((name) => !have.has(name.toLowerCase().trim()))
525
669
  }
526
670
 
671
+ // Words that identify a workspace state as belonging to a lifecycle bucket.
672
+ // Used only to SUGGEST a replacement for a configured name the workspace does
673
+ // not have — never to pick one silently. The 8→9 case this exists for is
674
+ // `complete`, where the correct value inverts: the project status `Completed`
675
+ // became the issue state `Done`, and no string-distance measure gets you from
676
+ // one to the other.
677
+ const BUCKET_WORDS = {
678
+ backlog: ['backlog', 'triage', 'todo', 'to do'],
679
+ 'in-progress': ['in progress', 'in-progress', 'doing', 'started', 'in review'],
680
+ complete: ['done', 'complete', 'completed', 'shipped', 'merged', 'released'],
681
+ cancelled: ['canceled', 'cancelled', 'abandoned', "won't do", 'wont do', 'duplicate'],
682
+ }
683
+
684
+ /**
685
+ * For each configured state name the workspace lacks, what to use instead.
686
+ *
687
+ * `validateStates` says a name is wrong; this says what is right, which is the
688
+ * difference between an error you can act on and one you have to go look up.
689
+ *
690
+ * @returns {Array<{bucket:string, configured:string, suggestion:string|null}>}
691
+ */
692
+ function stateSuggestions(config, workspaceStates) {
693
+ const names = (workspaceStates || []).map((s) => String(s)).filter(Boolean)
694
+ const have = new Set(names.map((n) => n.toLowerCase().trim()))
695
+ const out = []
696
+ for (const [bucket, configured] of Object.entries((config && config.states) || {})) {
697
+ if (typeof configured !== 'string') continue
698
+ if (have.has(configured.toLowerCase().trim())) continue
699
+ const words = BUCKET_WORDS[bucket] || []
700
+ const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
701
+ out.push({ bucket, configured, suggestion })
702
+ }
703
+ return out
704
+ }
705
+
527
706
  module.exports = {
707
+ stateSuggestions,
528
708
  normalizeLocal,
709
+ lintPhases,
529
710
  readSnapshot,
530
711
  parseFrontmatter,
531
712
  parseSections,
@@ -18,6 +18,7 @@
18
18
  const { normalizeLocal } = require('./normalize.js')
19
19
  const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
20
  const { readBase, writeBase } = require('./base.js')
21
+ const { detectLegacyMirror } = require('./legacy.js')
21
22
 
22
23
  // Build the one-way projection from a local snapshot: the spec issue's prose +
23
24
  // status, and its phase sub-issues. `status` is the local lifecycle bucket; the
@@ -36,6 +37,12 @@ function push({ dir, snapshotDir, identifier, config }) {
36
37
  const projection = projectionOf(snapshotDir, config)
37
38
  const snapshot = readBase(dir, identifier, config)
38
39
  const plan = planChanges(projection, snapshot)
40
+ // A spec still linked under the pre-9.0 model reads as unlinked here, so the
41
+ // plan above is all-creates and would abandon a live mirror. Carry the finding
42
+ // ON THE PLAN, not as a warning: `--json` routes warnings to stderr, and the
43
+ // skill that applies this plan is exactly the consumer that would miss them.
44
+ const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
45
+ if (legacy) plan.legacy = legacy
39
46
  return { ok: true, empty: isEmptyPlan(plan), plan, projection }
40
47
  }
41
48
 
@@ -25,6 +25,43 @@ const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
25
25
  // A checkbox bullet — unambiguously a task, so it always starts its own block,
26
26
  // at any indent. (A bare list marker is ambiguous; a checkbox never is.)
27
27
  const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
28
+ // A bare list bullet — no checkbox — captured with its marker so a sub-bullet
29
+ // is re-rendered as the `-`/`*`/`1.` its author wrote.
30
+ const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
31
+
32
+ function indentWidth(line) {
33
+ return line.length - line.trimStart().length
34
+ }
35
+
36
+ // Collect the wrapped continuation lines following the bullet opening at
37
+ // `start`, seeded with the opener's own text. Shared by task bullets and plain
38
+ // sub-bullets — both wrap the same way, and both stop at the first line that
39
+ // belongs to something else.
40
+ function collectContinuation(lines, start, hang, inFence, parts) {
41
+ let j = start + 1
42
+ for (; j < lines.length; j++) {
43
+ const l = lines[j]
44
+ if (!l.trim()) break
45
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
46
+ if (!CONTINUATION_RE.test(l)) break
47
+ if (BLOCK_BREAK_RE.test(l)) {
48
+ // Indent alone can't tell a nested child from a wrapped continuation —
49
+ // both sit at the hanging indent. The *marker* is the reliable signal:
50
+ // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
51
+ // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
52
+ // sits exactly at the hang; shallower or deeper it's a real sub/
53
+ // sibling list → break, and the caller's scan claims it as a block of
54
+ // its own. (Keeping the at-hang continuation preserves the task's
55
+ // stamped id, so the next push updates instead of creating a
56
+ // duplicate issue.)
57
+ // - headings, quotes, tables and fences always break.
58
+ if (CHECKBOX_RE.test(l)) break
59
+ if (!LIST_MARKER_RE.test(l) || indentWidth(l) !== hang) break
60
+ }
61
+ parts.push(l.trim())
62
+ }
63
+ return { end: j, parts }
64
+ }
28
65
 
29
66
  // Mark every line that lies inside a fenced code block — the opening fence, its
30
67
  // content, and the closing fence all count as `true`. Line scanners that hunt
@@ -107,51 +144,68 @@ function spanMask(body) {
107
144
  }
108
145
 
109
146
  /**
110
- * Find every task bullet in `lines` as a logical block.
111
- * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
147
+ * Find every task bullet in `lines` as a logical block, plus the non-checkbox
148
+ * bullets that live inside a task's list subtree.
149
+ * @returns {Array<{start:number, end:number, indent:string, marker:string,
150
+ * checkbox:boolean, mark:string|null, text:string}>}
112
151
  * `end` is exclusive. `text` is the collapsed single-line form, id included.
152
+ * `checkbox` is false for a plain sub-bullet; its `mark` is then null and its
153
+ * `marker` is the bullet it was written with (`-`, `*`, `1.` …).
113
154
  */
114
155
  function findTaskBlocks(lines) {
115
156
  const blocks = []
116
157
  const inFence = fenceMask(lines)
158
+ // The marker indents of the task bullets whose list subtree is still open,
159
+ // outermost first. Without this the scan had no model of nesting at all: a
160
+ // bare bullet that dedented out of a nested checkbox belonged to nothing and
161
+ // was silently dropped, taking its wrapped continuations with it.
162
+ const open = []
163
+
117
164
  for (let i = 0; i < lines.length; i++) {
165
+ const line = lines[i]
166
+ // A blank line alone doesn't close a subtree — a loose list is still a list.
167
+ // What closes it is a later line at or shallower than the task's own indent.
168
+ if (!line.trim()) continue
169
+ const indent = indentWidth(line)
170
+ while (open.length && indent <= open[open.length - 1]) open.pop()
118
171
  if (inFence[i]) continue // an example bullet inside a ``` block is not a task
119
- const m = TASK_START_RE.exec(lines[i])
120
- if (!m) continue
121
- const parts = [m[3]]
122
- // The hanging indent: where the bullet's text starts (marker width). A
123
- // wrapped continuation aligns exactly AT this column.
124
- const hang = lines[i].length - m[3].length
125
- let j = i + 1
126
- for (; j < lines.length; j++) {
127
- const l = lines[j]
128
- if (!l.trim()) break
129
- if (inFence[j]) break // a fence opening ends the bullet, never continues it
130
- if (!CONTINUATION_RE.test(l)) break
131
- if (BLOCK_BREAK_RE.test(l)) {
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
142
- const indent = l.length - l.trimStart().length
143
- if (!LIST_MARKER_RE.test(l) || indent !== hang) break
144
- }
145
- parts.push(l.trim())
172
+
173
+ const t = TASK_START_RE.exec(line)
174
+ if (t) {
175
+ // The hanging indent: where the bullet's text starts (marker width). A
176
+ // wrapped continuation aligns exactly AT this column.
177
+ const { end, parts } = collectContinuation(lines, i, line.length - t[3].length, inFence, [t[3]])
178
+ blocks.push({
179
+ start: i,
180
+ end,
181
+ indent: t[1],
182
+ marker: '-',
183
+ checkbox: true,
184
+ mark: t[2].toLowerCase() === 'x' ? 'x' : ' ',
185
+ text: collapseHyphenAware(parts.join('\n')),
186
+ })
187
+ open.push(indent)
188
+ i = end - 1
189
+ continue
146
190
  }
191
+
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.
195
+ if (!open.length) continue
196
+ const b = BULLET_RE.exec(line)
197
+ if (!b) continue
198
+ const { end, parts } = collectContinuation(lines, i, line.length - b[3].length, inFence, [b[3]])
147
199
  blocks.push({
148
200
  start: i,
149
- end: j,
150
- indent: m[1],
151
- mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
201
+ end,
202
+ indent: b[1],
203
+ marker: b[2],
204
+ checkbox: false,
205
+ mark: null,
152
206
  text: collapseHyphenAware(parts.join('\n')),
153
207
  })
154
- i = j - 1
208
+ i = end - 1
155
209
  }
156
210
  return blocks
157
211
  }
@@ -128,6 +128,7 @@ function stampIssueId(snapshotDir, text, id) {
128
128
  const lines = fs.readFileSync(p, 'utf-8').split('\n')
129
129
  const width = inferWidth(lines)
130
130
  for (const b of findTaskBlocks(lines)) {
131
+ if (!b.checkbox) continue // a plain sub-bullet is not a task — never stamp one
131
132
  if (INLINE_ID_RE.test(b.text)) continue
132
133
  if (b.text !== want) continue
133
134
  const rendered = renderTaskBlock({ indent: b.indent, done: b.mark === 'x', text: want, id }, width)