@skitterbyte/skitterspec-linear 9.1.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 +15 -4
- package/assets/core/SETUP.md +3 -2
- package/assets/core/linear.config.json.example +1 -1
- package/assets/core/linear.config.md +19 -5
- package/assets/skills/spec/SKILL.md +14 -12
- package/assets/skills/spec-push/SKILL.md +27 -11
- package/assets/skills/spec-review/SKILL.md +13 -0
- package/bin/skitterspec-linear.js +5 -1
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +125 -9
- package/src/vendor/linear/config.js +20 -1
- package/src/vendor/linear/mcp.js +2 -1
- package/src/vendor/sync-core/index.js +4 -2
- package/src/vendor/sync-core/src/normalize.js +147 -7
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,
|
|
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**.
|
|
87
|
-
|
|
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`.
|
package/assets/core/SETUP.md
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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,
|
|
@@ -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;
|
|
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)
|
|
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": "
|
|
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
|
|
116
|
-
|
|
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
|
|
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
|
-
- **
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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)
|
|
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`).
|
|
66
|
-
identifier
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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.
|
|
78
|
+
## 5. Stamp the ids, then record the snapshot
|
|
79
79
|
|
|
80
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
390
|
-
//
|
|
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
|
|
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,
|