@skitterbyte/skitterspec-linear 6.0.0 → 7.0.1

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.
@@ -159,9 +159,11 @@ Every spec is a **folder** `specs/<bucket>/<name>/` — never a bare file, even
159
159
  simple changes. Inside it:
160
160
 
161
161
  - `00-overview.md` is the entry point / dashboard: header, Problem, Decisions,
162
- Solution overview, the **phase index** (a table linking to each phase file with
163
- its status), Open questions, State log, Changelog. **No per-phase task lists
164
- live here.**
162
+ Solution overview, the **Impact map** (a `Surface | Change | Detail` table
163
+ naming the concrete surfaces the spec touches endpoints, schemas, DB tables,
164
+ domain objects, routes, business rules — as the scannable blast radius), the
165
+ **phase index** (a table linking to each phase file with its status), Open
166
+ questions, State log, Changelog. **No per-phase task lists live here.**
165
167
  - **One file per phase** — `01-<phase-slug>.md`, `02-<phase-slug>.md`, … in
166
168
  execution order. Each holds that phase's goal, its task checkboxes (tests
167
169
  included), and any phase-specific notes. Even a single-phase spec gets `01-….md`
@@ -96,6 +96,26 @@ the spec — be specific.>
96
96
  <Short prose or bullets describing the chosen shape end-to-end. Optional small
97
97
  schema/grammar/output snippets where they remove ambiguity.>
98
98
 
99
+ ## Impact
100
+
101
+ <The concrete surfaces this spec touches — the scannable blast radius, so a
102
+ reader can eyeball where the spec got something wrong without reading prose.
103
+ `Change` is `add` · `update` · `remove`. `Surface` is guided-but-open: use
104
+ values like Endpoint, Route/UI, Schema/model, DB table/migration, Domain object,
105
+ Service, CLI command, Config key, Skill/rule, Business rule — or whatever fits
106
+ this project (skitterspec itself is a CLI with no HTTP surface). Keep `Detail`
107
+ terse — names/signatures, not sentences. List **only** surfaces that actually
108
+ change; the heading is always present, but if nothing external changes write the
109
+ single line below instead of an empty table.>
110
+
111
+ | Surface | Change | Detail |
112
+ |---------|--------|--------|
113
+ | <e.g. Endpoint> | add | <e.g. POST /orders> |
114
+ | <e.g. DB> | update | <e.g. orders (+status col)> |
115
+
116
+ <_No external surface changes — internal refactor only._ — use this line in
117
+ place of the table when the spec touches no external surface.>
118
+
99
119
  ## Phases
100
120
 
101
121
  Each phase lives in its own file in this folder. Status: ⬜ not started ·
@@ -163,6 +183,13 @@ Rules for the spec body:
163
183
  phase index.
164
184
  - **Honour project conventions** when writing tasks — reference the relevant
165
185
  `.claude/rules/*.md` rather than re-explaining them.
186
+ - **The `## Impact` table is derived from Phase A items 3 (Affected areas) & 5
187
+ (Data/API impact)** — a structured place to record what those already surface,
188
+ not new grilling. It is the scannable substitute for spelling impact out in
189
+ prose: name the surfaces (endpoints, schemas, DB tables, domain objects,
190
+ routes, business rules) instead of describing them, keep `Detail` terse, and
191
+ let it — not paragraphs — carry the blast radius. It complements the `Area:`
192
+ header (files) by naming behavioural surfaces.
166
193
  - **Changelog** is mandatory and lives in the spec. Every later decision or
167
194
  course-correction gets a dated one-line entry. Convert relative dates to
168
195
  absolute.
@@ -120,6 +120,26 @@ because work is already underway. Keep it lean:
120
120
  commands — confirm no regressions.
121
121
  - [ ] <any follow-up hardening, or "None">
122
122
 
123
+ ## Impact
124
+
125
+ <The concrete surfaces this spec touches — the scannable blast radius, so a
126
+ reader can eyeball where the spec got something wrong without reading prose.
127
+ `Change` is `add` · `update` · `remove`. `Surface` is guided-but-open: use
128
+ values like Endpoint, Route/UI, Schema/model, DB table/migration, Domain object,
129
+ Service, CLI command, Config key, Skill/rule, Business rule — or whatever fits
130
+ this project (skitterspec itself is a CLI with no HTTP surface). Keep `Detail`
131
+ terse — names/signatures, not sentences. List **only** surfaces that actually
132
+ change; the heading is always present, but if nothing external changes write the
133
+ single line below instead of an empty table. A bug fix often changes no external
134
+ surface — that's fine, use the one-liner.>
135
+
136
+ | Surface | Change | Detail |
137
+ |---------|--------|--------|
138
+ | <e.g. Endpoint> | update | <e.g. GET /orders (fix null total)> |
139
+
140
+ <_No external surface changes — internal refactor only._ — use this line in
141
+ place of the table when the spec touches no external surface.>
142
+
123
143
  ## State log
124
144
 
125
145
  | Date | Status | Folder | By |
@@ -101,6 +101,16 @@ is finished — e.g. to run a later phase in CI or a shared test env? Use
101
101
  the normal landing plan. Commit any live fixes to the branch first; it refuses
102
102
  if the primary checkout is dirty, or if a *different* spec holds it (release that
103
103
  one with `/spec-live main`). Teardown (step 7) is unchanged.
104
+ **Work-loss abort.** Before it ends the live session, `integrate` checks the
105
+ work is actually landable and **aborts loudly** rather than finalize a spec
106
+ having landed nothing. Two cases, both leaving the live session intact:
107
+ - *stranded commits* — commits sit on the worktree's **detached HEAD** (e.g. a
108
+ pre-fix `/spec-go` committed there instead of on the branch). It prints the
109
+ count, the sha, and a `git -C <worktree> branch <tmp> <sha>` recovery hint —
110
+ recover those commits onto the branch, then re-run.
111
+ - *no worktree* — the spec is live but its worktree is gone. Re-isolate it with
112
+ `skitterspec spec-env up <name>`, then re-run.
113
+ Relay the diagnostic to the user and **stop** — do not proceed to teardown.
104
114
  2. **Plan + execute.** Run `skitterspec spec-env integrate <name>` and run the
105
115
  printed commands **in order**:
106
116
  - `git -C <worktree> rebase <base>` — replay the branch onto base.
@@ -22,9 +22,24 @@ Diverting your browser to the spec is a separate explicit step — `/spec-connec
22
22
 
23
23
  ## 2. Move it into development
24
24
 
25
- **If per-spec isolation is enabled** (`specs/.core/env.config.json` exists) and
26
- the spec doesn't already have a worktree, provision it **first**, so all the
27
- housekeeping below lands on the spec's branch and never on `main`:
25
+ **Live check first (isolation only).** If per-spec isolation is enabled, before
26
+ provisioning run `skitterspec spec-env live status <name>` and read its `live:`
27
+ line. If it says **`live: yes`**, this spec is already checked out in the
28
+ **primary checkout** (you took it live with `/spec-live`) — **do not provision,
29
+ do not run `spec-env up`, and do not "work in the worktree"**. Its branch lives
30
+ in the primary checkout and its worktree is on a **detached HEAD**, so a commit
31
+ made in the worktree would strand on that detached HEAD and never reach the
32
+ branch. Instead skip the provisioning bullets and step 2b, leave the spec where
33
+ it is, and go straight to **step 4**, implementing the phase **in the primary
34
+ checkout on the branch** — edits and commits there advance the branch, and
35
+ `/spec-complete` lands them. (`spec-env up` refuses while live and says the same.
36
+ To return to an isolated worktree instead, run `/spec-live main` first, then
37
+ re-run `/spec-go`.)
38
+
39
+ **If per-spec isolation is enabled** (`specs/.core/env.config.json` exists), the
40
+ spec **isn't already live** (the check above), and it doesn't already have a
41
+ worktree, provision it **first**, so all the housekeeping below lands on the
42
+ spec's branch and never on `main`:
28
43
 
29
44
  **Opt-out:** if the user passes `--no-worktree` (or explicitly asks to work in
30
45
  place), skip the provisioning bullets below and build on the current branch — the
@@ -115,6 +115,26 @@ is usually a single-pass fix, so the `## Fix` block can live directly in
115
115
  commands — confirm no regressions.
116
116
  - [ ] <any follow-up hardening, or "None">
117
117
 
118
+ ## Impact
119
+
120
+ <The concrete surfaces this spec touches — the scannable blast radius, so a
121
+ reader can eyeball where the spec got something wrong without reading prose.
122
+ `Change` is `add` · `update` · `remove`. `Surface` is guided-but-open: use
123
+ values like Endpoint, Route/UI, Schema/model, DB table/migration, Domain object,
124
+ Service, CLI command, Config key, Skill/rule, Business rule — or whatever fits
125
+ this project (skitterspec itself is a CLI with no HTTP surface). Keep `Detail`
126
+ terse — names/signatures, not sentences. List **only** surfaces that actually
127
+ change; the heading is always present, but if nothing external changes write the
128
+ single line below instead of an empty table. A hotfix should be minimal — often
129
+ no external surface changes; that's fine, use the one-liner.>
130
+
131
+ | Surface | Change | Detail |
132
+ |---------|--------|--------|
133
+ | <e.g. Endpoint> | update | <e.g. GET /orders (fix null total)> |
134
+
135
+ <_No external surface changes — internal refactor only._ — use this line in
136
+ place of the table when the spec touches no external surface.>
137
+
118
138
  ## Landing
119
139
 
120
140
  - [ ] Deploy tag (patch bump of the base version) created at `/spec-complete`
@@ -26,6 +26,13 @@ trust the spec's own wording — verify:
26
26
  - **Referenced things still exist.** Grep/read for each `file:line`, module,
27
27
  function, route, model, type, or symbol the spec names. Flag anything renamed,
28
28
  moved, or deleted.
29
+ - **Impact map matches reality.** Walk every row of the `## Impact` table and
30
+ verify against the code: does the named surface (endpoint, schema/model, DB
31
+ table/migration, domain object, route, business rule) exist, and does its
32
+ stated `Change` (add/update/remove) still hold? A stale Impact map is the
33
+ highest-signal drift — each row is a named, checkable surface, so a wrong row
34
+ is exactly the "obvious mistake" the map exists to surface. Flag/refresh stale
35
+ rows (and add rows for surfaces the spec now touches but omits).
29
36
  - **Tasks already done.** For each `- [ ]`, check whether the code already
30
37
  implements it (it may have landed via other work). Tick `- [x]` what's done.
31
38
  - **Decisions still valid.** Re-check each `## Decisions` entry against the
@@ -47,9 +54,9 @@ reading the code, do that instead of asking.
47
54
 
48
55
  ## 4. Update the spec
49
56
 
50
- - Rewrite stale **Decisions** / **Solution overview** in `00-overview.md` and
51
- stale **tasks** in the phase files so they match the current code and the
52
- resolved questions. Add/remove tasks within a phase file; add a new phase by
57
+ - Rewrite stale **Decisions** / **Solution overview** and refresh the **Impact
58
+ map** in `00-overview.md`, and stale **tasks** in the phase files, so they
59
+ match the current code and the resolved questions. Add/remove tasks within a phase file; add a new phase by
53
60
  creating a `0N-<slug>.md` file **and** a matching overview index row, or drop a
54
61
  dead phase by removing both. Keep the index and files in sync; **preserve
55
62
  completed `[x]` history**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -185,6 +185,19 @@ function specEnvUp(dir, config, specArg) {
185
185
  }
186
186
  const spec = resolveSpec(specArg, dir, config)
187
187
 
188
+ // Live-safe: if this spec is already live on the primary checkout (its branch was
189
+ // branch-switched in by `live take`), a `git worktree add` would fail — the branch
190
+ // is checked out there. Point the operator at the primary checkout rather than
191
+ // emit a plan that can't run (see spec feat-live-spec-flow).
192
+ const primaryUp = assertPrimaryOnMain(config, gitReader(dir))
193
+ if (!primaryUp.onBase && primaryUp.branch === spec.branch) {
194
+ process.stdout.write(
195
+ `spec-env up: ${spec.folder} is live in the primary checkout — work there directly ` +
196
+ '(its branch is checked out), or run `/spec-live main` first to re-isolate its worktree.\n',
197
+ )
198
+ return
199
+ }
200
+
188
201
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
189
202
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
190
203
  // every provision for teammates who only cloned and ran /spec-go.
@@ -567,6 +580,33 @@ function specEnvIntegrate(dir, config, specArg) {
567
580
  )
568
581
  return
569
582
  }
583
+ // Work-loss guard — runs BEFORE the destructive `checkout base` that ends the
584
+ // live session. Ending the session must leave landable work behind; if it
585
+ // wouldn't, abort loudly instead of silently finalizing the spec with nothing
586
+ // landed (see spec feat-live-spec-flow).
587
+ if (!fs.existsSync(spec.worktreePath)) {
588
+ process.stdout.write(
589
+ `spec-env integrate: blocked — ${spec.folder} is live but has no worktree to land ` +
590
+ `from. Re-isolate it with \`skitterspec spec-env up ${spec.folder}\`, then re-run.\n`,
591
+ )
592
+ return
593
+ }
594
+ const liveWtGit = gitReader(spec.worktreePath)
595
+ if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
596
+ // Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
597
+ // non-live-aware /spec-go) would be abandoned by the re-isolate `switch` below.
598
+ const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
599
+ const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
600
+ if (stranded !== null && Number(stranded) > 0) {
601
+ process.stdout.write(
602
+ `spec-env integrate: blocked — ${stranded} commit(s) are stranded on the detached ` +
603
+ `HEAD of ${spec.worktreePath} (at ${head}), ahead of ${spec.branch}; re-isolating ` +
604
+ `would abandon them. Recover with \`git -C ${spec.worktreePath} branch <tmp> ${head}\`, ` +
605
+ `reconcile onto ${spec.branch}, then re-run.\n`,
606
+ )
607
+ return
608
+ }
609
+ }
570
610
  const co = runGit(dir, ['checkout', base])
571
611
  if (!co.ok) {
572
612
  process.stdout.write(`spec-env integrate: could not check out ${base} — ${co.err}\n`)
@@ -912,7 +952,7 @@ async function specEnvLive(dir, config, positional) {
912
952
  const action = positional[0] || 'status'
913
953
  switch (action) {
914
954
  case 'status':
915
- specEnvLiveStatus(dir, config)
955
+ specEnvLiveStatus(dir, config, positional[1])
916
956
  break
917
957
  case 'take':
918
958
  await specEnvLiveTake(dir, config, positional[1])
@@ -1146,8 +1186,28 @@ async function specEnvLiveAbort(dir, config) {
1146
1186
  )
1147
1187
  }
1148
1188
 
1149
- function specEnvLiveStatus(dir, config) {
1189
+ function specEnvLiveStatus(dir, config, specArg) {
1150
1190
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1191
+
1192
+ // Per-spec query (`live status <spec>`): a clear yes/no verdict the /spec-go
1193
+ // skill branches on to decide whether to skip worktree provisioning and work in
1194
+ // the primary checkout. The stable `live: yes|no` line is the machine seam.
1195
+ if (specArg) {
1196
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1197
+ const live = !onBase && branch === spec.branch
1198
+ process.stdout.write(
1199
+ `spec-env live status: ${spec.folder}\n` +
1200
+ ` spec: ${spec.folder} (branch ${spec.branch})\n` +
1201
+ ` primary: ${branch || '(detached)'}\n` +
1202
+ ` live: ${
1203
+ live
1204
+ ? `yes — ${spec.folder} holds the primary checkout; work there`
1205
+ : `no — primary is on ${branch || '(detached)'}`
1206
+ }\n`,
1207
+ )
1208
+ return
1209
+ }
1210
+
1151
1211
  const receipt = readReceipt(dir, config)
1152
1212
  const state = onBase
1153
1213
  ? 'on base — free'
@@ -16,6 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs')
18
18
  const path = require('node:path')
19
+ const { findTaskBlocks, collapse } = require('./task-block.js')
19
20
 
20
21
  // --- markdown / frontmatter parsing -----------------------------------------
21
22
 
@@ -165,10 +166,15 @@ function readPhaseFiles(snapshotDir) {
165
166
  .map((file) => {
166
167
  const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
167
168
  const { data, body } = parseFrontmatter(raw)
168
- const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
169
- const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
170
- t.replace(/^-\s*/, '').trim(),
171
- )
169
+ // No /m on either: under /m, `$` matches end-of-LINE, so a non-greedy
170
+ // scan stops at the first newline and a hand-wrapped bullet or goal loses
171
+ // every continuation line. Tasks come from findTaskBlocks, which reads a
172
+ // wrapped bullet as one logical task.
173
+ // Collapsed, not just captured: the goal becomes a milestone description,
174
+ // and Linear may canonicalize a soft line break away on save. Collapsing
175
+ // both sides keeps a wrapped goal from diffing forever.
176
+ const goal = collapse((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
177
+ const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
172
178
  return {
173
179
  phase: file.replace(/\.md$/, ''),
174
180
  file,
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+
3
+ // Task bullets in a phase file are hand-wrapped prose, not single lines:
4
+ //
5
+ // - [x] Add `DbProcessEventOutbox` to `prisma/schema.prisma`, modelled on
6
+ // `DbNotificationOutbox`: status, attempts, `nextAttemptAt`, …
7
+ //
8
+ // A Linear issue title is single-line, so the two representations differ by
9
+ // wrapping alone. This module is the one place that converts between them:
10
+ // `findTaskBlocks` reads wrapped bullets into logical tasks, `renderTaskBlock`
11
+ // writes a logical task back out re-wrapped in the file's own style.
12
+ //
13
+ // Everything here is line-index based so callers can splice whole blocks.
14
+
15
+ const DEFAULT_WIDTH = 80
16
+
17
+ // Start of a task bullet. The continuation lines that follow are any indented,
18
+ // non-empty lines that are not themselves a bullet or heading.
19
+ const TASK_START_RE = /^([ \t]*)-\s*\[([ xX])\]\s*(.*)$/
20
+ const CONTINUATION_RE = /^[ \t]+\S/
21
+ const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
22
+
23
+ // Collapse a wrapped bullet's lines into the single logical line the rest of the
24
+ // sync engine (and Linear) works in.
25
+ function collapse(text) {
26
+ return String(text).replace(/\s+/g, ' ').trim()
27
+ }
28
+
29
+ /**
30
+ * Find every task bullet in `lines` as a logical block.
31
+ * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
32
+ * `end` is exclusive. `text` is the collapsed single-line form, id included.
33
+ */
34
+ function findTaskBlocks(lines) {
35
+ const blocks = []
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const m = TASK_START_RE.exec(lines[i])
38
+ if (!m) continue
39
+ const parts = [m[3]]
40
+ let j = i + 1
41
+ for (; j < lines.length; j++) {
42
+ const l = lines[j]
43
+ if (!l.trim()) break
44
+ if (!CONTINUATION_RE.test(l)) break
45
+ if (BLOCK_BREAK_RE.test(l)) break
46
+ parts.push(l.trim())
47
+ }
48
+ blocks.push({
49
+ start: i,
50
+ end: j,
51
+ indent: m[1],
52
+ mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
53
+ text: collapse(parts.join(' ')),
54
+ })
55
+ i = j - 1
56
+ }
57
+ return blocks
58
+ }
59
+
60
+ /**
61
+ * Render a logical task back into wrapped file lines, matching the surrounding
62
+ * style: `- [x] ` opener, continuations aligned under the text.
63
+ * @returns {string[]}
64
+ */
65
+ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
66
+ const opener = `${indent}- [${done ? 'x' : ' '}] `
67
+ const hang = ' '.repeat(opener.length)
68
+ const body = collapse(text) + (id ? ` (${id})` : '')
69
+
70
+ const out = []
71
+ let line = opener
72
+ let first = true
73
+ for (const word of body.split(' ')) {
74
+ if (!first && line.length + 1 + word.length > width) {
75
+ out.push(line)
76
+ line = hang + word
77
+ } else {
78
+ line += (first ? '' : ' ') + word
79
+ first = false
80
+ }
81
+ }
82
+ out.push(line)
83
+ return out
84
+ }
85
+
86
+ // Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
87
+ // different column. Falls back to the default when there's nothing to learn from.
88
+ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
89
+ const widths = lines.filter((l) => l.trim()).map((l) => l.length)
90
+ if (!widths.length) return fallback
91
+ const max = Math.max(...widths)
92
+ return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
93
+ }
94
+
95
+ module.exports = { findTaskBlocks, renderTaskBlock, collapse, inferWidth, DEFAULT_WIDTH }
@@ -18,6 +18,12 @@
18
18
 
19
19
  const fs = require('node:fs')
20
20
  const path = require('node:path')
21
+ const {
22
+ findTaskBlocks,
23
+ renderTaskBlock,
24
+ collapse,
25
+ inferWidth,
26
+ } = require('./task-block.js')
21
27
 
22
28
  // Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
23
29
  // key is dropped (caller shouldn't pass those).
@@ -222,33 +228,31 @@ function applyMilestonesPull(snapshotDir, items) {
222
228
 
223
229
  // --- task-line denormalizer (keyed issue pull) -----------------------------
224
230
  //
225
- // Tasks live as checkbox lines inside phase files. A pulled issue edit rewrites
226
- // the matching line (by its inline id) in place; a Linear-only issue appends a
227
- // new task line; a created issue's id is stamped inline. Removals report-only.
231
+ // Tasks live as (hand-wrapped) checkbox bullets inside phase files. A pulled
232
+ // issue edit rewrites the matching bullet whole block, re-wrapped by its
233
+ // inline id; a Linear-only issue appends a new bullet; a created issue's id is
234
+ // stamped inline. Removals report-only. See task-block.js for the wrapping.
228
235
 
229
- const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
230
236
  const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
231
237
 
232
- // Render a task line from an item.
233
- function taskLine(indent, { id, text, done }) {
234
- return `${indent}- [${done ? 'x' : ' '}] ${text}${id ? ` (${id})` : ''}`
235
- }
236
-
237
- // Update the task line carrying inline id `id` (text + checkbox), in place.
238
+ // Update the task carrying inline id `id` (text + checkbox), in place.
239
+ //
240
+ // Block-aware: a task bullet is hand-wrapped across several lines, so the whole
241
+ // block is replaced and the new text re-wrapped in the file's own style. Editing
242
+ // only the first line would strand its continuation lines as orphaned prose.
238
243
  function updateTaskLine(snapshotDir, id, { text, done }) {
239
244
  const want = String(id)
240
245
  for (const file of listPhaseFiles(snapshotDir)) {
241
246
  const p = path.join(snapshotDir, file)
242
247
  const lines = fs.readFileSync(p, 'utf-8').split('\n')
243
- for (let i = 0; i < lines.length; i++) {
244
- const m = TASK_RE.exec(lines[i])
245
- if (!m) continue
246
- const idm = INLINE_ID_RE.exec(m[3])
247
- if (idm && idm[1] === want) {
248
- lines[i] = taskLine(m[1], { id: want, text, done })
249
- fs.writeFileSync(p, lines.join('\n'), 'utf-8')
250
- return true
251
- }
248
+ const width = inferWidth(lines)
249
+ for (const b of findTaskBlocks(lines)) {
250
+ const idm = INLINE_ID_RE.exec(b.text)
251
+ if (!idm || idm[1] !== want) continue
252
+ const rendered = renderTaskBlock({ indent: b.indent, done, text, id: want }, width)
253
+ lines.splice(b.start, b.end - b.start, ...rendered)
254
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
255
+ return true
252
256
  }
253
257
  }
254
258
  return false
@@ -262,11 +266,10 @@ function addTaskLine(snapshotDir, item) {
262
266
  if (!file) return null
263
267
  const p = path.join(snapshotDir, file)
264
268
  const lines = fs.readFileSync(p, 'utf-8').split('\n')
265
- let lastTask = -1
266
- for (let i = 0; i < lines.length; i++) if (TASK_RE.test(lines[i])) lastTask = i
267
- const line = taskLine('', item)
268
- if (lastTask >= 0) lines.splice(lastTask + 1, 0, line)
269
- else lines.push(line)
269
+ const blocks = findTaskBlocks(lines)
270
+ const rendered = renderTaskBlock({ indent: '', ...item }, inferWidth(lines))
271
+ if (blocks.length) lines.splice(blocks[blocks.length - 1].end, 0, ...rendered)
272
+ else lines.push(...rendered)
270
273
  fs.writeFileSync(p, lines.join('\n'), 'utf-8')
271
274
  return file
272
275
  }
@@ -274,18 +277,21 @@ function addTaskLine(snapshotDir, item) {
274
277
  // Stamp an inline id onto the (idless) task line whose text matches — used after
275
278
  // the skill creates an issue for a new local task.
276
279
  function stampIssueId(snapshotDir, text, id) {
277
- const want = String(text).trim()
280
+ const want = collapse(text)
278
281
  for (const file of listPhaseFiles(snapshotDir)) {
279
282
  const p = path.join(snapshotDir, file)
280
283
  const lines = fs.readFileSync(p, 'utf-8').split('\n')
281
- for (let i = 0; i < lines.length; i++) {
282
- const m = TASK_RE.exec(lines[i])
283
- if (!m || INLINE_ID_RE.test(m[3])) continue
284
- if (m[3].trim() === want) {
285
- lines[i] = `${m[1]}- [${m[2].toLowerCase() === 'x' ? 'x' : ' '}] ${want} (${id})`
286
- fs.writeFileSync(p, lines.join('\n'), 'utf-8')
287
- return file
288
- }
284
+ const width = inferWidth(lines)
285
+ for (const b of findTaskBlocks(lines)) {
286
+ if (INLINE_ID_RE.test(b.text)) continue
287
+ if (b.text !== want) continue
288
+ const rendered = renderTaskBlock(
289
+ { indent: b.indent, done: b.mark === 'x', text: want, id },
290
+ width,
291
+ )
292
+ lines.splice(b.start, b.end - b.start, ...rendered)
293
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
294
+ return file
289
295
  }
290
296
  }
291
297
  return null