@skitterbyte/skitterspec-linear 8.0.0 → 8.0.2

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # @skitterbyte/skitterspec-linear
2
2
 
3
3
  Spec-driven development for [Claude Code](https://claude.com/claude-code), **with
4
- Linear hybrid-sync**. A strict **superset** of
4
+ one-way Linear sync**. A strict **superset** of
5
5
  [`@skitterbyte/skitterspec`](https://www.npmjs.com/package/@skitterbyte/skitterspec):
6
- everything in the base filesystem workflow, plus git-like sync between a spec and
7
- its linked Linear project.
6
+ everything in the base filesystem workflow, plus one-way sync from a spec up to
7
+ its linked Linear project — the repo is canonical, Linear is a generated mirror.
8
8
 
9
9
  ```sh
10
10
  npx @skitterbyte/skitterspec-linear init
@@ -46,27 +46,26 @@ fuller guide):
46
46
  That file is the opt-in gate — until it exists, everything below is inert and
47
47
  the package behaves exactly like the base.
48
48
 
49
- 4. **Link and sync** — `/spec` now creates a linked Linear Project (a Milestone
50
- per phase) and stamps the id; then `/spec-status`, `/spec-pull`, `/spec-push`
51
- keep the spec and its project in step. Optionally turn on the per-Milestone /
52
- per-Issue **body round-trip** (see below).
49
+ 4. **Link and push** — `/spec` creates a linked Linear Project (a Milestone per
50
+ phase) and stamps the id; then `/spec-push` publishes the spec up and
51
+ `/spec-status` reports what would push. Sync is **one-way**: the repo is the
52
+ source of truth and Linear is a generated mirror.
53
53
 
54
54
  ## What the superset adds
55
55
 
56
56
  On top of the base skills (`/spec`, `/spec-go`, isolation, …):
57
57
 
58
- - **`/spec-status`** — read-only, per-field divergence (local-only / remote-only /
59
- conflict / in-sync). Changes nothing.
60
- - **`/spec-pull [--force]`** — Linearrepo. Applies remote-only fields; refuses
61
- to clobber a conflicting local edit unless `--force`.
62
- - **`/spec-push [--force]`** repo Linear. Ownership-respecting,
63
- concurrency-checked; refuses if Linear moved since base unless `--force`.
58
+ - **`/spec-status`** — read-only drift report: what the next push would create /
59
+ update, plus any workflow-state drift. Changes nothing.
60
+ - **`/spec-push`** — repoLinear, one-way. Diffs the spec against a committed
61
+ last-pushed snapshot and applies only what changed (project description +
62
+ status, milestones, issues), stamping the returned ids back into the spec.
64
63
  - **`spec-sync` CLI** (`skitterspec-linear spec-sync …`) — the deterministic
65
64
  engine behind the skills, for CI / local runs.
66
65
 
67
66
  The shared `/spec` and `/spec-go` skills come composed with the Linear steps
68
- filled in: `/spec` links a new spec to a Linear Project (a Milestone per phase),
69
- and `/spec-go` pulls first so you build against the current shared state.
67
+ filled in: `/spec` links a new spec to a Linear Project (a Milestone per phase).
68
+ There is no pull the repo is already canonical, so `/spec-go` just builds.
70
69
 
71
70
  ## Opt-in
72
71
 
@@ -78,16 +77,15 @@ behaves exactly like the base.
78
77
  setup guide — connecting the `linear` MCP server, finding your team id, linking a
79
78
  spec, and a smoke test. Per-field docs live in `specs/.core/linear.config.md`.
80
79
 
81
- **What syncs:** by default the whole spec body travels as the Linear Project
82
- **`description`** (co-authored, push + pull); **status / priority / labels** are
83
- Linear-owned (pull only). **Field ownership** (`both` / `pull` / `push`) collapses
84
- conflicts only a `both` field that moved on both sides is a real conflict, and
85
- `--force` backs up the losing side before winning. **Opt into a per-Milestone /
86
- per-Issue body round-trip** phases Linear Milestones and tasks Issues,
87
- compared per item by adding `milestones`/`tasks` to `sync.keyedFields` (see the
88
- "Body round-trip" section of `linear.config.md`); deletions there are report-only.
89
- **Base sidecars** (`specs/.core/linear-base/`) are committed; **backups**
90
- (`specs/.core/linear-backups/`) are gitignored.
80
+ **What pushes:** the spec body travels as the Linear Project **`description`**,
81
+ phases as **Milestones**, tasks as **Issues** (a short first-sentence title, the
82
+ full task text as the description), and the lifecycle bucket sets the project's
83
+ **workflow state**. Priority, labels, cycles and comments are **Linear-native
84
+ triage** the PM's to set in Linear; one-way sync neither pushes nor reads them,
85
+ so they're never clobbered. A workflow-state a teammate moves in Linear is
86
+ surfaced by `/spec-status` as drift and overwritten on the next push. **Last-pushed
87
+ snapshots** (`specs/.core/linear-base/`, content hashes) are committed so push
88
+ sends only what changed.
91
89
 
92
90
  Branch naming that embeds the Linear id lives in the isolation config
93
91
  (`env.config.json` → `branch.pattern` with `{identifier}`, `branch.identifierField:
@@ -168,3 +168,9 @@ With a linked spec, confirm push end-to-end:
168
168
  (`Backlog / Planned / In Progress / Completed / Canceled`).
169
169
  - **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
170
170
  workspace. Remove and re-add the server to authenticate against another.
171
+ - **Bold around an inline code span renders oddly in Linear** — Linear moves the
172
+ closing `**` before an inline code span on save (`**no unresolved `` `X` ``**`
173
+ → `**no unresolved** `` `X` ``). This is a Linear rendering quirk in the mirror
174
+ only; it never touches your repo (one-way sync never reads content back), so
175
+ it's cosmetic. Avoid wrapping a whole phrase that ends in code in bold if the
176
+ mirror's rendering matters to you.
@@ -23,12 +23,11 @@
23
23
  },
24
24
  "sync": {
25
25
  "baseDir": "specs/.core/linear-base",
26
- "backupDir": "specs/.core/linear-backups",
27
26
  "fieldOwnership": {
28
- "description": "both",
29
- "workflowState": "pull",
30
- "priority": "pull",
31
- "labels": "pull"
27
+ "description": "push",
28
+ "milestones": "push",
29
+ "tasks": "push",
30
+ "workflowState": "push"
32
31
  },
33
32
  "localOnlySections": ["State log", "Changelog", "Open questions"],
34
33
  "keyedFields": {}
@@ -61,101 +61,69 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
61
61
  "pattern": "{type}/{slug}"
62
62
  },
63
63
 
64
- // The three-way merge engine's on-disk state.
64
+ // One-way push engine state.
65
65
  "sync": {
66
- // Committed base sidecar dir: the last-synced snapshot per spec, as
67
- // {baseDir}/{identifier}.base.json. Committed so each worktree carries its
68
- // own base and the divergence check stays accurate.
66
+ // Committed last-pushed snapshot dir: content hashes of the last push per
67
+ // spec, as {baseDir}/{identifier}.base.json. Committed so each worktree
68
+ // carries its own, and push knows what changed without reading Linear back.
69
69
  "baseDir": "specs/.core/linear-base",
70
70
 
71
- // Backup-before-force lands the about-to-be-clobbered side here (the
72
- // reflog). --force never destroys without first writing a copy.
73
- "backupDir": "specs/.core/linear-backups",
74
-
75
- // Per-field sync direction collapses which fields can ever conflict:
76
- // "both" co-authored: push + pull, may conflict (both moved off base).
77
- // "pull" — Linear→local only (e.g. status/priority); a local edit never
78
- // pushes and a conflict resolves to remote-wins.
79
- // "push" — local→Linear only; a remote edit never pulls and a conflict
80
- // resolves to local-wins.
81
- // Any field key you add here joins the compared field set; a value outside
82
- // both|pull|push is rejected at load time. The default set is scoped to the
83
- // fields that round-trip through the live skill today: the whole spec body
84
- // travels as `description`; status/priority/labels are Linear-owned (pull).
85
- // A phase/milestone or per-issue round-trip is a future extension — opt in by
86
- // adding `milestones`/`phaseBodies`/`taskBreakdown` here once that write path
87
- // exists (see the deferred body write-back note in the package README).
71
+ // The pushed projection field set (repo → Linear, one-way). The `push` marker
72
+ // is retained for shape; there is no pull. The default set is the project
73
+ // `description`, `milestones` (one per phase), `tasks` (one issue each), and
74
+ // the lifecycle `workflowState`. Priority, labels, cycles and comments are
75
+ // Linear-native triagedeliberately NOT here, so a PM's triage is never
76
+ // touched. Any key you add joins the pushed projection.
88
77
  "fieldOwnership": {
89
- "description": "both",
90
- "workflowState": "pull",
91
- "priority": "pull",
92
- "labels": "pull"
78
+ "description": "push",
79
+ "milestones": "push",
80
+ "tasks": "push",
81
+ "workflowState": "push"
93
82
  },
94
83
 
95
84
  // Markdown sections of 00-overview.md that are local-only scaffolding and
96
85
  // are stripped from the pushed `description` (never sent to Linear).
97
86
  "localOnlySections": ["State log", "Changelog", "Open questions"],
98
87
 
99
- // OPT-IN body round-trip. Map a keyed collection field its item id key to
100
- // sync it per item (each phase ↔ a Milestone, each task an Issue) instead of
101
- // as one description blob. Empty by default. See "Body round-trip" below.
88
+ // Reserved. Milestones and tasks are always projected per item (each phase
89
+ // a Milestone, each task an Issue), so this no longer needs setting; it is
90
+ // validated but unused. Leave it `{}`.
102
91
  "keyedFields": {}
103
92
  }
104
93
  }
105
94
  ```
106
95
 
107
- ## Body round-trip (milestones & tasks) opt-in
96
+ ## Phases Milestones, tasks Issues
108
97
 
109
- By default the whole spec body travels as the project **`description`**. Opt a
110
- workspace into a finer-grained, bidirectional sync by adding the keyed fields:
98
+ Push maps the spec's structure to Linear's, keyed by id so it updates rather than
99
+ recreates:
111
100
 
112
- ```jsonc
113
- "sync": {
114
- "fieldOwnership": { "milestones": "both", "tasks": "both" },
115
- "keyedFields": { "milestones": "id", "tasks": "id" }
116
- }
117
- ```
118
-
119
- With this on:
120
-
121
- - **Phases ↔ Milestones.** Each phase file maps to a Linear Milestone. The link id
122
- lives in the phase file's frontmatter (`linear_milestone_id`); its title ← the
101
+ - **Phases → Milestones.** Each phase file maps to a Linear Milestone. The link id
102
+ lives in the phase file's frontmatter (`linear_milestone_id`); its name ← the
123
103
  phase h1, its description ← the phase `**Goal:**` line. The `Phases` index is
124
- then stripped from the pushed `description` (no duplication).
125
- - **Tasks Issues.** Each `- [ ]` task line maps to a Linear Issue. The link id
126
- is carried **inline** on the line — `- [ ] do the thing (SKI-123)`. Text ↔ the
127
- issue title; `[x]`/`[ ]` a completed / non-completed issue state.
128
- - **Per-item merge.** Items are compared by id, so editing milestone A locally and
129
- milestone B in Linear both apply; only the *same* item moving on both sides is a
130
- conflict.
131
- - **Deletions are report-only.** A phase/milestone or task/issue removed on either
132
- side is surfaced by `/spec-status` (and the pull/push summaries) for you to
133
- resolve by hand — it is never auto-deleted.
104
+ stripped from the pushed `description` (no duplication).
105
+ - **Tasks Issues.** Each `- [ ]` task line maps to a Linear Issue. The link id
106
+ is carried **inline** on the line — `- [ ] do the thing (SKI-123)`. The issue
107
+ **title** is the task's first sentence; the **description** is the full task
108
+ text; `[x]`/`[ ]` a completed / non-completed issue state.
134
109
 
135
110
  Unlinked local items (a new phase with no `linear_milestone_id`, a task with no
136
111
  inline id) are created in Linear on the next `/spec-push`, which stamps the new id
137
112
  back so they link from then on.
138
113
 
139
- ## Field ownership & conflicts
140
-
141
- The spec is a set of structured fields, most written by only one side. Marking a
142
- field's owner collapses which fields can genuinely conflict:
143
-
144
- - A `pull` field (Linear owns it) never reports as **pushable** — a stray local
145
- edit is informational and gets reverted on the next pull.
146
- - A `push` field (the repo owns it) never reports as **pullable**.
147
- - Only a `both` field where **both** sides moved off the committed base is a real
148
- `conflict` — `/spec-push` / `/spec-pull` refuse it unless `--force` (which
149
- backs up the losing side into `sync.backupDir` first).
114
+ ## One direction nothing to reconcile
150
115
 
151
- After any successful pull/push/force the engine **rewrites the base** so the next
152
- three-way compare starts clean.
116
+ The repo owns the spec and pushes it; there is no pull. `/spec-push` diffs the
117
+ current spec against the committed **last-pushed snapshot** and sends only what
118
+ changed — creates for unlinked items, updates for changed ones. A workflow-state a
119
+ teammate moves in Linear is surfaced by `/spec-status` as **drift** (advisory) and
120
+ overwritten on the next push. Priority, labels, cycles and comments are
121
+ Linear-native triage and are never touched. No base merge, no conflicts, no
122
+ `--force`.
153
123
 
154
124
  ## What to commit
155
125
 
156
- - **`sync.baseDir`** (default `specs/.core/linear-base/`) — **commit it.** The base
157
- sidecar is the last-synced snapshot the three-way merge compares against; each
158
- worktree carries its own base, so it must travel with the branch.
159
- - **`sync.backupDir`** (default `specs/.core/linear-backups/`) — **gitignore it.**
160
- These are `--force` recovery copies (a local reflog), per-machine and not shared.
161
- Add `specs/.core/linear-backups/` to your `.gitignore`.
126
+ - **`sync.baseDir`** (default `specs/.core/linear-base/`) — **commit it.** The
127
+ last-pushed snapshot is content hashes of the last push, so `/spec-push` knows
128
+ what changed without reading Linear back; each worktree carries its own, so it
129
+ must travel with the branch.
@@ -217,11 +217,11 @@ finish as above.
217
217
  ## Phase E — link to a ticketing provider (only if one is installed)
218
218
 
219
219
  **Only when a ticketing provider is installed and configured** (it ships the
220
- `/spec-push` · `/spec-pull` · `/spec-status` skills and a provider config under
220
+ `/spec-push` · `/spec-status` skills and a provider config under
221
221
  `specs/.core/`). If none is present, skip this phase entirely — the spec stays
222
222
  local-only and `/spec` behaves exactly as above. When a provider is present, link
223
223
  the spec to the tracker after writing it, so status and discussion live there
224
- while the repo stays the co-authoring surface — follow the provider's link steps
224
+ while the repo stays the source of truth — follow the provider's link steps
225
225
  below (nothing to do here without one).
226
226
 
227
227
  **Only when `specs/.core/linear.config.json` exists** (Linear sync is opted in).
@@ -129,12 +129,11 @@ Before writing any code for this phase, get the workspace clean:
129
129
  next phase on top of an uncommitted one. (Skip if this is the first phase —
130
130
  there's nothing prior to commit.)
131
131
 
132
- ## 3b. Pull from the tracker first (only if a provider is installed)
132
+ ## 3b. Sync with the tracker (only if a provider is installed)
133
133
 
134
- **Only when a ticketing provider with a `/spec-pull` skill is installed** and the
135
- spec is linked to the tracker. Otherwise skip this step — no provider means zero
136
- change to the flow below. Follow the provider's pull steps below (nothing to do
137
- here without one).
134
+ **Only when a ticketing provider is installed** and the spec is linked to the
135
+ tracker. Otherwise skip this step — no provider means zero change to the flow
136
+ below. Follow the provider's steps below (nothing to do here without one).
138
137
 
139
138
  **Only when `specs/.core/linear.config.json` exists** and the spec carries a
140
139
  `linear_project_id`. Otherwise skip this step — no config means zero change.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "8.0.0",
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.",
3
+ "version": "8.0.2",
4
+ "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
package/src/init.js CHANGED
@@ -259,6 +259,30 @@ function removeRetiredFiles(dir) {
259
259
  }
260
260
  }
261
261
 
262
+ // Prune a file the manifest records as managed but the current package no longer
263
+ // ships — a retired skill (e.g. `spec-pull` after the one-way switch), rule, or
264
+ // template. Without this, upgrading leaves a live, model-visible skill on disk
265
+ // whose instructions invoke a command that no longer exists. Delete it only when
266
+ // PRISTINE (still matches the hash we last wrote) so a user edit is never lost; a
267
+ // customized retired file is kept with a warning. An emptied skill folder is
268
+ // removed. Takes the pre-flush manifest (which still holds the retired entries).
269
+ function pruneRetiredManaged(dir, manifest) {
270
+ const managedRel = new Set(managedTargets(dir).map((t) => t.relPath))
271
+ for (const relPath of Object.keys(manifest.files || {})) {
272
+ if (managedRel.has(relPath)) continue // still shipped by this version
273
+ const abs = path.join(dir, relPath)
274
+ if (!fs.existsSync(abs)) continue // already gone
275
+ if (managedState(dir, relPath, manifest) === 'customized') {
276
+ report.warnings.push(`retired but kept (you edited it): ${relPath} — delete manually if unused`)
277
+ continue
278
+ }
279
+ fs.unlinkSync(abs)
280
+ report.removed.push(rel(dir, abs))
281
+ const folder = path.dirname(abs)
282
+ if (fs.existsSync(folder) && !fs.readdirSync(folder).length) fs.rmdirSync(folder)
283
+ }
284
+ }
285
+
262
286
  // Scaffold the opt-in isolation templates into specs/.core/ (the example config
263
287
  // + its field docs). Copied, not activated: the feature stays off until the
264
288
  // consumer copies env.config.json.example → env.config.json.
@@ -409,6 +433,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
409
433
  for (const t of managedTargets(dir)) resyncManagedFile(dir, t, manifest, force)
410
434
  installFolders(dir)
411
435
  removeRetiredFiles(dir)
436
+ pruneRetiredManaged(dir, manifest)
412
437
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
413
438
  flushManifest(dir)
414
439
  printReport(dir, 'resync')
Binary file
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Config loader for the Linear hybrid-sync feature (`/spec-status`, `/spec-pull`,
5
- * `/spec-push` and the Linear-aware paths of `/spec` and `/spec-go`).
4
+ * Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
5
+ * and the Linear-aware paths of `/spec` and `/spec-go`).
6
6
  *
7
7
  * Reads `specs/.core/linear.config.json` from the project root and normalises it
8
8
  * over frozen defaults. The feature is strictly opt-in: when the file is absent
@@ -54,21 +54,17 @@ const DEFAULT_CONFIG = Object.freeze({
54
54
  sync: Object.freeze({
55
55
  baseDir: 'specs/.core/linear-base',
56
56
  backupDir: 'specs/.core/linear-backups',
57
- // The synced field set. Kept to the fields that genuinely round-trip through
58
- // the live skill today: the project `description` (co-authored) plus the
59
- // Linear-owned status/priority/labels (pull-only). A spec's phase/milestone,
60
- // acceptance-criteria and task detail still travel *inside* `description` a
61
- // separate milestone/issue round-trip is a future extension (add the fields
62
- // here to opt a workspace in). Any key you add joins the compared set.
63
- // One-way (repo → Linear): every field is repo-owned and pushed. The
64
- // `push` marker is kept for the projection field-set; there is no pull.
57
+ // One-way (repo → Linear): the projection field set the repo owns and pushes
58
+ // the project `description`, `milestones` (one per phase), `tasks` (one
59
+ // issue each), and the lifecycle `workflowState`. There is no pull. Priority,
60
+ // labels, cycles and comments are Linear-native triage deliberately NOT in
61
+ // the set, so the PM's triage is never touched. The `push` marker is retained
62
+ // for shape; any key you add joins the pushed projection.
65
63
  fieldOwnership: Object.freeze({
66
64
  description: 'push',
67
65
  milestones: 'push',
68
66
  tasks: 'push',
69
67
  workflowState: 'push',
70
- priority: 'push',
71
- labels: 'push',
72
68
  }),
73
69
  localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
74
70
  // Fields that are keyed collections (arrays of objects with a stable id),
@@ -346,13 +346,16 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
346
346
  */
347
347
  function normalizeLocal(snapshotDir, config) {
348
348
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
349
- const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
349
+ // Phases sync as first-class Milestones whenever `milestones` is in the pushed
350
+ // projection, so strip the `## Phases` index from the description to avoid
351
+ // duplicating it (as prose AND as milestones) in the Linear mirror.
352
+ const milestonesProjected = !!(config.sync.fieldOwnership && 'milestones' in config.sync.fieldOwnership)
350
353
  const extracted = {
351
354
  description: buildDescription(
352
355
  title,
353
356
  sections,
354
357
  config.sync.localOnlySections,
355
- milestonesKeyed ? ['Phases'] : [],
358
+ milestonesProjected ? ['Phases'] : [],
356
359
  ),
357
360
  // Milestone projection items. `ref` is the phase-file basename — the local
358
361
  // handle the push skill stamps a newly-created milestone id back into.
@@ -430,6 +433,19 @@ function remoteWorkflowState(project, config) {
430
433
  return name != null ? bucketForState(name, config) : null
431
434
  }
432
435
 
436
+ // Trim a trailing parenthetical whose opener has no matching close — so a cut
437
+ // title never ends on a dangling `(`/`[`.
438
+ function dropUnclosedBracket(t) {
439
+ for (const [open, close] of [
440
+ ['(', ')'],
441
+ ['[', ']'],
442
+ ]) {
443
+ const o = t.lastIndexOf(open)
444
+ if (o !== -1 && t.indexOf(close, o) === -1) t = t.slice(0, o)
445
+ }
446
+ return t
447
+ }
448
+
433
449
  // Derive a short Linear issue title from a task's full text: the first sentence,
434
450
  // falling back to the first `max` chars at a word boundary. A paragraph-length
435
451
  // task keeps its full text as the issue *description* (see the projection); this
@@ -454,8 +470,23 @@ function titleFromText(text, max = 100) {
454
470
  title = title.trim()
455
471
  if (title.length > max) {
456
472
  const cut = title.slice(0, max)
457
- const sp = cut.lastIndexOf(' ')
458
- title = (sp > 40 ? cut.slice(0, sp) : cut).trim()
473
+ // Prefer the last clause boundary before the limit — a terminator/separator
474
+ // to cut AFTER (`.`/`;`/`:`…) or a dash to cut BEFORE over a bare word break.
475
+ const minCut = Math.floor(max * 0.4)
476
+ let boundary = -1
477
+ let mm
478
+ const after = /[.!?;:](?=\s|$)/g
479
+ while ((mm = after.exec(cut)) !== null) boundary = Math.max(boundary, mm.index + 1)
480
+ const before = /\s[—–]/g
481
+ while ((mm = before.exec(cut)) !== null) boundary = Math.max(boundary, mm.index)
482
+ let t
483
+ if (boundary > minCut) {
484
+ t = cut.slice(0, boundary)
485
+ } else {
486
+ const sp = cut.lastIndexOf(' ')
487
+ t = sp > 40 ? cut.slice(0, sp) : cut
488
+ }
489
+ title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
459
490
  }
460
491
  return title
461
492
  }
@@ -36,6 +36,29 @@ function cleanLogicalLine(text) {
36
36
  const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
37
37
  // Structural blocks we never reflow.
38
38
  const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
39
+ // A line that is its own structural block — a blockquote (`>`) or a table/pipe
40
+ // row (`|`). A hard boundary a join must never reach across (else the markers get
41
+ // absorbed into the preceding paragraph as literal text); copied verbatim.
42
+ const STRUCT_LINE_RE = /^[ \t]*[>|]/
43
+ // A fenced-code delimiter.
44
+ const FENCE_RE = /^[ \t]*```/
45
+
46
+ // A structural fingerprint of a document: the count of block-marker lines that a
47
+ // safe reflow must leave untouched. Used as a self-check — if sanitising changes
48
+ // any of these, the reflow corrupted structure (a blockquote absorbed, a fence
49
+ // eaten, a bullet collapsed) and the write is refused. Cheap insurance against the
50
+ // next corruption class, not just the ones we special-case.
51
+ function structuralSignature(text) {
52
+ let struct = 0 // blockquote or pipe-row lines
53
+ let fence = 0
54
+ let bullets = 0
55
+ for (const l of String(text).split('\n')) {
56
+ if (STRUCT_LINE_RE.test(l)) struct++
57
+ if (FENCE_RE.test(l)) fence++
58
+ if (BULLET_RE.test(l)) bullets++
59
+ }
60
+ return `${struct}|${fence}|${bullets}`
61
+ }
39
62
  // A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
40
63
  // dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
41
64
  // inline `code|span` doesn't make us treat a whole list as an untouchable table.
@@ -99,21 +122,28 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
99
122
  while (i < lines.length) {
100
123
  const line = lines[i]
101
124
  // Fenced code — copy verbatim through the closing fence.
102
- if (/^[ \t]*```/.test(line)) {
125
+ if (FENCE_RE.test(line)) {
103
126
  out.push(line)
104
127
  i++
105
- while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
128
+ while (i < lines.length && !FENCE_RE.test(lines[i])) out.push(lines[i++])
106
129
  if (i < lines.length) out.push(lines[i++])
107
130
  continue
108
131
  }
132
+ // Blockquote / table row — copy the whole run verbatim. A hard boundary: a
133
+ // join must never reach across it, so it also terminates any preceding block.
134
+ if (STRUCT_LINE_RE.test(line)) {
135
+ while (i < lines.length && STRUCT_LINE_RE.test(lines[i])) out.push(lines[i++])
136
+ continue
137
+ }
109
138
  if (!line.trim()) {
110
139
  out.push(line)
111
140
  i++
112
141
  continue
113
142
  }
114
- // Gather a block of consecutive non-blank, non-fence lines.
143
+ // Gather a block of consecutive non-blank lines, stopping at any structural
144
+ // boundary (fence, blockquote, table row) so a reflow can't absorb one.
115
145
  let j = i
116
- while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
146
+ while (j < lines.length && lines[j].trim() && !FENCE_RE.test(lines[j]) && !STRUCT_LINE_RE.test(lines[j])) j++
117
147
  const block = lines.slice(i, j)
118
148
  i = j
119
149
 
@@ -137,7 +167,13 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
137
167
  }
138
168
  }
139
169
  const result = out.join('\n')
170
+ // Self-check: a safe reflow never changes the count of structural markers. If it
171
+ // did, we corrupted something (a blockquote, fence, or bullet) — refuse the write
172
+ // and hand back the original untouched, flagged for the caller to report.
173
+ if (result !== src && structuralSignature(result) !== structuralSignature(src)) {
174
+ return { text: src, changed: false, fixes: 0, refused: true }
175
+ }
140
176
  return { text: result, changed: result !== src, fixes }
141
177
  }
142
178
 
143
- module.exports = { sanitizeSpecMarkdown, hasStraddle }
179
+ module.exports = { sanitizeSpecMarkdown, hasStraddle, structuralSignature }