@skitterbyte/skitterspec-linear 9.0.0 → 9.2.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
@@ -56,12 +56,14 @@ fuller guide):
56
56
  On top of the base skills (`/spec`, `/spec-go`, isolation, …):
57
57
 
58
58
  - **`/spec-status`** — read-only drift report: what the next push would create /
59
- update, plus any workflow-state drift. Changes nothing.
59
+ update, any workflow-state drift, and any phase whose status signals disagree
60
+ (see **Phase status** below). Changes nothing.
60
61
  - **`/spec-push`** — repo → Linear, one-way. Diffs the spec against a committed
61
62
  last-pushed snapshot and applies only what changed (issue description + state,
62
63
  phase sub-issues), stamping the returned ids back into the spec.
63
64
  - **`spec-sync` CLI** (`skitterspec-linear spec-sync …`) — the deterministic
64
- engine behind the skills, for CI / local runs.
65
+ engine behind the skills, for CI / local runs:
66
+ `normalize` · `push` · `stamp` · `record` · `status` · `linked`.
65
67
 
66
68
  The shared `/spec`, `/spec-bug` and `/spec-go` skills come composed with the
67
69
  Linear steps filled in: `/spec` asks which Linear **Project** the spec belongs to,
@@ -83,8 +85,10 @@ spec, and a smoke test. Per-field docs live in `specs/.core/linear.config.md`.
83
85
  **What pushes:** the spec is one Linear **issue** — the spec body travels as its
84
86
  **`description`**, each phase as a **sub-issue** (phase name → title, `**Goal:**`
85
87
  → description, phase emoji → state), and the spec's lifecycle folder sets the
86
- issue's **workflow state**. Tasks are **not** synced they stay in the repo phase
87
- files. Priority, labels, cycles and comments are **Linear-native triage** — the
88
+ issue's **workflow state**. A phase's **tasks are mirrored** into its sub-issue's
89
+ description as a read-only checklist (`mapping.tasks: "checklist"`, the default;
90
+ `"none"` keeps the Goal line alone) — no issue is created per task, and a box
91
+ ticked in Linear is overwritten by the next push. Priority, labels, cycles and comments are **Linear-native triage** — the
88
92
  PM's to set in Linear; one-way sync neither pushes nor reads them, so they're
89
93
  never clobbered. A workflow-state a teammate moves in Linear is surfaced by
90
94
  `/spec-status` as drift and overwritten on the next push. **Last-pushed snapshots**
@@ -105,6 +109,13 @@ replaces the description with the spec. A bug-labelled issue routes to
105
109
  `/spec-bug`, which adopts it the same way. `skitterspec-linear spec-sync linked`
106
110
  lists what's already adopted, so an issue never becomes two specs.
107
111
 
112
+ **Phase status.** A phase's state in Linear comes from the `⬜`/`🔄`/`✅` on its
113
+ phase-file **heading** — not from its `> **Status:**` line and not from the
114
+ overview's phase-index row, which are the human mirrors of it. A heading carrying
115
+ no emoji reads as *not started*, so `spec-sync normalize|push|status` warn when
116
+ the emoji is missing or when the three disagree, rather than quietly mirroring a
117
+ finished phase as backlog. The warnings never block a push.
118
+
108
119
  Branch naming that embeds the Linear id lives in the isolation config
109
120
  (`env.config.json` → `branch.pattern` with `{identifier}`, `branch.identifierField:
110
121
  "linear_identifier"`), not in `linear.config.json`.
@@ -3,8 +3,9 @@
3
3
  A start-to-finish guide to getting `/spec-status` and `/spec-push` working against
4
4
  a real Linear workspace. Sync is **one-way**: the repo is the source of truth and
5
5
  the linked Linear **issue** is a **generated mirror** — content is pushed up, never
6
- read back or merged. A spec is a Linear issue and each phase a sub-issue; tasks are
7
- not synced. Covers the **Linear side** (connecting the MCP server, finding your
6
+ read back or merged. A spec is a Linear issue and each phase a sub-issue, with the
7
+ phase's tasks mirrored into that sub-issue's description as a read-only
8
+ checklist. Covers the **Linear side** (connecting the MCP server, finding your
8
9
  team) that the config reference (`linear.config.md`) assumes you already have.
9
10
 
10
11
  > The whole feature is **opt-in**: until `specs/.core/linear.config.json` exists,
@@ -11,7 +11,7 @@
11
11
  "mapping": {
12
12
  "specFolder": "issue",
13
13
  "phases": "subissue",
14
- "tasks": "none"
14
+ "tasks": "checklist"
15
15
  },
16
16
  "states": {
17
17
  "backlog": "Backlog",
@@ -3,7 +3,8 @@
3
3
  Opt-in config for the Linear sync (`/spec-status`, `/spec-push`, and the
4
4
  Linear-aware paths of `/spec` and `/spec-go`). Sync is **one-way**: the repo is
5
5
  the source of truth and the Linear **issue** is a **generated mirror**. A spec is
6
- a Linear issue and each phase a sub-issue; tasks are not synced. Content is
6
+ a Linear issue and each phase a sub-issue; a phase's tasks ride along inside
7
+ that sub-issue's description as a read-only checklist. Content is
7
8
  pushed up and never read back or merged — `/spec-push` diffs the spec against a
8
9
  committed **last-pushed snapshot** and applies only what changed; `/spec-status`
9
10
  is a read-only drift report. The `sync.fieldOwnership` map now just selects the
@@ -41,11 +42,14 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
41
42
  },
42
43
 
43
44
  // How a spec's parts map onto Linear objects: a spec is an Issue, each phase a
44
- // sub-issue (a child issue), tasks are not synced. These are the defaults.
45
+ // sub-issue (a child issue). `tasks` selects how a phase's checkboxes reach
46
+ // that sub-issue: "checklist" mirrors them into its description (default),
47
+ // "none" leaves the description as the phase's Goal line alone. Either way no
48
+ // issue is created per task. These are the defaults.
45
49
  "mapping": {
46
50
  "specFolder": "issue",
47
51
  "phases": "subissue",
48
- "tasks": "none"
52
+ "tasks": "checklist"
49
53
  },
50
54
 
51
55
  // Map the spec's lifecycle bucket → the Linear ISSUE workflow-state name. Used
@@ -112,8 +116,18 @@ recreates:
112
116
  - **Phases → sub-issues.** Each phase file maps to a child issue (`parentId` = the
113
117
  spec issue). The link id lives in the phase file's frontmatter
114
118
  (`linear_issue_id`); its title ← the phase h1, its description ← the phase
115
- `**Goal:**` line, its state ← the phase heading emoji (⬜/🔄/✅).
116
- - **Tasks are not synced.** Task checkboxes stay in the repo phase files only.
119
+ `**Goal:**` line plus its task checklist (see below), its state ← the phase
120
+ heading emoji (⬜/🔄/✅).
121
+ - **Tasks are mirrored, not synced.** With `mapping.tasks: "checklist"` (the
122
+ default) a phase's checkboxes are rendered into its sub-issue's description as
123
+ a markdown checklist — nesting and `[x]` state preserved, any legacy inline
124
+ `(KEY-123)` stripped. No issue is created per task and nothing is read back, so
125
+ a box ticked in Linear is overwritten by the next push. Set `"none"` to keep
126
+ the description as the Goal line alone.
127
+
128
+ Turning this on (or off) changes every sub-issue's description, so the first
129
+ `/spec-push` afterwards reports every already-linked sub-issue as an update.
130
+ That is expected — they update in place and no duplicates are minted.
117
131
 
118
132
  Unlinked local items (a spec with no `linear_identifier`, a phase with no
119
133
  `linear_issue_id`) are created in Linear on the next `/spec-push`, which stamps
@@ -289,7 +289,8 @@ below (nothing to do here without one).
289
289
  If it's absent, skip this phase entirely — the spec stays local-only and `/spec`
290
290
  behaves exactly as above. When present, after writing the spec, link it to Linear
291
291
  so status and discussion live there while the repo stays the co-authoring surface.
292
- A spec is a Linear **issue**; each phase is a **sub-issue**; tasks are not synced:
292
+ A spec is a Linear **issue**; each phase is a **sub-issue**, carrying that
293
+ phase's tasks in its description as a read-only checklist:
293
294
 
294
295
  - **Discover the Linear MCP tools at runtime** (don't hardcode names). If Linear
295
296
  isn't connected/authed, relay the fix and stop — leave the spec written and
@@ -301,19 +302,20 @@ A spec is a Linear **issue**; each phase is a **sub-issue**; tasks are not synce
301
302
  - **Create a sub-issue per phase** (the `mapping.phases` target — `subissue` by
302
303
  default): a child issue with `parentId` = the spec issue, named from each phase
303
304
  file, in execution order.
304
- - **Add the frontmatter block** to `00-overview.md` (above the `#` title) so the
305
- spec is linkable:
306
-
307
- ```yaml
308
- ---
309
- linear_identifier: "<TEAM-123>"
310
- linear_url: "https://linear.app/..."
311
- last_synced_at: "<ISO-8601 now>"
312
- ---
305
+ - **Stamp the ids** so the spec is linkable one call, no hand-edited
306
+ frontmatter:
307
+
308
+ ```
309
+ skitterspec spec-sync stamp <spec> \
310
+ --issue TEAM-123 --url https://linear.app/… \
311
+ --sub 01-<slug>=TEAM-124 --sub 02-<slug>=TEAM-125
313
312
  ```
314
313
 
315
- Stamp each phase file's `linear_issue_id` with its sub-issue id. (Status is not
316
- stored in frontmatter it comes from the spec's lifecycle folder.)
314
+ It writes `linear_identifier`/`linear_url` onto `00-overview.md` and each phase
315
+ file's `linear_issue_id`, validating every ref and id **before** touching a
316
+ file — on any problem it changes nothing and exits non-zero, so a typo can't
317
+ leave the spec pointing at an issue that isn't there. (Status is not stored in
318
+ frontmatter — it comes from the spec's lifecycle folder.)
317
319
  - **Write the initial base sidecar** so the spec starts clean and non-diverged —
318
320
  run `skitterspec spec-sync record <spec>` to capture the local snapshot as the
319
321
  committed base (`sync.baseDir`). `/spec-status` should report in-sync right after.
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: spec-push
3
- description: Push a spec up to its linked Linear issue (repo → Linear, one-way). The repo is the source of truth; Linear is a generated mirror. A spec is a Linear issue and each phase a sub-issue; tasks are not synced. Runs `skitterspec spec-sync push` to get a create/update plan, applies it over MCP (issue description/state, phase sub-issues), stamps the returned ids back into the spec, then records the snapshot. Never reads Linear content back. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-push", "push to Linear", "update the Linear issue from this spec".
3
+ description: Push a spec up to its linked Linear issue (repo → Linear, one-way). The repo is the source of truth; Linear is a generated mirror. A spec is a Linear issue and each phase a sub-issue, with the phase's tasks mirrored read-only into that sub-issue's description. Runs `skitterspec spec-sync push` to get a create/update plan, applies it over MCP (issue description/state, phase sub-issues), stamps the returned ids back into the spec, then records the snapshot. Never reads Linear content back. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-push", "push to Linear", "update the Linear issue from this spec".
4
4
  ---
5
5
 
6
6
  # /spec-push — send a spec up to Linear (one-way)
7
7
 
8
8
  Repo → Linear. The repo is the **source of truth**; Linear is a **generated
9
9
  mirror**. A spec is a Linear **issue**; each phase is a **sub-issue** (a child
10
- issue). Tasks are not synced they live only in the repo phase files. This skill
10
+ issue), and a phase's tasks are mirrored into that sub-issue's description as a
11
+ read-only checklist — never as issues of their own. This skill
11
12
  computes what changed since the last push and applies it — it never reads Linear
12
13
  content back or merges. A person editing the mirror in Linear will see it
13
14
  overwritten on the next push.
@@ -62,22 +63,37 @@ config — Linear silently ignores an unknown issue state.
62
63
  **mints** it: run the picker in **Picking the Linear Project** below, then
63
64
  create it with `save_issue` (`team` = `linear.teamId`; `project` = the picked
64
65
  id; `title` from the spec title; `description` from `plan.issue.description`;
65
- state from `plan.issue.state` via `config.states`). Stamp the returned
66
- identifier into `00-overview.md` frontmatter as `linear_identifier` (and
67
- `linear_url`). If it already exists and `plan.issue` is present, **update it by
68
- id and send no `project`** — its placement is Linear's from then on.
66
+ state from `plan.issue.state` via `config.states`). Keep the returned
67
+ identifier and url for step 5 — don't hand-edit frontmatter. If it already
68
+ exists and `plan.issue` is present, **update it by id and send no `project`**
69
+ — its placement is Linear's from then on.
69
70
  2. **Sub-issues create** → for each, `save_issue` with `parentId` = the spec
70
- issue id (`name` → title, `goal` → description, `state` via `config.states`);
71
- stamp the returned id into its phase file as `linear_issue_id` (`ref` is the
72
- phase-file basename).
71
+ issue id (`name` → title, `goal` → description, `state` via `config.states`).
72
+ Keep each returned id against its `ref` (the phase-file basename).
73
73
  3. **Sub-issues update** → `save_issue` by `id` (title/description/state).
74
74
 
75
75
  Priority, labels, cycles and comments are Linear-native triage — do **not** push
76
76
  them; they're the PM's.
77
77
 
78
- ## 5. Record the snapshot
78
+ ## 5. Stamp the ids, then record the snapshot
79
79
 
80
- After everything applied and the ids are stamped into the files:
80
+ Write every id you collected back into the spec in **one** call the engine
81
+ does the file edits, so there is no hand-editing of frontmatter:
82
+
83
+ ```
84
+ skitterspec spec-sync stamp <spec> \
85
+ --issue SKI-11 --url https://linear.app/… \
86
+ --sub 01-outbox=SKI-12 --sub 02-api=SKI-13
87
+ ```
88
+
89
+ Pass `--issue`/`--url` only on the push that minted the spec issue; pass one
90
+ `--sub <ref>=<id>` for every sub-issue **created** in step 4.2 (updates already
91
+ have their id). It validates every ref and id **before** writing anything and
92
+ exits non-zero having changed nothing if any is wrong — so a typo can't leave the
93
+ spec half-stamped, pointing at an issue that isn't there. Fix what it reports and
94
+ re-run; it is safe to repeat.
95
+
96
+ Then record what was pushed:
81
97
 
82
98
  ```
83
99
  skitterspec spec-sync record <spec>
@@ -60,6 +60,19 @@ reading the code, do that instead of asking.
60
60
  creating a `0N-<slug>.md` file **and** a matching overview index row, or drop a
61
61
  dead phase by removing both. Keep the index and files in sync; **preserve
62
62
  completed `[x]` history**.
63
+ - **A phase's status is the `⬜`/`🔄`/`✅` on its H1** — `# Phase 2 — <goal> 🔄`.
64
+ It is mirrored in that file's `> **Status:**` line and in the matching
65
+ `00-overview.md` phase-index row, but the **heading is the authoritative one**:
66
+ tooling reads it, and a heading carrying no emoji reads as *not started*. So a
67
+ finished phase marked only by a `**Status:** done` line is silently wrong
68
+ everywhere except the file itself. Set all three together.
69
+ - **Especially when you create phase files.** Migrating a legacy spec (a bare
70
+ `<name>.md`, or a `00-overview.md` with inline phases) into the folder +
71
+ phase-file form means authoring `0N-<slug>.md` headings from scratch — carry
72
+ each phase's real status onto its H1 as you split it out, rather than
73
+ defaulting every new file to `⬜`. A ticketing provider projects that emoji as
74
+ the phase's state in the tracker, so getting it wrong mirrors a
75
+ complete phase as not-started.
63
76
  - Tick tasks already satisfied by the code; re-open `## Open questions` for
64
77
  anything still undecided.
65
78
  - Add a dated **Changelog** entry summarising the review (e.g. `- <date> —
@@ -15,7 +15,11 @@ const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
15
15
  async function main(argv) {
16
16
  const [cmd, ...rest] = argv
17
17
  if (cmd === 'spec-sync') {
18
- await specSync(rest)
18
+ // Propagate the exit code, like spec-sanitise below. Dropping it made
19
+ // `status --workspace-states` (a bad state name) and `stamp` (a refused
20
+ // write) both look successful to any caller checking $?, which is exactly
21
+ // what the /spec-push skill does before it applies a plan.
22
+ process.exitCode = await specSync(rest)
19
23
  return
20
24
  }
21
25
  if (cmd === 'spec-sanitise') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "9.0.0",
3
+ "version": "9.2.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",
@@ -10,12 +10,13 @@
10
10
  *
11
11
  * spec-sync normalize <spec> print the local projection (JSON)
12
12
  * spec-sync push <spec> print the create/update PLAN the skill applies
13
+ * spec-sync stamp <spec> write returned ids back into the spec files
13
14
  * spec-sync record <spec> write the last-pushed snapshot (after apply)
14
15
  * spec-sync status <spec> read-only drift report (never writes)
15
16
  * spec-sync linked list every spec's linear_identifier (offline)
16
17
  *
17
- * The `/spec-push` skill: `push` → apply the plan over MCP → stamp returned ids
18
- * into the repo → `record`. There is no pull — Linear is not read for content.
18
+ * The `/spec-push` skill: `push` → apply the plan over MCP → `stamp` the returned
19
+ * ids into the repo → `record`. There is no pull — Linear is not read for content.
19
20
  */
20
21
 
21
22
  const fs = require('node:fs')
@@ -34,6 +35,10 @@ const {
34
35
  isEmptyPlan,
35
36
  remoteWorkflowState,
36
37
  validateStates,
38
+ lintPhases,
39
+ writeFrontmatter,
40
+ stampSubIssueId,
41
+ listPhaseFiles,
37
42
  } = require('../sync-core')
38
43
 
39
44
  const { loadLinearConfig } = require('./config.js')
@@ -131,6 +136,24 @@ function specSyncLinked(dir, config, flags, out) {
131
136
  out.write(lines.join('\n') + '\n')
132
137
  }
133
138
 
139
+ // Phase-status warnings for a spec, one formatted line each.
140
+ //
141
+ // A spec states each phase's status three times — the phase file's h1 emoji, its
142
+ // `> **Status:**` line, and the overview phase-index row — and only the h1 is
143
+ // read. Get it wrong and the phase projects as `backlog`, pushes cleanly, and is
144
+ // recorded as intended: invisible. So every subcommand that reads a projection
145
+ // reports these, and none of them treats one as fatal — a legacy spec must still
146
+ // push. See sync-core `lintPhases`.
147
+ function warningLines(snapshotDir, config) {
148
+ return lintPhases(snapshotDir, config).map((w) => ` warning ${w.file}: ${w.message}`)
149
+ }
150
+
151
+ // Emit warnings on stderr, keeping stdout pure for a machine-readable payload.
152
+ function warnToErr(snapshotDir, config, err) {
153
+ const lines = warningLines(snapshotDir, config)
154
+ if (lines.length) err.write(lines.join('\n') + '\n')
155
+ }
156
+
134
157
  function resolveOrExit(specArg, dir, out) {
135
158
  if (!specArg) return null
136
159
  const snapshotDir = resolveSnapshotDir(specArg, dir)
@@ -142,26 +165,29 @@ function resolveOrExit(specArg, dir, out) {
142
165
  }
143
166
 
144
167
  // `spec-sync normalize <spec>` — print the local projection as JSON.
145
- function specSyncNormalize(dir, config, specArg, out) {
168
+ function specSyncNormalize(dir, config, specArg, out, err) {
146
169
  const snapshotDir = resolveOrExit(specArg, dir, out)
147
170
  if (!snapshotDir) return
171
+ // stdout is the projection and nothing else — callers pipe it into jq.
172
+ warnToErr(snapshotDir, config, err)
148
173
  out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
149
174
  }
150
175
 
151
176
  // `spec-sync push <spec> [--json]` — print the create/update PLAN diffed against
152
177
  // the last-pushed snapshot. Machine-readable by default; the /spec-push skill
153
178
  // applies it over MCP then calls `record`.
154
- function specSyncPush(dir, config, specArg, flags, out) {
179
+ function specSyncPush(dir, config, specArg, flags, out, err) {
155
180
  const snapshotDir = resolveOrExit(specArg, dir, out)
156
181
  if (!snapshotDir) return
157
182
  const identifier = specIdentifier(snapshotDir, config)
158
183
  const r = push({ dir, snapshotDir, identifier, config })
159
184
  if (flags.json || !out.isTTY) {
185
+ warnToErr(snapshotDir, config, err)
160
186
  out.write(JSON.stringify(r.plan, null, 2) + '\n')
161
187
  return
162
188
  }
163
189
  const p = r.plan
164
- const lines = [`spec-sync push: ${identifier}`]
190
+ const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
165
191
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
166
192
  else {
167
193
  if (p.issue) lines.push(' issue: description/state')
@@ -172,6 +198,89 @@ function specSyncPush(dir, config, specArg, flags, out) {
172
198
  out.write(lines.join('\n') + '\n')
173
199
  }
174
200
 
201
+ // A tracker id as it appears in a spec: `SKI-42`. Deliberately strict — the
202
+ // whole point of `stamp` is that a mistyped id is caught here rather than
203
+ // re-minting a duplicate issue on the next push.
204
+ const ID_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
205
+
206
+ // Resolve a `--sub` ref to a phase file in the spec folder. Accepts the ref as
207
+ // the plan emits it (`01-outbox`) or with its extension (`01-outbox.md`).
208
+ function resolvePhaseFile(snapshotDir, ref) {
209
+ const want = String(ref).replace(/\.md$/, '')
210
+ return listPhaseFiles(snapshotDir).find((f) => f.replace(/\.md$/, '') === want) || null
211
+ }
212
+
213
+ /**
214
+ * `spec-sync stamp <spec> --issue KEY-N [--url URL] --sub <ref>=KEY-M …`
215
+ *
216
+ * Write the ids a push just returned back into the spec: `linear_identifier` /
217
+ * `linear_url` onto the overview, `linear_issue_id` onto each phase file. This
218
+ * was prose in `/spec-push` telling the agent to hand-edit N files — the step
219
+ * most likely to go wrong at scale, because one mistyped id makes the next push
220
+ * treat the phase as unlinked and mint a duplicate issue.
221
+ *
222
+ * Validates EVERYTHING before writing ANYTHING: a bad ref or id fails the whole
223
+ * command with nothing touched. A half-stamped spec is worse than an unstamped
224
+ * one — it looks linked while pointing at the wrong object.
225
+ *
226
+ * `record` stays a separate call: this writes the repo, that writes the snapshot,
227
+ * and the skill sequences them.
228
+ */
229
+ function specSyncStamp(dir, config, specArg, flags, out) {
230
+ const snapshotDir = resolveOrExit(specArg, dir, out)
231
+ if (!snapshotDir) return 1
232
+
233
+ const problems = []
234
+ if (flags.issue != null && !ID_RE.test(flags.issue)) {
235
+ problems.push(`--issue ${flags.issue} is not an id like SKI-42`)
236
+ }
237
+ if (flags.url != null && !/^https?:\/\//.test(flags.url)) {
238
+ problems.push(`--url ${flags.url} is not an http(s) URL`)
239
+ }
240
+
241
+ const subs = []
242
+ for (const raw of flags.subs) {
243
+ const eq = String(raw).indexOf('=')
244
+ if (eq === -1) {
245
+ problems.push(`--sub ${raw} is not <ref>=<id> (e.g. --sub 01-outbox=SKI-43)`)
246
+ continue
247
+ }
248
+ const ref = raw.slice(0, eq)
249
+ const id = raw.slice(eq + 1)
250
+ const file = resolvePhaseFile(snapshotDir, ref)
251
+ if (!file) problems.push(`--sub ${ref}: no phase file in ${path.relative(dir, snapshotDir)}`)
252
+ if (!ID_RE.test(id)) problems.push(`--sub ${ref}=${id}: not an id like SKI-42`)
253
+ if (file && ID_RE.test(id)) subs.push({ ref, id, file })
254
+ }
255
+
256
+ if (!problems.length && flags.issue == null && !subs.length) {
257
+ problems.push('nothing to stamp — pass --issue and/or --sub <ref>=<id>')
258
+ }
259
+
260
+ if (problems.length) {
261
+ // Every problem at once: fixing them one round-trip at a time is the same
262
+ // slow hand-editing this command replaces.
263
+ out.write(['spec-sync stamp: refusing to write — nothing was changed', ...problems.map((p) => ` ${p}`)].join('\n') + '\n')
264
+ return 1
265
+ }
266
+
267
+ const lines = [`spec-sync stamp: ${path.relative(dir, snapshotDir)}`]
268
+ if (flags.issue != null || flags.url != null) {
269
+ const written = writeFrontmatter(snapshotDir, config, {
270
+ linear_identifier: flags.issue,
271
+ linear_url: flags.url,
272
+ })
273
+ lines.push(` overview: ${written.join(', ')}`)
274
+ }
275
+ for (const s of subs) {
276
+ stampSubIssueId(snapshotDir, s.file, s.id)
277
+ lines.push(` ${s.file}: linear_issue_id = ${s.id}`)
278
+ }
279
+ lines.push(' next: skitterspec spec-sync record <spec>')
280
+ out.write(lines.join('\n') + '\n')
281
+ return 0
282
+ }
283
+
175
284
  // `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
176
285
  // files. The skill calls this AFTER applying the plan and stamping new ids.
177
286
  function specSyncRecord(dir, config, specArg, out) {
@@ -191,7 +300,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
191
300
  const snapshotDir = resolveOrExit(specArg, dir, out)
192
301
  if (!snapshotDir) return
193
302
  const identifier = specIdentifier(snapshotDir, config)
194
- const lines = [`spec-sync status: ${identifier}`]
303
+ const lines = [`spec-sync status: ${identifier}`, ...warningLines(snapshotDir, config)]
195
304
 
196
305
  if (flags.workspaceStates && fs.existsSync(flags.workspaceStates)) {
197
306
  const names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
@@ -234,15 +343,19 @@ function specSyncStatus(dir, config, specArg, flags, out) {
234
343
 
235
344
  async function specSync(rest, io = {}) {
236
345
  const out = io.out || process.stdout
346
+ const err = io.err || process.stderr
237
347
  const [sub, ...args] = rest
238
348
  let dir = io.cwd || process.cwd()
239
349
  const positional = []
240
- const flags = { json: false, remote: null, workspaceStates: null }
350
+ const flags = { json: false, remote: null, workspaceStates: null, issue: null, url: null, subs: [] }
241
351
  for (let i = 0; i < args.length; i++) {
242
352
  if (args[i] === '--dir') dir = path.resolve(args[++i])
243
353
  else if (args[i] === '--json') flags.json = true
244
354
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
245
355
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
356
+ else if (args[i] === '--issue') flags.issue = args[++i]
357
+ else if (args[i] === '--url') flags.url = args[++i]
358
+ else if (args[i] === '--sub') flags.subs.push(args[++i])
246
359
  else positional.push(args[i])
247
360
  }
248
361
  dir = path.resolve(dir)
@@ -258,11 +371,13 @@ async function specSync(rest, io = {}) {
258
371
 
259
372
  switch (sub) {
260
373
  case 'normalize':
261
- specSyncNormalize(dir, config, positional[0], out)
374
+ specSyncNormalize(dir, config, positional[0], out, err)
262
375
  return 0
263
376
  case 'push':
264
- specSyncPush(dir, config, positional[0], flags, out)
377
+ specSyncPush(dir, config, positional[0], flags, out, err)
265
378
  return 0
379
+ case 'stamp':
380
+ return specSyncStamp(dir, config, positional[0], flags, out)
266
381
  case 'record':
267
382
  specSyncRecord(dir, config, positional[0], out)
268
383
  return 0
@@ -273,6 +388,7 @@ async function specSync(rest, io = {}) {
273
388
  return 0
274
389
  default:
275
390
  out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
391
+ ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
276
392
  ' skitterspec spec-sync linked [--json]\n')
277
393
  return 0
278
394
  }
@@ -37,6 +37,13 @@ const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
37
37
 
38
38
  const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
39
39
 
40
+ // How a phase's task list is projected into its sub-issue description.
41
+ // checklist — mirror the tasks as a read-only markdown checklist (default)
42
+ // none — sub-issue description is the phase's `**Goal:**` line alone
43
+ // Tasks are never read back either way; the repo stays the source of truth and a
44
+ // box ticked in the tracker is overwritten by the next push.
45
+ const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
46
+
40
47
  const DEFAULT_CONFIG = Object.freeze({
41
48
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
42
49
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -52,7 +59,9 @@ const DEFAULT_CONFIG = Object.freeze({
52
59
  intake: Object.freeze({ label: '', bugLabels: Object.freeze([]) }),
53
60
  // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
54
61
  // synced (they live only in the repo phase files).
55
- mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'none' }),
62
+ // A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
63
+ // phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
64
+ mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'checklist' }),
56
65
  // Linear ISSUE workflow-state names — the spec issue's state (from the folder
57
66
  // bucket) and each sub-issue's state (from the phase emoji) both map through
58
67
  // this one table. They must match the workspace's issue states exactly;
@@ -183,6 +192,15 @@ function mergeConfig(base, parsed) {
183
192
  assign(base.mapping, parsed.mapping, 'specFolder', 'string')
184
193
  assign(base.mapping, parsed.mapping, 'phases', 'string')
185
194
  assign(base.mapping, parsed.mapping, 'tasks', 'string')
195
+ // Loud on a typo, like fieldOwnership above. Quietly falling back would make
196
+ // a misspelt value look like a deliberate `none` — the same silent
197
+ // degradation the phase-status lint exists to stamp out.
198
+ if (!TASK_MAPPINGS.includes(base.mapping.tasks)) {
199
+ throw new Error(
200
+ `Invalid ${CONFIG_FILE}: mapping.tasks = ${JSON.stringify(base.mapping.tasks)} ` +
201
+ `(expected one of ${TASK_MAPPINGS.join('|')})`,
202
+ )
203
+ }
186
204
  }
187
205
 
188
206
  if (isObject(parsed.states)) {
@@ -247,4 +265,5 @@ module.exports = {
247
265
  DEFAULT_CONFIG,
248
266
  CONFIG_FILE,
249
267
  OWNERSHIP,
268
+ TASK_MAPPINGS,
250
269
  }
@@ -4,7 +4,8 @@
4
4
  * The Linear MCP boundary — the one place that knows concrete Linear tool names.
5
5
  *
6
6
  * A spec is a Linear **issue** and each phase a **sub-issue** (a child issue
7
- * with a `parentId`); tasks are not synced. `discoverLinear(tools)` resolves the
7
+ * with a `parentId`); a phase's tasks are mirrored into its sub-issue
8
+ * description, never created as issues. `discoverLinear(tools)` resolves the
8
9
  * issue operations the sync needs (read / create / update an issue, optionally
9
10
  * list a parent's children) against the *connected* server's advertised tool
10
11
  * list at runtime, rather than hardcoding names that drift. If Linear isn't
@@ -10,15 +10,16 @@
10
10
  * over its API. No remote content is read or merged.
11
11
  */
12
12
 
13
- const { normalizeLocal, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
13
+ const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
14
14
  const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
15
15
  const { readBase, writeBase } = require('./src/base.js')
16
16
  const { push, recordPush, projectionOf } = require('./src/push.js')
17
- const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
17
+ const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
18
18
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
19
 
20
20
  module.exports = {
21
21
  normalizeLocal,
22
+ lintPhases,
22
23
  readSnapshot,
23
24
  parseFrontmatter,
24
25
  projectionOf,
@@ -38,5 +39,6 @@ module.exports = {
38
39
  stampSubIssueId,
39
40
  stampIssueId,
40
41
  findPhaseFileByTitle,
42
+ listPhaseFiles,
41
43
  sanitizeSpecMarkdown,
42
44
  }
@@ -253,14 +253,53 @@ function phaseTitle(body) {
253
253
  return t || null
254
254
  }
255
255
 
256
+ // The raw ⬜/🔄/✅ on a phase file's h1, or undefined when it carries none.
257
+ // Kept separate from `phaseStateBucket` because the LINT has to tell "absent"
258
+ // apart from "not-started" — the projection deliberately cannot (see below).
259
+ function headingEmoji(body) {
260
+ const h1 = /^#\s+(.*)$/m.exec(body)
261
+ return h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
262
+ }
263
+
264
+ // The `> **Status:** …` line's value, or null when the file has none. This is
265
+ // the human mirror of the heading emoji, not a source of truth — `lintPhases`
266
+ // cross-checks it, and nothing else reads it.
267
+ function phaseStatusLine(body) {
268
+ const m = /^>.*\*\*Status:\*\*\s*(.+?)\s*$/m.exec(body)
269
+ return m ? m[1] : null
270
+ }
271
+
272
+ // Read a `> **Status:**` value leniently into the canonical vocabulary: an emoji
273
+ // if it carries one, else a word we recognise. Returns null for anything else —
274
+ // the line is free prose, and warning on an unrecognised phrasing would train
275
+ // the warning away.
276
+ // `pending` is deliberately NOT here: "pending review" matches as not-started
277
+ // but means roughly the opposite, and a false positive is worse than a missed
278
+ // check — it teaches the reader to ignore the warning.
279
+ const STATUS_WORDS = [
280
+ [/\b(not[\s-]?started|todo|to[\s-]do|planned)\b/i, 'not-started'],
281
+ [/\b(in[\s-]?progress|started|doing|wip)\b/i, 'in-progress'],
282
+ [/\b(done|complete[d]?|finished|shipped)\b/i, 'done'],
283
+ ]
284
+ function statusLineValue(line) {
285
+ if (!line) return null
286
+ const emoji = (line.match(/[⬜🔄✅]/u) || [])[0]
287
+ if (emoji) return EMOJI_STATUS[emoji]
288
+ for (const [re, status] of STATUS_WORDS) if (re.test(line)) return status
289
+ return null
290
+ }
291
+
256
292
  // A phase's status (from its heading emoji ⬜/🔄/✅) mapped to a state bucket the
257
293
  // `states` table understands, so a sub-issue lands in the matching Linear issue
258
294
  // state. Unknown/absent → backlog.
295
+ //
296
+ // That fallback conflates "author marked it not-started" with "author used a
297
+ // format we don't parse", which is silent corruption: the wrong state pushes
298
+ // cleanly and `record` then commits it as the INTENDED value. The fix is not
299
+ // leniency here — one convention beats two — it is `lintPhases`, which warns.
259
300
  const PHASE_STATE_BUCKET = { 'not-started': 'backlog', 'in-progress': 'in-progress', done: 'complete' }
260
301
  function phaseStateBucket(body) {
261
- const h1 = /^#\s+(.*)$/m.exec(body)
262
- const emoji = h1 ? (h1[1].match(/[⬜🔄✅]/u) || [])[0] : undefined
263
- return PHASE_STATE_BUCKET[EMOJI_STATUS[emoji]] || 'backlog'
302
+ return PHASE_STATE_BUCKET[EMOJI_STATUS[headingEmoji(body)]] || 'backlog'
264
303
  }
265
304
 
266
305
  // Parse a task line (already stripped of its leading "- ") into a keyed item:
@@ -303,7 +342,15 @@ function readPhaseFiles(snapshotDir) {
303
342
  // and Linear may canonicalize a soft line break away on save. Collapsing
304
343
  // both sides keeps a wrapped goal from diffing forever.
305
344
  const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
306
- const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
345
+ // Rendered as markdown checklist lines, ready to drop into a sub-issue
346
+ // description: indentation kept so nesting survives, the bullet marker
347
+ // normalised to `-`, and any inline `(KEY-123)` stamped on a legacy task
348
+ // line stripped — those ids were per-task issues we no longer create, and
349
+ // they read as noise in the mirror.
350
+ const tasks = findTaskBlocks(body.split('\n')).map((b) => {
351
+ const parsed = parseTaskLine(`[${b.mark}] ${b.text}`)
352
+ return `${b.indent}- [${b.mark}] ${parsed ? parsed.text : b.text}`
353
+ })
307
354
  return {
308
355
  phase: file.replace(/\.md$/, ''),
309
356
  file,
@@ -312,11 +359,85 @@ function readPhaseFiles(snapshotDir) {
312
359
  name: phaseTitle(body),
313
360
  goal: goal.trim(),
314
361
  state: phaseStateBucket(body),
362
+ // Lint-only signals. `emoji` is undefined when the heading carries none
363
+ // — the distinction `state` throws away; `statusLine` is the raw
364
+ // `> **Status:**` value. Neither affects the projection.
365
+ emoji: headingEmoji(body),
366
+ statusLine: phaseStatusLine(body),
315
367
  tasks,
316
368
  }
317
369
  })
318
370
  }
319
371
 
372
+ // --- phase-status lint ------------------------------------------------------
373
+
374
+ /**
375
+ * Warn where a phase's status signals are absent or disagree.
376
+ *
377
+ * A spec carries the same status in three places — the phase file's h1 emoji,
378
+ * its `> **Status:**` line, and the `00-overview.md` phase-index row — and only
379
+ * the h1 is load-bearing. Writing the other two correctly while leaving the h1
380
+ * bare projects a finished phase as `backlog`, pushes cleanly, and records the
381
+ * wrong value as intended. Nothing looks wrong anywhere.
382
+ *
383
+ * Returns `[{ file, code, message }]`, `code` being `missing-status-emoji` or
384
+ * `status-disagreement`. Pure aside from reads; callers decide how loud to be
385
+ * (today: printed, never fatal).
386
+ */
387
+ function lintPhases(snapshotDir, config) {
388
+ const phases = readPhaseFiles(snapshotDir)
389
+ if (!phases.length) return []
390
+
391
+ // The overview may be absent (a legacy bare `<name>.md` spec) — that is not
392
+ // itself a lint failure, it just removes one of the three cross-checks.
393
+ let indexRows = []
394
+ try {
395
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
396
+ const raw = fs.readFileSync(path.join(snapshotDir, overviewFile), 'utf-8')
397
+ const { sections } = parseSections(parseFrontmatter(raw).body)
398
+ indexRows = parsePhaseIndex(sections.Phases)
399
+ } catch {
400
+ indexRows = []
401
+ }
402
+
403
+ const warnings = []
404
+ phases.forEach((phase, i) => {
405
+ if (!phase.emoji) {
406
+ warnings.push({
407
+ file: phase.file,
408
+ code: 'missing-status-emoji',
409
+ message: `no ⬜/🔄/✅ in the heading — projecting as not-started`,
410
+ })
411
+ // Without a heading emoji there is nothing to disagree WITH: the other two
412
+ // signals can't be checked against a value that was never expressed.
413
+ return
414
+ }
415
+
416
+ const heading = EMOJI_STATUS[phase.emoji]
417
+
418
+ const fromLine = statusLineValue(phase.statusLine)
419
+ if (fromLine && fromLine !== heading) {
420
+ warnings.push({
421
+ file: phase.file,
422
+ code: 'status-disagreement',
423
+ message: `heading says ${heading} but its Status line says ${fromLine}`,
424
+ })
425
+ }
426
+
427
+ // Match the index row by phase title, falling back to position — a renamed
428
+ // phase shouldn't silently drop the check.
429
+ const row = indexRows.find((r) => r.name === phase.name) || indexRows[i]
430
+ if (row && row.status !== heading) {
431
+ warnings.push({
432
+ file: phase.file,
433
+ code: 'status-disagreement',
434
+ message: `heading says ${heading} but the overview phase-index row says ${row.status}`,
435
+ })
436
+ }
437
+ })
438
+ return warnings
439
+ }
440
+
320
441
  // --- ownership-driven field set ---------------------------------------------
321
442
 
322
443
  // Reduce an `extracted` map to exactly the configured field keys, defaulting a
@@ -359,6 +480,22 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
359
480
  return canonicalizeMarkdown(parts.join('\n\n')) || null
360
481
  }
361
482
 
483
+ // A phase sub-issue's description: its `**Goal:**` line, plus the phase's task
484
+ // list as a markdown checklist when `mapping.tasks` is `checklist`.
485
+ //
486
+ // The checklist is a READ-ONLY mirror like everything else here — the repo is
487
+ // the source of truth, so a box ticked in the tracker is overwritten on the next
488
+ // push. Without it a sub-issue is a title and one sentence, which is too thin to
489
+ // act on; with it the phase is legible to someone working in the tracker without
490
+ // tasks becoming individually-synced objects again.
491
+ function subIssueBody(phase, tasksMode) {
492
+ if (tasksMode !== 'checklist' || !phase.tasks.length) return phase.goal
493
+ const parts = []
494
+ if (phase.goal) parts.push(phase.goal, '')
495
+ parts.push('## Tasks', '', ...phase.tasks)
496
+ return parts.join('\n')
497
+ }
498
+
362
499
  /**
363
500
  * Normalize a local spec snapshot into the configured field set.
364
501
  */
@@ -376,6 +513,7 @@ function normalizeLocal(snapshotDir, config) {
376
513
  // so strip the `## Phases` index from the description to avoid duplicating it
377
514
  // (as prose AND as sub-issues) in the Linear mirror.
378
515
  const phasesProjected = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
516
+ const tasksMode = (config.mapping && config.mapping.tasks) || 'checklist'
379
517
  const extracted = {
380
518
  description: buildDescription(
381
519
  title,
@@ -386,11 +524,12 @@ function normalizeLocal(snapshotDir, config) {
386
524
  // Sub-issue projection: one per phase. `ref` is the phase-file basename — the
387
525
  // local handle the push skill stamps a newly-created sub-issue id back into.
388
526
  // `state` is the phase's status bucket (from its heading emoji), mapped to a
389
- // Linear issue state via `config.states` at push time. Tasks are NOT
390
- // projected they live only in the repo phase files.
527
+ // Linear issue state via `config.states` at push time. Tasks ride along in
528
+ // the description as a read-only checklist (`mapping.tasks`), never as
529
+ // individually-synced objects.
391
530
  subIssues: phases
392
531
  .filter((p) => p.name)
393
- .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal, state: p.state })),
532
+ .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: subIssueBody(p, tasksMode), state: p.state })),
394
533
  // Status is the spec's lifecycle bucket. The folder is the source of truth;
395
534
  // an explicit `spec_status` frontmatter key overrides it if present.
396
535
  workflowState:
@@ -526,6 +665,7 @@ function validateStates(config, workspaceStates) {
526
665
 
527
666
  module.exports = {
528
667
  normalizeLocal,
668
+ lintPhases,
529
669
  readSnapshot,
530
670
  parseFrontmatter,
531
671
  parseSections,