@skitterbyte/skitterspec-linear 10.0.1 → 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/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
@@ -47,6 +47,10 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
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
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
+ //
50
54
  // Under "checklist" the mirror keeps the phase file's OWN section headings: a
51
55
  // phase with `## Tasks` and `## Acceptance` arrives as two headed sections, in
52
56
  // source order, each heading reproduced as written. Checkboxes written before
@@ -145,6 +149,70 @@ Unlinked local items (a spec with no `linear_identifier`, a phase with no
145
149
  `linear_issue_id`) are created in Linear on the next `/spec-push`, which stamps
146
150
  the new id back so they link from then on.
147
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
+
148
216
  ## Which Project a spec issue belongs to
149
217
 
150
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.1",
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 -----------------------------------------
@@ -527,7 +528,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
527
528
  if (skip.has(heading)) continue
528
529
  parts.push(`## ${heading}\n\n${content}`.trim())
529
530
  }
530
- return canonicalizeMarkdown(parts.join('\n\n')) || null
531
+ return flattenNestedTables(canonicalizeMarkdown(parts.join('\n\n'))) || null
531
532
  }
532
533
 
533
534
  // A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
@@ -539,7 +540,7 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
539
540
  // act on; with it the phase is legible to someone working in the tracker without
540
541
  // tasks becoming individually-synced objects again.
541
542
  function subIssueBody(phase, tasksMode) {
542
- if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
543
+ if (tasksMode !== 'checklist' || !phase.tasks.length) return flattenNestedTables(phase.goal)
543
544
  const parts = []
544
545
  if (phase.goal) parts.push(phase.goal, '')
545
546
  // One section per source heading, in source order. Checkboxes written before
@@ -550,7 +551,7 @@ function subIssueBody(phase, tasksMode) {
550
551
  if (i) parts.push('')
551
552
  parts.push(group.heading || '## Tasks', '', ...group.tasks)
552
553
  })
553
- return parts.join('\n')
554
+ return flattenNestedTables(parts.join('\n'))
554
555
  }
555
556
 
556
557
  /**
@@ -564,13 +565,57 @@ function bucketFromPath(snapshotDir) {
564
565
  return LIFECYCLE_BUCKETS.includes(parent) ? parent : null
565
566
  }
566
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
+
567
600
  function normalizeLocal(snapshotDir, config) {
568
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
+
569
611
  // Phases sync as sub-issues whenever `subIssues` is in the pushed projection,
570
612
  // so strip the `## Phases` index from the description to avoid duplicating it
571
- // (as prose AND as sub-issues) in the Linear mirror.
572
- const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
573
- 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
574
619
  const extracted = {
575
620
  description: buildDescription(
576
621
  title,
@@ -584,17 +629,32 @@ function normalizeLocal(snapshotDir, config) {
584
629
  // Linear issue state via `config.states` at push time. Tasks ride along in
585
630
  // the description as a read-only checklist (`mapping.tasks`), never as
586
631
  // individually-synced objects.
587
- subIssues: phases
588
- .filter((p) => p.name)
589
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: subIssueBody(p, tasksMode), state: p.state })),
590
- // Status is the spec's lifecycle bucket. The folder is the source of truth;
591
- // an explicit `spec_status` frontmatter key overrides it if present.
592
- workflowState:
593
- 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,
594
640
  }
595
641
  return toFieldSet(extracted, config)
596
642
  }
597
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
+
598
658
  // --- remote projection ------------------------------------------------------
599
659
 
600
660
  // Map a remote workflow-state name back to the local lifecycle bucket (the
@@ -758,6 +818,8 @@ function stateSuggestions(config, workspaceStates) {
758
818
  module.exports = {
759
819
  stateSuggestions,
760
820
  normalizeLocal,
821
+ phaseProjection,
822
+ phasesWithheld,
761
823
  lintPhases,
762
824
  readSnapshot,
763
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 }