@skitterbyte/skitterspec-linear 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Migration guide
2
2
 
3
+ ## `@skitterbyte/skitterspec-linear` v9 → v10 (`push` validates your issue states)
4
+
5
+ **`spec-sync push` now refuses to run until the configured `states` names have
6
+ been checked against your Linear workspace.** The check itself is not new — it
7
+ already existed on `spec-sync status --workspace-states` — but it was advisory,
8
+ and skipping it sent a state name Linear **silently ignores**: the description
9
+ lands, the issue never moves, and nothing errors. The base
10
+ `@skitterbyte/skitterspec` is unaffected.
11
+
12
+ ### Breaking change
13
+
14
+ | | v9 | v10 |
15
+ |---|-----|-----|
16
+ | `spec-sync push <spec>` | runs | **exits 1** unless `--workspace-states <file>` or `--skip-state-check` is passed |
17
+ | A configured state absent from the workspace | pushed, silently no-op | **exits 1**, naming the workspace's real states |
18
+
19
+ `/spec-push` handles this for you — it fetches the workspace's issue
20
+ workflow-state names over MCP and passes them on. **Nothing changes if you drive
21
+ sync through the skill.** Only a direct CLI caller needs updating.
22
+
23
+ ### What to do
24
+
25
+ 1. **Using `/spec-push`?** Nothing. Run `update` and carry on.
26
+ 2. **Calling `spec-sync push` from CI or a script?** Supply the workspace's issue
27
+ workflow-state names as a JSON array and pass the file:
28
+
29
+ ```sh
30
+ # names come from your Linear workspace, e.g. via the MCP server or the API
31
+ echo '["Backlog","Todo","In Progress","Done","Canceled"]' > states.json
32
+ skitterspec spec-sync push my-spec --workspace-states states.json --json
33
+ ```
34
+
35
+ Or opt out deliberately with `--skip-state-check`. Don't reach for it to get
36
+ past a *failing* check — that check is the only thing standing between you and
37
+ a push that moves nothing.
38
+ 3. **If the check refuses,** it tells you what your workspace actually has and,
39
+ where the lifecycle bucket makes it unambiguous, which name to use:
40
+
41
+ ```
42
+ spec-sync push: refusing — configured state name(s) not in the workspace
43
+
44
+ states.complete: "Done" is not an issue state in this workspace
45
+ use "Completed" instead
46
+
47
+ available: Backlog, Todo, In Progress, Completed, Canceled
48
+ ```
49
+
50
+ Fix `specs/.core/linear.config.json` → `states`. `/spec-push` will offer to
51
+ apply the fix for you.
52
+
53
+ ### Also in v10 (not breaking)
54
+
55
+ - **A pre-9.0 mirror is detected before it can be orphaned.** A spec still
56
+ carrying `linear_project_id` / `linear_milestone_id` reads as unlinked to v9+,
57
+ so `push` would emit an all-creates plan and abandon the live mirror. The plan
58
+ now carries a `legacy` field naming how many objects that would strand, and
59
+ `/spec-push` stops. **If you skipped the v8 → v9 migration below, read it now** —
60
+ this is the guard that catches you, not a substitute for it.
61
+ - **`update` says what it skipped** — each `customized (kept)` file now reports
62
+ `+added −removed`, with `--diff` to see the upstream changes you declined.
63
+ - **This guide now ships inside the package** (it wasn't in the published tarball
64
+ before v10 — `files` listed only `bin`/`src`/`assets`).
65
+
3
66
  ## `@skitterbyte/skitterspec-linear` v8 → v9 (a spec is an Issue, phases are sub-issues)
4
67
 
5
68
  **v9 remaps the Linear mirror.** A spec is now a Linear **issue** (not a Project),
@@ -48,6 +111,79 @@ phase**. The base `@skitterbyte/skitterspec` is unaffected (still v15).
48
111
  5. **Task-level issues** created under v8 are no longer managed by the sync —
49
112
  close or repurpose them in Linear by hand.
50
113
 
114
+ ## `@skitterbyte/skitterspec-linear` v7 → v8 (sync goes one-way; `/spec-pull` removed)
115
+
116
+ **v8 made sync one-way.** The repo became the sole source of truth and Linear a
117
+ **generated mirror**: content is pushed up, never read back or merged. The
118
+ three-way merge engine and everything that fed it were retired.
119
+
120
+ ### Breaking changes
121
+
122
+ | Area | v7 | v8 |
123
+ |------|-----|-----|
124
+ | Skills | `/spec-status`, `/spec-push`, **`/spec-pull`** | `/spec-status`, `/spec-push` |
125
+ | `spec-sync` subcommands | `normalize`, `push`, `status`, **`pull`** | `normalize`, `push`, `status`, **`record`** |
126
+ | Sidecar | a three-way merge base | the **last-pushed snapshot** (`record` writes it) |
127
+ | `sync.fieldOwnership` | `pull` / `both` values were load-bearing | still parsed; nothing is pulled |
128
+
129
+ ### What to do
130
+
131
+ 1. **Drop `/spec-pull` from any workflow that calls it.** There is no
132
+ replacement: editing the mirror in Linear is no longer an input. A person
133
+ editing the issue will see it overwritten by the next push.
134
+ 2. **Replace `spec-sync pull` in scripts with `spec-sync record`** — it writes the
135
+ snapshot from the current repo files after a push is applied.
136
+ 3. **Delete stale merge-base sidecars** under `sync.baseDir`; the first push after
137
+ upgrading writes the new snapshot format.
138
+
139
+ ## `@skitterbyte/skitterspec-linear` v1 → v7 (no breaking changes)
140
+
141
+ Every major in this range was a **routine version bump**, not a breaking
142
+ contract. The skills (`/spec-status`, `/spec-push`, `/spec-pull`) and the
143
+ `spec-sync` subcommands (`normalize`, `push`, `pull`, `status`) were identical at
144
+ v1 and at v7. Upgrading anywhere inside this range needs **no action** beyond
145
+ re-running `update`.
146
+
147
+ One thing did change quietly, at **v4**: `sync.fieldOwnership` lost its
148
+ `milestones`, `phaseBodies`, `acceptanceCriteria` and `taskBreakdown` entries when
149
+ that detail moved inside `description`. A config still listing them does not
150
+ error — unknown keys merge in and *join the compared set* — so remove them if
151
+ you have them, or they will be compared against fields that no longer exist.
152
+
153
+ ## `@skitterbyte/skitterspec` v3 → v16 (no breaking changes)
154
+
155
+ **Nothing in this range requires action.** Thirteen majors sounds like thirteen
156
+ migrations; it was one habit. Every release in this period was cut as a major
157
+ bump regardless of size (see `RELEASING.md`), and the base package's contract
158
+ never broke: **no skill was ever removed and no CLI flag was ever removed** — the
159
+ surface only grew. The spec folder layout
160
+ (`.core`/`backlog`/`in-progress`/`complete`/`cancelled`) is unchanged throughout.
161
+
162
+ The `feat(sync)!` commits that appear in this window changed
163
+ `@skitterbyte/skitterspec-linear`, which ships separately; the base was bumped
164
+ alongside it in lockstep. If you are on the **superset**, read the provider
165
+ entries above — those are the ones with work in them.
166
+
167
+ What each major actually added, so you can see what you gain by upgrading:
168
+
169
+ | Major | What landed |
170
+ |-------|-------------|
171
+ | v4 | `setup` commands bootstrap a fresh worktree's dependencies |
172
+ | v5, v6 | version bumps only |
173
+ | v7 | release docs refreshed; stale scripts dropped |
174
+ | v8 | version bump only |
175
+ | v9 | released alongside the provider's Linear body round-trip |
176
+ | v10, v11 | version bumps only |
177
+ | v12 | install manifest + `update --resync` / `--reset`; **`/spec-live`** overlay |
178
+ | v13 | **`/spec-hotfix`**, **`/spec-to-main`**, `spec-env prune` for orphaned test DBs |
179
+ | v14 | **Impact map** in the spec templates; live-aware `/spec-go`; the docs site |
180
+ | v15 | released alongside the provider's one-way sync switch |
181
+ | v16 | spec `Name:` handle; `/spec-complete` · `/spec-cancel` commit their own edits |
182
+
183
+ **Spec files written under an older version still read.** The template grew
184
+ (the Impact map at v14, the `Name:` header at v16) but the lifecycle skills treat
185
+ both as optional — `/spec-review` adds them if you want them.
186
+
51
187
  ## `@skitterbyte/skitterspec` v2 → v3 (slimmer surface + local traffic diversion)
52
188
 
53
189
  **v3 shrinks the everyday command surface to five verbs — `spec → go → connect →
package/README.md CHANGED
@@ -114,6 +114,22 @@ never clobbered. A workflow-state a teammate moves in Linear is surfaced by
114
114
  (`specs/.core/linear-base/`, content hashes) are committed so push sends only what
115
115
  changed.
116
116
 
117
+ **Fidelity safeguards.** Linear's markdown parser silently drops characters from
118
+ tables nested inside list items, so a nested table is reshaped before sending —
119
+ 2-column tables become a bullet list, others a code block — and column-0 tables
120
+ are left alone. Your spec files are never modified. After a push, each stored
121
+ description is read back and compared against what was sent, ignoring Linear's
122
+ own reformatting and reporting anything genuinely lost. Both are automatic.
123
+
124
+ **Adopting on a long backlog.** By default a spec costs one `save_issue` call
125
+ plus one per phase, so mirroring a backlog that already runs to dozens of specs
126
+ front-loads hundreds of calls for work nobody has started. Set
127
+ `mapping.phases: "deferred"` and a spec sitting in `specs/backlog/` mirrors as
128
+ **the issue alone**, keeping its phase list in the description; its sub-issues are
129
+ created by the push that follows `/spec-go`, when the work actually starts. Phases
130
+ that are already linked keep syncing either way, so switching an existing project
131
+ over never strands a live sub-issue. See `linear.config.md` for the details.
132
+
117
133
  **Which Project a spec lands in** is asked once, when the issue is first created
118
134
  — a filterable list of your team's projects, defaulting to `linear.projectId` and
119
135
  always offering *None*. It's passed on the create call only and never stored, so
@@ -14,16 +14,25 @@ team) that the config reference (`linear.config.md`) assumes you already have.
14
14
 
15
15
  ---
16
16
 
17
- ## Upgrading from 8.x
18
-
19
- **v9 remapped the mirror.** A spec is now an **issue** (was a Project), a phase a
20
- **sub-issue** (was a Milestone), and tasks are no longer objects. The frontmatter
21
- keys moved with it, so a spec linked under 8.x reads as **unlinked** to v9 — the
22
- next `/spec-push` would mint a fresh mirror and abandon the old one.
23
-
24
- `spec-sync push` detects this and refuses to let the plan be applied blind, but
25
- read **`MIGRATION.md`** ("v8 v9", shipped with the package) before upgrading a
26
- repo with a live mirror. Fresh installs can skip this section.
17
+ ## Upgrading an existing install
18
+
19
+ Fresh installs can skip this section. **`MIGRATION.md` ships with the package**
20
+ read the entry for the version you are coming from before upgrading a repo with a
21
+ live mirror.
22
+
23
+ **From 9.x — `push` now validates your issue states.** `spec-sync push` refuses
24
+ to run until the configured `states` names have been checked against the
25
+ workspace. `/spec-push` does that for you, so nothing changes if you drive sync
26
+ through the skill; a script calling the CLI directly must pass
27
+ `--workspace-states <file>` (or `--skip-state-check`). See `MIGRATION.md`
28
+ → "v9 → v10".
29
+
30
+ **From 8.x — the mirror was remapped.** A spec is now an **issue** (was a
31
+ Project), a phase a **sub-issue** (was a Milestone), and tasks are no longer
32
+ objects. The frontmatter keys moved with it, so a spec linked under 8.x reads as
33
+ **unlinked** — the next `/spec-push` would mint a fresh mirror and abandon the old
34
+ one. `push` detects this and refuses to let the plan be applied blind, but read
35
+ `MIGRATION.md` → "v8 → v9" first.
27
36
 
28
37
  ## 1. Install the package
29
38
 
@@ -46,6 +46,17 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
46
46
  // that sub-issue: "checklist" mirrors them into its description (default),
47
47
  // "none" leaves the description as the phase's Goal line alone. Either way no
48
48
  // issue is created per task. These are the defaults.
49
+ //
50
+ // `phases` selects WHEN a phase becomes a sub-issue: "subissue" from the
51
+ // spec's first push (default), or "deferred" only once the work starts — see
52
+ // "Deferring sub-issues until a spec starts" below.
53
+ //
54
+ // Under "checklist" the mirror keeps the phase file's OWN section headings: a
55
+ // phase with `## Tasks` and `## Acceptance` arrives as two headed sections, in
56
+ // source order, each heading reproduced as written. Checkboxes written before
57
+ // any heading appear under `## Tasks`. A heading with no checkboxes under it
58
+ // is not mirrored. Nesting, sub-bullets and inline formatting are preserved;
59
+ // a legacy inline `(KEY-123)` on a task line is stripped.
49
60
  "mapping": {
50
61
  "specFolder": "issue",
51
62
  "phases": "subissue",
@@ -138,6 +149,70 @@ Unlinked local items (a spec with no `linear_identifier`, a phase with no
138
149
  `linear_issue_id`) are created in Linear on the next `/spec-push`, which stamps
139
150
  the new id back so they link from then on.
140
151
 
152
+ ## What is reshaped before sending, and what is checked afterwards
153
+
154
+ Two safeguards sit either side of the push. Neither is configurable — both exist
155
+ because Linear's markdown parser does not always store what it is given.
156
+
157
+ **Nested tables are reshaped.** A markdown table written *inside* a list item is
158
+ corrupted by Linear: every data cell loses its first N characters, N being the
159
+ list-content indent Linear renders at (3 per ordered level, 2 per bullet),
160
+ whatever indent the source used. The header row survives, which makes it easy to
161
+ miss. Measured, not inferred — a table at source indent 3, 4 or 6 inside a
162
+ numbered list loses exactly 3 characters per cell. So before sending, a nested
163
+ table is re-emitted as a **bullet list** (2 columns) or a **fenced code block**
164
+ (any other count), both of which round-trip byte-identically. Column-0 tables —
165
+ the `## Phases` index, every Impact map — are never touched, and neither are
166
+ tables inside a fenced example. **Your spec files are not modified**: the source
167
+ markdown is valid and renders correctly in GitHub and every editor, so this
168
+ shapes only the projection.
169
+
170
+ **The round-trip is verified.** After `/spec-push` applies a plan it reads each
171
+ description back and runs `spec-sync verify`, which compares word characters and
172
+ ignores the reformatting Linear legitimately applies (renumbered ordered lists,
173
+ `-`→`*`, collapsed table separators, checkbox case, whitespace). Anything lost or
174
+ altered is reported with both sides of the first difference. It warns rather than
175
+ fails — the repo is unaffected and re-pushing overwrites the mirror. This is not
176
+ a pull: nothing read back is merged, stamped or written anywhere.
177
+
178
+ ## Deferring sub-issues until a spec starts
179
+
180
+ `mapping.phases` decides *when* a phase becomes a sub-issue:
181
+
182
+ - `"subissue"` (default) — from the spec's first push. A spec costs `1 + N`
183
+ `save_issue` calls to mirror, N being its phase count.
184
+ - `"deferred"` — only once the work starts. A spec sitting in `specs/backlog/`
185
+ mirrors as **the issue alone**; its sub-issues are created by the push that
186
+ follows `/spec-go`.
187
+
188
+ Deferral is worth setting when you adopt sync on a project that already has a
189
+ long backlog, where the default means mirroring every phase of every spec nobody
190
+ has started yet — in this repo, 130 calls where 35 would do.
191
+
192
+ What defers and what does not:
193
+
194
+ - **Unlinked phases defer; linked ones never do.** A phase already carrying a
195
+ `linear_issue_id` keeps projecting whatever the mode. One-way sync has no
196
+ delete, so withholding a live sub-issue would not remove it from Linear — it
197
+ would freeze it there, never updated again. That makes switching an existing
198
+ project to `"deferred"` safe: it only changes what has yet to be minted.
199
+ - **The trigger is the spec's projected state**, not its folder alone — so a
200
+ `spec_status` frontmatter override moves the issue's state and its sub-issues
201
+ together. Phases defer while that state is `backlog` or `cancelled`; a spec
202
+ cancelled without ever starting never mints phases it never worked, while one
203
+ cancelled mid-flight has ids already and keeps them.
204
+ - **A deferred spec keeps its `## Phases` index in the description.** That
205
+ section is normally stripped because the sub-issues carry it; while they are
206
+ withheld it is the only place the phase breakdown appears. It drops out of the
207
+ description in the same push that creates the sub-issues.
208
+ - **`/spec-push` and `/spec-status` say so**, printing `N phase(s) deferred`, and
209
+ the JSON plan carries a `phasesDeferred` count — a spec with no sub-issues
210
+ reads as deliberate rather than as phase files that failed to parse.
211
+
212
+ There is no snapshot state behind this and nothing to migrate: the last-pushed
213
+ snapshot only ever recorded sub-issues that have an id, so a deferred phase is
214
+ simply absent from it and arrives as an ordinary `create` when it projects.
215
+
141
216
  ## Which Project a spec issue belongs to
142
217
 
143
218
  `linear.projectId` is the **default**, not a mandate. When a spec issue is first
@@ -152,9 +152,16 @@ below. Follow the provider's steps below (nothing to do here without one).
152
152
  to bring down before building — the repo is already the source of truth. (A
153
153
  workflow-state a teammate moved in Linear is advisory only; `/spec-status`
154
154
  surfaces it. It is overwritten on the next push.)
155
- - **Optional: refresh the mirror.** If you want Linear to reflect the now
156
- in-progress spec, run `/spec-push` to send it up. This is optional at
157
- `/spec-go` timethe mirror can equally be refreshed later.
155
+ - **Refresh the mirror.** Run `/spec-push` to send the now in-progress spec up.
156
+ Whether that is optional depends on `mapping.phases`:
157
+ - `"subissue"` (the default) **optional**. The phase sub-issues already
158
+ exist; this push only moves their states. Refresh now or later.
159
+ - `"deferred"` — **do it now, without asking.** Under deferral a spec sitting
160
+ in the backlog is mirrored as the issue alone, and this push is what mints
161
+ its phase sub-issues. Skip it and a started spec stays mirrored as a
162
+ phase-less issue until someone happens to run `/spec-push` by hand. Run it
163
+ straight after the step-2 commit, so the sub-issues land with the same
164
+ branch push that fires the tracker's automation.
158
165
  - Linear's GitHub branch/PR automation may drive status transitions off the
159
166
  branch/PR you pushed in step 2; that's expected and the repo still wins on the
160
167
  next `/spec-push`.
@@ -69,6 +69,12 @@ date — say so and stop. `state` values are local buckets
69
69
  (`backlog`/`in-progress`/`complete`/`cancelled`); map each to the Linear
70
70
  issue-state NAME via `config.states` at apply time.
71
71
 
72
+ A **`phasesDeferred`** field means `mapping.phases` is `"deferred"` and this spec
73
+ has not started, so its phases are deliberately absent from the plan — the issue
74
+ pushes alone and the sub-issues are minted by the push that follows `/spec-go`.
75
+ Relay the count; it is not a sign the phase files failed to parse. Nothing else
76
+ about applying the plan changes.
77
+
72
78
  ### Stop if the plan reports a pre-9.0 mirror
73
79
 
74
80
  If the plan carries a **`legacy`** field, this spec was linked under the pre-9.0
@@ -97,6 +103,36 @@ migrated or explicitly confirms they want a new mirror.
97
103
  Priority, labels, cycles and comments are Linear-native triage — do **not** push
98
104
  them; they're the PM's.
99
105
 
106
+ ## 4b. Verify what Linear actually stored
107
+
108
+ Linear reserialises markdown on save, and it does not always preserve what you
109
+ sent — a table nested in a list item comes back with characters missing from
110
+ every data cell, silently. Check before you record the push as good.
111
+
112
+ For each issue you created or updated in step 4, read its `description` back
113
+ (`get_issue`) and write what you got to a JSON file:
114
+
115
+ ```json
116
+ { "issue": "<stored description>", "subIssues": { "01-outbox": "<stored>" } }
117
+ ```
118
+
119
+ Then:
120
+
121
+ ```
122
+ skitterspec spec-sync verify <spec> --stored <file>
123
+ ```
124
+
125
+ It compares against what the engine sent, ignoring the reformatting Linear
126
+ legitimately applies (renumbered ordered lists, `-`→`*`, collapsed table
127
+ separators, checkbox case, whitespace) and reporting only lost or altered **word
128
+ characters**. Relay any divergence — it prints both sides around the first
129
+ difference. It exits 0 either way: the repo is unaffected and still correct, so
130
+ this is a warning, not a failure.
131
+
132
+ This is **not a pull**. Nothing read here is merged, stamped, or written
133
+ anywhere; the repo remains the only source of truth. Do it before step 5 so a
134
+ corrupted push is visible before the snapshot records it as good.
135
+
100
136
  ## 5. Stamp the ids, then record the snapshot
101
137
 
102
138
  Write every id you collected back into the spec in **one** call — the engine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.0.0",
3
+ "version": "10.1.0",
4
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",
@@ -41,6 +41,7 @@ const {
41
41
  writeFrontmatter,
42
42
  stampSubIssueId,
43
43
  listPhaseFiles,
44
+ compareStored,
44
45
  } = require('../sync-core')
45
46
 
46
47
  const { loadLinearConfig } = require('./config.js')
@@ -196,6 +197,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
196
197
  const p = r.plan
197
198
  const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
198
199
  if (p.legacy) lines.push(...legacyLines(p.legacy))
200
+ if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
199
201
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
200
202
  else {
201
203
  if (p.issue) lines.push(' issue: description/state')
@@ -207,6 +209,16 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
207
209
  return 0
208
210
  }
209
211
 
212
+ // `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
213
+ // plan or a status report is printed, because the alternative reading of a spec
214
+ // with no sub-issues is that its phase files failed to parse.
215
+ function deferredLines(n) {
216
+ return [
217
+ ` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
218
+ ' they are created on the push that follows /spec-go',
219
+ ]
220
+ }
221
+
210
222
  // The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
211
223
  // ordinary — an all-creates plan for a spec that reads as unlinked — and
212
224
  // applying it mints a second mirror and abandons the first.
@@ -404,6 +416,9 @@ function specSyncStatus(dir, config, specArg, flags, out) {
404
416
  const projection = projectionOf(snapshotDir, config)
405
417
  const snapshot = readBase(dir, identifier, config)
406
418
  const plan = planChanges(projection, snapshot)
419
+ // From the projection, not the plan: `status` builds its plan with
420
+ // `planChanges` directly rather than going through `push`.
421
+ if (projection.phasesWithheld) lines.push(...deferredLines(projection.phasesWithheld))
407
422
  if (!snapshot) lines.push(' push: never pushed — everything is pending')
408
423
  else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
409
424
  else {
@@ -427,17 +442,86 @@ function specSyncStatus(dir, config, specArg, flags, out) {
427
442
  return 0
428
443
  }
429
444
 
445
+ /**
446
+ * `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
447
+ * against what we sent, and report any lost text.
448
+ *
449
+ * Not a pull: it merges nothing and writes nothing (see sync-core `verify.js`).
450
+ * The engine is offline, so `/spec-push` does the read over MCP and hands the
451
+ * result over in a file — the same split `--workspace-states` uses. The file is
452
+ * `{ "issue": "…", "subIssues": { "<ref>": "…" } }`; any key may be omitted.
453
+ *
454
+ * Warns, never fails (exit 0). The mirror is generated and disposable, and a
455
+ * hard failure after the plan is applied would strand it half-written.
456
+ */
457
+ function specSyncVerify(dir, config, specArg, flags, out) {
458
+ const snapshotDir = resolveOrExit(specArg, dir, out)
459
+ if (!snapshotDir) return 1
460
+ if (!flags.stored) {
461
+ out.write(
462
+ 'spec-sync verify: refusing to run without --stored <file>.\n' +
463
+ ' The engine is offline: /spec-push reads each description back over MCP\n' +
464
+ ' and writes {"issue": "…", "subIssues": {"<ref>": "…"}} for this command.\n',
465
+ )
466
+ return 1
467
+ }
468
+ let stored
469
+ try {
470
+ stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
471
+ } catch (error) {
472
+ out.write(`spec-sync verify: cannot read --stored ${flags.stored}: ${error.message}\n`)
473
+ return 1
474
+ }
475
+
476
+ const identifier = specIdentifier(snapshotDir, config)
477
+ const projection = projectionOf(snapshotDir, config)
478
+ const checks = []
479
+ if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
480
+ for (const [ref, text] of Object.entries(stored.subIssues || {})) {
481
+ const sub = projection.subIssues.find((s) => s.ref === ref)
482
+ if (!sub) {
483
+ checks.push([`sub-issue ${ref}`, null, text])
484
+ continue
485
+ }
486
+ checks.push([`sub-issue ${ref}`, sub.goal, text])
487
+ }
488
+
489
+ const lines = [`spec-sync verify: ${identifier}`]
490
+ let bad = 0
491
+ for (const [label, sent, got] of checks) {
492
+ if (sent == null) {
493
+ lines.push(` ?? ${label}: read back, but the projection has no such phase — stale ref?`)
494
+ bad++
495
+ continue
496
+ }
497
+ const r = compareStored(sent, got)
498
+ if (r.ok) continue
499
+ bad++
500
+ lines.push(
501
+ ` !! ${label}: the tracker stored different text — ${Math.abs(r.lost)} character(s) ` +
502
+ `${r.lost > 0 ? 'lost' : 'added'}, first difference at ${r.at}`,
503
+ ` sent: …${r.sentContext}…`,
504
+ ` stored: …${r.storedContext}…`,
505
+ )
506
+ }
507
+ if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
508
+ else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
509
+ out.write(lines.join('\n') + '\n')
510
+ return 0
511
+ }
512
+
430
513
  async function specSync(rest, io = {}) {
431
514
  const out = io.out || process.stdout
432
515
  const err = io.err || process.stderr
433
516
  const [sub, ...args] = rest
434
517
  let dir = io.cwd || process.cwd()
435
518
  const positional = []
436
- const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [] }
519
+ const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null }
437
520
  for (let i = 0; i < args.length; i++) {
438
521
  if (args[i] === '--dir') dir = path.resolve(args[++i])
439
522
  else if (args[i] === '--json') flags.json = true
440
523
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
524
+ else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
441
525
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
442
526
  else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
443
527
  else if (args[i] === '--issue') flags.issue = args[++i]
@@ -469,6 +553,8 @@ async function specSync(rest, io = {}) {
469
553
  return 0
470
554
  case 'status':
471
555
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
556
+ case 'verify':
557
+ return specSyncVerify(dir, config, positional[0], flags, out) || 0
472
558
  case 'linked':
473
559
  specSyncLinked(dir, config, flags, out)
474
560
  return 0
@@ -476,6 +562,7 @@ async function specSync(rest, io = {}) {
476
562
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
477
563
  ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
478
564
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
565
+ ' skitterspec spec-sync verify <spec> --stored <file>\n' +
479
566
  ' skitterspec spec-sync linked [--json]\n')
480
567
  return 0
481
568
  }
@@ -44,6 +44,17 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
44
44
  // box ticked in the tracker is overwritten by the next push.
45
45
  const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
46
46
 
47
+ // When a phase becomes a sub-issue.
48
+ // subissue — always, from the spec's first push (default)
49
+ // deferred — only once the work starts: a spec sitting in `backlog` (or
50
+ // `cancelled` without ever having started) projects the issue
51
+ // alone, so adopting sync on a long backlog costs one call per
52
+ // spec instead of one per spec PLUS one per phase. A phase that
53
+ // already carries an id keeps projecting either way — one-way sync
54
+ // has no delete, so withholding a LINKED sub-issue would freeze it
55
+ // in the tracker rather than remove it.
56
+ const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred'])
57
+
47
58
  const DEFAULT_CONFIG = Object.freeze({
48
59
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
49
60
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -201,6 +212,12 @@ function mergeConfig(base, parsed) {
201
212
  `(expected one of ${TASK_MAPPINGS.join('|')})`,
202
213
  )
203
214
  }
215
+ if (!PHASE_MAPPINGS.includes(base.mapping.phases)) {
216
+ throw new Error(
217
+ `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.mapping.phases)} ` +
218
+ `(expected one of ${PHASE_MAPPINGS.join('|')})`,
219
+ )
220
+ }
204
221
  }
205
222
 
206
223
  if (isObject(parsed.states)) {
@@ -266,4 +283,5 @@ module.exports = {
266
283
  CONFIG_FILE,
267
284
  OWNERSHIP,
268
285
  TASK_MAPPINGS,
286
+ PHASE_MAPPINGS,
269
287
  }
@@ -7,7 +7,10 @@
7
7
  * about any specific tracker. The repo is the source of truth: the engine builds
8
8
  * a local projection, diffs it against a committed last-pushed snapshot
9
9
  * (`planChanges`), and returns a create/update plan the provider skill applies
10
- * over its API. No remote content is read or merged.
10
+ * over its API. No remote content is read for CONTENT: nothing the tracker holds
11
+ * ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
12
+ * one function that looks at a tracker value, and it only checks that what was
13
+ * stored matches what was sent — it merges nothing (see `src/verify.js`).
11
14
  */
12
15
 
13
16
  const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
@@ -17,6 +20,8 @@ const { push, recordPush, projectionOf } = require('./src/push.js')
17
20
  const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
18
21
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
22
  const { detectLegacyMirror } = require('./src/legacy.js')
23
+ const { compareStored } = require('./src/verify.js')
24
+ const { flattenNestedTables } = require('./src/tables.js')
20
25
 
21
26
  module.exports = {
22
27
  normalizeLocal,
@@ -44,4 +49,6 @@ module.exports = {
44
49
  listPhaseFiles,
45
50
  sanitizeSpecMarkdown,
46
51
  detectLegacyMirror,
52
+ compareStored,
53
+ flattenNestedTables,
47
54
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs')
18
18
  const path = require('node:path')
19
+ const { flattenNestedTables } = require('./tables.js')
19
20
  const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
20
21
 
21
22
  // --- markdown / frontmatter parsing -----------------------------------------
@@ -319,6 +320,46 @@ function parseTaskLine(line) {
319
320
  return { id, text, done }
320
321
  }
321
322
 
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
+ }
348
+
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
361
+ }
362
+
322
363
  // Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
323
364
  // linked milestone id (from optional frontmatter), title, goal and tasks.
324
365
  function readPhaseFiles(snapshotDir) {
@@ -347,7 +388,8 @@ function readPhaseFiles(snapshotDir) {
347
388
  // the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
348
389
  // line stripped — those ids were per-task issues we no longer create, and
349
390
  // they read as noise in the mirror.
350
- const tasks = findTaskBlocks(body.split('\n')).map((b) => {
391
+ const lines = body.split('\n')
392
+ const renderTask = (b) => {
351
393
  // `findTaskBlocks` also returns the plain sub-bullets written underneath
352
394
  // a task. They carry no checkbox, so they render as the bullet their
353
395
  // author used — emitting `- [ ]` here would invent a task that does not
@@ -355,7 +397,10 @@ function readPhaseFiles(snapshotDir) {
355
397
  const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
356
398
  const text = parsed ? parsed.text : b.text
357
399
  return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
358
- })
400
+ }
401
+ const blocks = findTaskBlocks(lines)
402
+ const tasks = blocks.map(renderTask)
403
+ const taskGroups = groupTasksByHeading(lines, blocks, renderTask)
359
404
  return {
360
405
  phase: file.replace(/\.md$/, ''),
361
406
  file,
@@ -370,6 +415,7 @@ function readPhaseFiles(snapshotDir) {
370
415
  emoji: headingEmoji(body),
371
416
  statusLine: phaseStatusLine(body),
372
417
  tasks,
418
+ taskGroups,
373
419
  }
374
420
  })
375
421
  }
@@ -482,7 +528,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
482
528
  if (skip.has(heading)) continue
483
529
  parts.push(`## ${heading}\n\n${content}`.trim())
484
530
  }
485
- return canonicalizeMarkdown(parts.join('\n\n')) || null
531
+ return flattenNestedTables(canonicalizeMarkdown(parts.join('\n\n'))) || null
486
532
  }
487
533
 
488
534
  // A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
@@ -494,11 +540,18 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
494
540
  // act on; with it the phase is legible to someone working in the tracker without
495
541
  // tasks becoming individually-synced objects again.
496
542
  function subIssueBody(phase, tasksMode) {
497
- if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
543
+ if (tasksMode !== 'checklist' || !phase.tasks.length) return flattenNestedTables(phase.goal)
498
544
  const parts = []
499
545
  if (phase.goal) parts.push(phase.goal, '')
500
- parts.push('## Tasks', '', ...phase.tasks)
501
- return parts.join('\n')
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)
553
+ })
554
+ return flattenNestedTables(parts.join('\n'))
502
555
  }
503
556
 
504
557
  /**
@@ -512,13 +565,57 @@ function bucketFromPath(snapshotDir) {
512
565
  return LIFECYCLE_BUCKETS.includes(parent) ? parent : null
513
566
  }
514
567
 
568
+ // Lifecycle buckets in which a spec's work has not begun: never started, or
569
+ // abandoned without ever starting. Under `mapping.phases: 'deferred'` these are
570
+ // the states in which phases are not yet worth minting as sub-issues.
571
+ const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
572
+
573
+ /**
574
+ * Which phases the projection sends, and how many the `deferred` mapping is
575
+ * holding back. Pure — split out so both the projection and the CLI's "N phases
576
+ * deferred" line read the SAME predicate rather than two copies of it.
577
+ *
578
+ * Only UNLINKED phases are withheld. A phase that already carries an id keeps
579
+ * projecting whatever the mode: one-way sync has no delete op, so withholding a
580
+ * live sub-issue would not remove it from the tracker — it would freeze it there,
581
+ * never updated again. That makes switching a project to `deferred` safe.
582
+ */
583
+ // The spec's lifecycle status as projected: its folder bucket, unless the
584
+ // overview frontmatter pins `spec_status`. Shared by the projection and by
585
+ // `phasesWithheld` so the two can never disagree about whether work has started.
586
+ function specStatus(snapshotDir, frontmatter) {
587
+ return frontmatter && frontmatter.spec_status != null
588
+ ? String(frontmatter.spec_status)
589
+ : bucketFromPath(snapshotDir)
590
+ }
591
+
592
+ function phaseProjection(phases, workflowState, config) {
593
+ 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 }
598
+ }
599
+
515
600
  function normalizeLocal(snapshotDir, config) {
516
601
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
602
+ const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
603
+ // Status is the spec's lifecycle bucket. The folder is the source of truth; an
604
+ // explicit `spec_status` frontmatter key overrides it if present. Resolved
605
+ // BEFORE the sub-issue projection because deferral withholds phases by this
606
+ // status — so the issue's state and its sub-issues always agree on whether the
607
+ // work has started, however that status was arrived at.
608
+ const workflowState = specStatus(snapshotDir, frontmatter)
609
+ const { projected, withheld } = phaseProjection(phases, workflowState, config)
610
+
517
611
  // Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
518
612
  // so strip the `## Phases` index from the description to avoid duplicating it
519
- // (as prose AND as sub-issues) in the Linear mirror.
520
- const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
521
- const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
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
522
619
  const extracted = {
523
620
  description: buildDescription(
524
621
  title,
@@ -532,17 +629,32 @@ function normalizeLocal(snapshotDir, config) {
532
629
  // Linear issue state via `config.states` at push time. Tasks ride along in
533
630
  // the description as a read-only checklist (`mapping.tasks`), never as
534
631
  // individually-synced objects.
535
- subIssues: phases
536
- .filter((p) => p.name)
537
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: subIssueBody(p, tasksMode), state: p.state })),
538
- // Status is the spec's lifecycle bucket. The folder is the source of truth;
539
- // an explicit `spec_status` frontmatter key overrides it if present.
540
- workflowState:
541
- frontmatter.spec_status != null ? String(frontmatter.spec_status) : bucketFromPath(snapshotDir),
632
+ subIssues: projected.map((p) => ({
633
+ id: p.id,
634
+ ref: p.phase,
635
+ name: p.name,
636
+ goal: subIssueBody(p, tasksMode),
637
+ state: p.state,
638
+ })),
639
+ workflowState,
542
640
  }
543
641
  return toFieldSet(extracted, config)
544
642
  }
545
643
 
644
+ /**
645
+ * How many phases `mapping.phases: 'deferred'` is currently holding back for
646
+ * this spec — 0 in every other mode.
647
+ *
648
+ * Deliberately NOT a key on `normalizeLocal`'s return: that is the configured
649
+ * field set and nothing else, so a reporting-only value cannot drift into the
650
+ * synced shape (or a hash). Callers that want to SAY "N phases deferred" ask for
651
+ * it, at the cost of a second read of a handful of small files.
652
+ */
653
+ function phasesWithheld(snapshotDir, config) {
654
+ const { frontmatter, phases } = readSnapshot(snapshotDir, config)
655
+ return phaseProjection(phases, specStatus(snapshotDir, frontmatter), config).withheld
656
+ }
657
+
546
658
  // --- remote projection ------------------------------------------------------
547
659
 
548
660
  // Map a remote workflow-state name back to the local lifecycle bucket (the
@@ -706,6 +818,8 @@ function stateSuggestions(config, workspaceStates) {
706
818
  module.exports = {
707
819
  stateSuggestions,
708
820
  normalizeLocal,
821
+ phaseProjection,
822
+ phasesWithheld,
709
823
  lintPhases,
710
824
  readSnapshot,
711
825
  parseFrontmatter,
@@ -15,7 +15,7 @@
15
15
  * Date.now(). `recordPush` writes the snapshot sidecar.
16
16
  */
17
17
 
18
- const { normalizeLocal } = require('./normalize.js')
18
+ const { normalizeLocal, phasesWithheld } = require('./normalize.js')
19
19
  const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
20
  const { readBase, writeBase } = require('./base.js')
21
21
  const { detectLegacyMirror } = require('./legacy.js')
@@ -30,6 +30,10 @@ function projectionOf(snapshotDir, config) {
30
30
  description: local.description ?? null,
31
31
  status: local.workflowState ?? null,
32
32
  subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
33
+ // How many phases `mapping.phases: 'deferred'` is holding back. Reporting
34
+ // only — `snapshotOf`/`specIssueHash` read named fields, so this never
35
+ // reaches a hash and cannot make an unchanged spec look edited.
36
+ phasesWithheld: phasesWithheld(snapshotDir, config),
33
37
  }
34
38
  }
35
39
 
@@ -43,6 +47,10 @@ function push({ dir, snapshotDir, identifier, config }) {
43
47
  // skill that applies this plan is exactly the consumer that would miss them.
44
48
  const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
45
49
  if (legacy) plan.legacy = legacy
50
+ // Same reasoning as `legacy`: carried ON THE PLAN, not as a stderr warning,
51
+ // because `--json` routes warnings to stderr and the skill applying the plan
52
+ // is the consumer that most needs to know the missing sub-issues are deliberate.
53
+ if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
46
54
  return { ok: true, empty: isEmptyPlan(plan), plan, projection }
47
55
  }
48
56
 
@@ -0,0 +1,102 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Flatten markdown tables that sit INSIDE a list item, because Linear corrupts
5
+ * them.
6
+ *
7
+ * Measured on probe SKI-28 (2026-08-28): when Linear renders a table nested in a
8
+ * list item, every **data** cell loses its first N characters, where N is the
9
+ * list-content indent Linear renders at — 3 per ordered-list level, 2 per bullet
10
+ * level — regardless of the indent the source used. Source indents 3, 4 and 6
11
+ * all lose exactly 3. The header row is never touched, column-0 tables never
12
+ * corrupt, and the column count is irrelevant. Real damage from the field: the
13
+ * auth header `X-Extraction-Key` was stored as `Extraction-Key`.
14
+ *
15
+ * The engine passes the table through byte-identically — this is Linear's
16
+ * parser, not ours — but the projection is the only place that can stop the
17
+ * markdown reaching it in a shape it mangles. So nested tables are re-emitted as
18
+ * shapes SKI-28 proved survive nesting unchanged:
19
+ *
20
+ * - 2 columns → a bullet list (`- a — b`), the key/value case, and the shape
21
+ * the reporter hand-repaired in production
22
+ * - otherwise → a fenced code block wrapping the original rows verbatim
23
+ *
24
+ * This shapes the PROJECTION only. Repo files are never rewritten: the source
25
+ * markdown is valid and renders correctly in GitHub and every editor.
26
+ */
27
+
28
+ const { fenceMask } = require('./task-block.js')
29
+
30
+ // A table row: optional indent, then a `|`-delimited line. We only ever act on
31
+ // indented ones — a column-0 table (the `## Phases` index, every Impact map) is
32
+ // rendered correctly by Linear and must project byte-identically.
33
+ const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
34
+ // The separator under the header — `|---|:--:|`. Its presence is what makes the
35
+ // block a table rather than prose that happens to contain pipes.
36
+ const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
37
+
38
+ // Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
39
+ // documented `` `a | b` `` alternation stays one cell instead of splitting.
40
+ function splitCells(body) {
41
+ const cells = []
42
+ let cur = ''
43
+ let code = false
44
+ for (const ch of body) {
45
+ if (ch === '`') code = !code
46
+ if (ch === '|' && !code) {
47
+ cells.push(cur.trim())
48
+ cur = ''
49
+ continue
50
+ }
51
+ cur += ch
52
+ }
53
+ cells.push(cur.trim())
54
+ return cells
55
+ }
56
+
57
+ /**
58
+ * Rewrite every indented table in `md`. Returns the text unchanged when there is
59
+ * nothing nested to flatten.
60
+ * @param {string} md
61
+ * @returns {string}
62
+ */
63
+ function flattenNestedTables(md) {
64
+ if (md == null) return md
65
+ const lines = String(md).split('\n')
66
+ const inFence = fenceMask(lines)
67
+ const out = []
68
+
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const header = ROW_RE.exec(lines[i])
71
+ // A table shown as an EXAMPLE inside a ``` block is documentation — often of
72
+ // this very bug — so it is left exactly as written.
73
+ if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
74
+ out.push(lines[i])
75
+ continue
76
+ }
77
+
78
+ const indent = header[1]
79
+ const rows = [splitCells(header[2])]
80
+ const raw = [lines[i], lines[i + 1]]
81
+ let j = i + 2
82
+ for (; j < lines.length && !inFence[j]; j++) {
83
+ const row = ROW_RE.exec(lines[j])
84
+ if (!row) break
85
+ rows.push(splitCells(row[2]))
86
+ raw.push(lines[j])
87
+ }
88
+
89
+ if (rows[0].length === 2) {
90
+ // Header first, bolded: dropping it would lose content and inventing a
91
+ // caption would invent it.
92
+ out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
93
+ for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
94
+ } else {
95
+ out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
96
+ }
97
+ i = j - 1
98
+ }
99
+ return out.join('\n')
100
+ }
101
+
102
+ module.exports = { flattenNestedTables, splitCells }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Compare what Linear STORED against what we sent, and report lost text.
5
+ *
6
+ * Why this is not a pull. One-way sync's rule is about **authority**: Linear
7
+ * must never influence repo content. This reads a description back to *check*
8
+ * it — it merges nothing, writes nothing, and feeds nothing into the projection
9
+ * or the snapshot. The repo remains the only source of truth; the only output is
10
+ * a warning for a human. Without it, a parser that silently eats characters
11
+ * produces a mirror that looks pushed and is wrong, which is exactly how the
12
+ * nested-table corruption went unnoticed (see `tables.js`).
13
+ *
14
+ * The engine is offline, so the read itself belongs to the `/spec-push` skill —
15
+ * it fetches over MCP and hands the result here, the same split
16
+ * `--workspace-states` already uses.
17
+ *
18
+ * Pure: no I/O, no clock, no randomness.
19
+ */
20
+
21
+ // Linear reserialises markdown on save. These transforms are all observed and
22
+ // all harmless, so they are normalised away BEFORE comparing — otherwise every
23
+ // push would report a false divergence.
24
+ function canonicalForCompare(text) {
25
+ return String(text == null ? '' : text)
26
+ .replace(/\r\n/g, '\n')
27
+ .split('\n')
28
+ .map((line) =>
29
+ line
30
+ // Ordered-list markers → a placeholder. Linear renumbers lists, and the
31
+ // digits it rewrites are alphanumeric, so a naive alphanumeric compare
32
+ // would flag its own benign reformat as data loss. Normalising the
33
+ // marker keeps digits significant EVERYWHERE ELSE — a port, a version, a
34
+ // key length still count.
35
+ .replace(/^(\s*)\d+\.(\s)/, '$1#.$2')
36
+ // Unordered markers unify (`-`/`+` come back as `*`).
37
+ .replace(/^(\s*)[*+-](\s)/, '$1-$2')
38
+ // Checkbox marks case-fold. Targeted rather than lowercasing the whole
39
+ // text, so a genuine case corruption in prose is still caught.
40
+ .replace(/^(\s*-\s*\[)[xX](\])/, '$1x$2')
41
+ // Table separator rows collapse (`|-------|` → `| -- |`).
42
+ .replace(/^\s*\|[\s:|-]+\|\s*$/, '|--|')
43
+ .replace(/[ \t]+$/, ''),
44
+ )
45
+ .join('\n')
46
+ .replace(/\n{3,}/g, '\n\n')
47
+ .trim()
48
+ }
49
+
50
+ // The word-character stream: everything that carries meaning, with every
51
+ // reformatting artefact (whitespace, bullets, asterisk boundaries, pipes,
52
+ // separators) removed. Comparing these catches dropped characters while
53
+ // ignoring every benign transform above.
54
+ function stream(text) {
55
+ return canonicalForCompare(text).replace(/[^\p{L}\p{N}]/gu, '')
56
+ }
57
+
58
+ /**
59
+ * @param {string} sent what we pushed
60
+ * @param {string} stored what the tracker returned
61
+ * @returns {{ok:boolean, at:number|null, lost:number, sentContext:string, storedContext:string}}
62
+ * `ok` false means word characters differ — content was lost or altered.
63
+ * `at` is the index in the reduced stream where they first diverge, with ~40
64
+ * characters of each side around it so the warning names the damage.
65
+ */
66
+ function compareStored(sent, stored) {
67
+ const a = stream(sent)
68
+ const b = stream(stored)
69
+ if (a === b) return { ok: true, at: null, lost: 0, sentContext: '', storedContext: '' }
70
+
71
+ let at = 0
72
+ while (at < a.length && at < b.length && a[at] === b[at]) at++
73
+ const window = (s) => s.slice(Math.max(0, at - 10), at + 30)
74
+ return {
75
+ ok: false,
76
+ at,
77
+ lost: a.length - b.length,
78
+ sentContext: window(a),
79
+ storedContext: window(b),
80
+ }
81
+ }
82
+
83
+ module.exports = { compareStored, canonicalForCompare, stream }