@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 +136 -0
- package/README.md +16 -0
- package/assets/core/SETUP.md +19 -10
- package/assets/core/linear.config.md +75 -0
- package/assets/skills/spec-go/SKILL.md +10 -3
- package/assets/skills/spec-push/SKILL.md +36 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +88 -1
- package/src/vendor/linear/config.js +18 -0
- package/src/vendor/sync-core/index.js +8 -1
- package/src/vendor/sync-core/src/normalize.js +130 -16
- package/src/vendor/sync-core/src/push.js +9 -1
- package/src/vendor/sync-core/src/tables.js +102 -0
- package/src/vendor/sync-core/src/verify.js +83 -0
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
|
package/assets/core/SETUP.md
CHANGED
|
@@ -14,16 +14,25 @@ team) that the config reference (`linear.config.md`) assumes you already have.
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
## Upgrading
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
- **
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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:
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 }
|