@skitterbyte/skitterspec-linear 10.5.0 → 10.6.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/assets/core/linear.config.md +16 -0
- package/assets/rules/commit-trailers.md +52 -0
- package/assets/skills/spec-bug/SKILL.md +24 -0
- package/assets/skills/spec-go/SKILL.md +50 -20
- package/assets/skills/spec-hotfix/SKILL.md +24 -0
- package/assets/skills/spec-linear-setup/SKILL.md +30 -3
- package/assets/skills/spec-sync/SKILL.md +26 -2
- package/package.json +1 -1
- package/src/vendor/linear/api.js +30 -0
- package/src/vendor/linear/cli-sync.js +301 -12
- package/src/vendor/linear/doctor.js +110 -0
- package/src/vendor/linear/released.js +80 -0
|
@@ -403,6 +403,22 @@ issue exists, where it lives is Linear's business: move it between projects and
|
|
|
403
403
|
A spec that **adopted** an existing issue (see below) skips the picker entirely —
|
|
404
404
|
it was filed somewhere deliberately.
|
|
405
405
|
|
|
406
|
+
`skitterspec spec-sync doctor` reports what this setting is doing:
|
|
407
|
+
|
|
408
|
+
| `linear.projectId` | Row | Meaning |
|
|
409
|
+
|--------------------|-----|---------|
|
|
410
|
+
| unset | `missing` | specs file to the team, and the picker asks each push — a supported choice, and it never fails the run |
|
|
411
|
+
| set | `ok` | the id is there; add `--check-remote` to resolve it against Linear |
|
|
412
|
+
| set, resolves in the team | `ok` | the project exists and this team is one of its teams |
|
|
413
|
+
| set, resolves elsewhere | `broken` | specs would file out of the team — exits non-zero |
|
|
414
|
+
| set, resolves to nothing | `broken` | a deleted or mistyped id |
|
|
415
|
+
|
|
416
|
+
`doctor --mcp <file>` additionally compares this id — and the team and workspace —
|
|
417
|
+
against what the Linear **MCP server** reports, so the two transports cannot
|
|
418
|
+
quietly point at different places. A mismatch is `broken` and names both sides; it
|
|
419
|
+
is never resolved by rewriting this file, because which side is wrong is yours to
|
|
420
|
+
say.
|
|
421
|
+
|
|
406
422
|
### Known limits — one team per repo, no initiatives
|
|
407
423
|
|
|
408
424
|
Two things this config deliberately cannot express today. Both are limits, not
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Commit Trailers
|
|
2
|
+
|
|
3
|
+
Extends `commit-messages.md` — the format, length limits and `Release-Note:`
|
|
4
|
+
grammar there all still apply. This file adds one trailer, and it is only
|
|
5
|
+
installed when a ticketing provider is.
|
|
6
|
+
|
|
7
|
+
## `Refs:` — the ticket this commit belongs to
|
|
8
|
+
|
|
9
|
+
A commit made on a spec's branch carries the Linear issue it belongs to, as the
|
|
10
|
+
**last** trailer in the message:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
fix(sync): verify no longer flags every update
|
|
14
|
+
|
|
15
|
+
- Key the apply read-back by ref, resolving id via the projection
|
|
16
|
+
|
|
17
|
+
Release-Note: Pushing a spec whose phases already exist in Linear no
|
|
18
|
+
longer reports every one of them as a possible stale reference.
|
|
19
|
+
|
|
20
|
+
Refs: SKS-29
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Get the value from the engine rather than reading it off a spec by hand:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pnpm exec skitterspec-linear spec-sync ref
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- **Omit the trailer entirely when there is no ref.** `spec-sync ref` exits
|
|
32
|
+
non-zero and prints nothing on a commit that has no ticket — on `main`, or on
|
|
33
|
+
a spec kept deliberately local. Never invent one, and never write
|
|
34
|
+
`Refs: none`: the release report counts unreferenced commits, and a fabricated
|
|
35
|
+
ref is worse than an honest gap.
|
|
36
|
+
- **One ref per commit.** A commit belongs to one spec — the spec whose branch
|
|
37
|
+
it is on. If work genuinely spans two tickets, that is two commits.
|
|
38
|
+
- **Never use Linear's magic words** — `Fixes`, `Closes`, `Resolves`. Those
|
|
39
|
+
close the issue the moment the commit reaches the default branch, which is
|
|
40
|
+
wrong here: a ticket moves when its work is **released**, not when it merges.
|
|
41
|
+
`Refs:` is deliberately inert to Linear's automation.
|
|
42
|
+
- **Blank line before it**, like `Release-Note:` — it is a git trailer, not body
|
|
43
|
+
text.
|
|
44
|
+
- Put it **after** `Release-Note:` when both are present, so the human-facing
|
|
45
|
+
note reads first.
|
|
46
|
+
|
|
47
|
+
## Why it exists
|
|
48
|
+
|
|
49
|
+
The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
50
|
+
names never reach it. The commit message is the only artefact that survives into
|
|
51
|
+
the range a release scans, which is what lets
|
|
52
|
+
`spec-sync released <range>` report the tickets a release contains.
|
|
@@ -311,6 +311,30 @@ test, split the fix into phase files (`01-<slug>.md` …) with a phase index in
|
|
|
311
311
|
`00-overview.md`, and leave the spec in `in-progress` for `/spec-go` to continue.
|
|
312
312
|
Say so explicitly — don't fake green.
|
|
313
313
|
|
|
314
|
+
**Then refresh the mirror (only if a provider is installed).** The Fix tasks are
|
|
315
|
+
ticked, so the repo is now the truth about this fix — and this skill can take a
|
|
316
|
+
bug all the way to green without `/spec-go` ever running. Without a provider this
|
|
317
|
+
is a no-op.
|
|
318
|
+
|
|
319
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
320
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
321
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
322
|
+
|
|
323
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
324
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
325
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
326
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
327
|
+
|
|
328
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
329
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
330
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
331
|
+
the mirror is disposable and the next push repairs it.
|
|
332
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
333
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
334
|
+
sweeps it up with the phase's own work.
|
|
335
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
336
|
+
unlinked, or failed with the reason.
|
|
337
|
+
|
|
314
338
|
## 6. Report
|
|
315
339
|
|
|
316
340
|
Summarise: root cause, the failing→passing test, the fix, and the full test
|
|
@@ -139,11 +139,17 @@ Before writing any code for this phase, get the workspace clean:
|
|
|
139
139
|
next phase on top of an uncommitted one. (Skip if this is the first phase —
|
|
140
140
|
there's nothing prior to commit.)
|
|
141
141
|
|
|
142
|
-
##
|
|
142
|
+
## 4. Implement the phase
|
|
143
|
+
|
|
144
|
+
Identify the **first unfinished phase** from the `00-overview.md` phase index,
|
|
145
|
+
then open its phase file (`0N-<slug>.md`) — that file holds the tasks. Mark it
|
|
146
|
+
started: set the phase-file heading to `🔄` and its `> **Status:**` to
|
|
147
|
+
`In progress`, and flip the matching row in the overview phase index to `🔄`.
|
|
143
148
|
|
|
144
|
-
**
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
**Then sync with the tracker (only if a provider is installed).** The phase has
|
|
150
|
+
just changed state, so refresh the mirror before the build starts — that is what
|
|
151
|
+
makes the phase show as in progress *while* it is being built rather than only
|
|
152
|
+
once it is over. Without a provider this is a no-op and nothing below changes.
|
|
147
153
|
|
|
148
154
|
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
149
155
|
`linear_identifier`. Otherwise skip this step — no config means zero change.
|
|
@@ -152,26 +158,27 @@ below. Follow the provider's steps below (nothing to do here without one).
|
|
|
152
158
|
to bring down before building — the repo is already the source of truth. (A
|
|
153
159
|
workflow-state a teammate moved in Linear is advisory only; `/spec-status`
|
|
154
160
|
surfaces it. It is overwritten on the next push.)
|
|
155
|
-
- **Refresh the mirror.** Run `/spec-push
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- `"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
- **Refresh the mirror now, without asking.** Run `/spec-push`. The spec has just
|
|
162
|
+
moved to `in-progress` and its phase to `🔄` — both real state changes, and the
|
|
163
|
+
tracker is a generated mirror of them. This holds under **both**
|
|
164
|
+
`mapping.phases` modes, for different reasons:
|
|
165
|
+
- `"subissue"` (the default) — the phase sub-issues already exist, and this
|
|
166
|
+
push is what moves the current one into its in-progress state. Skip it and
|
|
167
|
+
every sub-issue sits in Backlog until the spec completes.
|
|
168
|
+
- `"deferred"` — the sub-issues do not exist yet, and this push is what mints
|
|
169
|
+
them. Skip it and a started spec stays mirrored as a phase-less issue.
|
|
170
|
+
- **Never mint the spec issue.** An unlinked spec is skipped, not created —
|
|
171
|
+
`/spec-push` is how someone opts in.
|
|
172
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
173
|
+
and **carry on with the build**. The repo is correct regardless; the mirror is
|
|
174
|
+
disposable and the next push repairs it.
|
|
175
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
176
|
+
and stamps any new ids, and `/spec-go` does not commit. The next `/commit`
|
|
177
|
+
sweeps it up with the phase's own work.
|
|
165
178
|
- Linear's GitHub branch/PR automation may drive status transitions off the
|
|
166
179
|
branch/PR you pushed in step 2; that's expected and the repo still wins on the
|
|
167
180
|
next `/spec-push`.
|
|
168
181
|
|
|
169
|
-
## 4. Implement the phase
|
|
170
|
-
|
|
171
|
-
Identify the **first unfinished phase** from the `00-overview.md` phase index,
|
|
172
|
-
then open its phase file (`0N-<slug>.md`) — that file holds the tasks. Mark it
|
|
173
|
-
started: set the phase-file heading to `🔄` and its `> **Status:**` to
|
|
174
|
-
`In progress`, and flip the matching row in the overview phase index to `🔄`.
|
|
175
182
|
Then build it, following the project rules in `.claude/rules/*.md` and `CLAUDE.md`:
|
|
176
183
|
|
|
177
184
|
- Work task by task through the phase file. Make focused edits that match
|
|
@@ -194,6 +201,29 @@ Then build it, following the project rules in `.claude/rules/*.md` and `CLAUDE.m
|
|
|
194
201
|
- If new work surfaced, add it as tasks to the appropriate phase file (or add a
|
|
195
202
|
new phase file + index row) rather than doing it silently.
|
|
196
203
|
|
|
204
|
+
**Then refresh the mirror (only if a provider is installed).** The phase is done
|
|
205
|
+
in the repo now; leaving the tracker to catch up at `/spec-complete` is what makes
|
|
206
|
+
a mirror lag a whole spec behind. Without a provider this is a no-op.
|
|
207
|
+
|
|
208
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
209
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
210
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
211
|
+
|
|
212
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
213
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
214
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
215
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
216
|
+
|
|
217
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
218
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
219
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
220
|
+
the mirror is disposable and the next push repairs it.
|
|
221
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
222
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
223
|
+
sweeps it up with the phase's own work.
|
|
224
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
225
|
+
unlinked, or failed with the reason.
|
|
226
|
+
|
|
197
227
|
## 6. Report
|
|
198
228
|
|
|
199
229
|
Summarise what was implemented, the test result (quote failures if any), and
|
|
@@ -318,6 +318,30 @@ message.
|
|
|
318
318
|
- Commit the fix to the `hotfix/<slug>` branch (this commit is what gets tagged
|
|
319
319
|
and cherry-picked). Tick the Fix tasks; add a Changelog line.
|
|
320
320
|
|
|
321
|
+
**Then refresh the mirror (only if a provider is installed).** The Fix tasks are
|
|
322
|
+
ticked, so the repo is now the truth about this fix — and this skill can take a
|
|
323
|
+
bug all the way to green without `/spec-go` ever running. Without a provider this
|
|
324
|
+
is a no-op.
|
|
325
|
+
|
|
326
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
327
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
328
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
329
|
+
|
|
330
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
331
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
332
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
333
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
334
|
+
|
|
335
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
336
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
337
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
338
|
+
the mirror is disposable and the next push repairs it.
|
|
339
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
340
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
341
|
+
sweeps it up with the phase's own work.
|
|
342
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
343
|
+
unlinked, or failed with the reason.
|
|
344
|
+
|
|
321
345
|
## 7. Report
|
|
322
346
|
|
|
323
347
|
Summarise: the base tag, root cause, the failing→passing test, the fix, and the
|
|
@@ -47,6 +47,11 @@ anything. The interview offers real lists; it never prompts for a raw id.
|
|
|
47
47
|
| `list_projects` | the projects, per candidate team — names + ids, minus archived/completed |
|
|
48
48
|
| `list_issue_statuses` | the team's **issue workflow-state names**, exactly as spelled |
|
|
49
49
|
| `list_issue_labels` | the label names available for intake routing |
|
|
50
|
+
| `get_workspace` | **which workspace this MCP server is connected to** — id + name |
|
|
51
|
+
|
|
52
|
+
`get_workspace` is not part of the interview: nothing is asked about it. It is
|
|
53
|
+
recorded so step 8 can prove the MCP server and the API key are pointed at the
|
|
54
|
+
same Linear, which nothing else establishes.
|
|
50
55
|
|
|
51
56
|
Projects, labels and statuses are all **team-scoped** — fetch them for the team
|
|
52
57
|
once step 3 has settled it, not for the whole workspace up front.
|
|
@@ -159,10 +164,22 @@ paraphrase it into "done".
|
|
|
159
164
|
|
|
160
165
|
## 8. Report and hand off
|
|
161
166
|
|
|
162
|
-
**Finish by checking, not by describing.**
|
|
167
|
+
**Finish by checking, not by describing.** First write down what the MCP server
|
|
168
|
+
says, from the reads you already made in step 2 — no extra round trip unless a
|
|
169
|
+
project was chosen and you have not read it yet (`get_project`):
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{ "workspace": {"id": "…", "name": "…"},
|
|
173
|
+
"team": {"id": "…", "key": "SKS"},
|
|
174
|
+
"project": {"id": "…", "name": "…"} }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Every key is optional, and **omit what you could not fetch** rather than guessing
|
|
178
|
+
— an absent field is reported as unchecked, while a wrong one is reported as a
|
|
179
|
+
mismatch. Then run:
|
|
163
180
|
|
|
164
181
|
```
|
|
165
|
-
skitterspec spec-sync doctor
|
|
182
|
+
skitterspec spec-sync doctor --mcp <factsfile>
|
|
166
183
|
```
|
|
167
184
|
|
|
168
185
|
and relay its table. That is the difference between a summary of what setup
|
|
@@ -173,7 +190,17 @@ nothing to paraphrase.
|
|
|
173
190
|
|
|
174
191
|
It exits non-zero only when a layer is **broken** — configured but wrong. A
|
|
175
192
|
`missing` row is an opt-in nobody took, which is fine; report it, don't treat it
|
|
176
|
-
as a failure.
|
|
193
|
+
as a failure. An empty `project` row is exactly that: specs file to the team and
|
|
194
|
+
the picker asks each push.
|
|
195
|
+
|
|
196
|
+
**A `broken` `mcp` row is the one to stop on.** It means the MCP server and the
|
|
197
|
+
config (or the API key) name different workspaces, teams or projects — so where a
|
|
198
|
+
spec lands depends on which transport ran. Relay both sides as the row prints
|
|
199
|
+
them and **stop**: do not rewrite the config to make them agree. Which one is
|
|
200
|
+
correct is the user's to say — the API key may be the wrong one just as easily as
|
|
201
|
+
the config, and picking a winner silently sends their specs somewhere they did
|
|
202
|
+
not choose. Ask which is right, then re-run this skill (or reconnect the MCP
|
|
203
|
+
server) to match it.
|
|
177
204
|
|
|
178
205
|
Then name the next step:
|
|
179
206
|
|
|
@@ -37,6 +37,8 @@ In a project that installs the Linear superset the binary is
|
|
|
37
37
|
| "link this spec to KEY-1 by hand" | `stamp <spec> --issue KEY-1` |
|
|
38
38
|
| "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
|
|
39
39
|
| "is the team key stale?", "did Linear get renamed?" | `retarget` |
|
|
40
|
+
| "what ticket am I on?", writing a commit | `ref` |
|
|
41
|
+
| "what shipped in this release?" | `released` |
|
|
40
42
|
| push one spec, or "what would push?" | **defer** — see below |
|
|
41
43
|
|
|
42
44
|
**With no argument, run `linked`.** It is the repo-wide overview, it is
|
|
@@ -131,7 +133,29 @@ its object exists, so an interrupted run continues rather than duplicating.
|
|
|
131
133
|
|
|
132
134
|
`--all` refuses over MCP by design — bulk goes through the API path.
|
|
133
135
|
|
|
134
|
-
## 7. `
|
|
136
|
+
## 7. `ref` and `released` — tickets in commits
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
pnpm exec skitterspec-linear spec-sync ref [--json]
|
|
140
|
+
pnpm exec skitterspec-linear spec-sync released [<range>] [--json]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`ref` prints the ticket for the branch you are on, so a commit can carry
|
|
144
|
+
`Refs: <KEY-N>` — see `.claude/rules/commit-trailers.md`. **Off a spec branch, or
|
|
145
|
+
on a spec that is not linked, it prints nothing and exits non-zero.** That is the
|
|
146
|
+
correct answer, not an error to work around: do not invent a ref, and never write
|
|
147
|
+
`Refs: none`.
|
|
148
|
+
|
|
149
|
+
`released` reports the tickets in a commit range — by default since the most
|
|
150
|
+
recent tag, and it always prints the range it chose so a wrong default is
|
|
151
|
+
visible. It is **read-only**: it does not move anything in Linear. A release can
|
|
152
|
+
be cut and never deployed, so transitioning tickets stays a deliberate act.
|
|
153
|
+
|
|
154
|
+
Relay the **unreferenced count** even when it is zero. A chore commit
|
|
155
|
+
legitimately carries no ticket and a *missed* trailer looks identical, so
|
|
156
|
+
omitting the number reads as "everything is accounted for" when it may not be.
|
|
157
|
+
|
|
158
|
+
## 8. `retarget` — after a Linear team is renamed
|
|
135
159
|
|
|
136
160
|
```
|
|
137
161
|
pnpm exec skitterspec-linear spec-sync retarget [--yes]
|
|
@@ -160,7 +184,7 @@ rewrites those fields. Read-only until `--yes`.
|
|
|
160
184
|
Over MCP the team key is unreadable (`get_team` does not return it), so it says
|
|
161
185
|
so and asks you to confirm the key rather than guessing.
|
|
162
186
|
|
|
163
|
-
##
|
|
187
|
+
## 9. Report
|
|
164
188
|
|
|
165
189
|
Relay the engine's output. Name the subcommand you ran, in full, so the user can
|
|
166
190
|
re-run it themselves. For anything that wrote, say what changed in Linear and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.6.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",
|
package/src/vendor/linear/api.js
CHANGED
|
@@ -246,6 +246,36 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
|
|
|
246
246
|
const data = await call(`query($id: String!) { team(id: $id) { id key name } }`, { id: teamId })
|
|
247
247
|
return (data && data.team) || null
|
|
248
248
|
},
|
|
249
|
+
// The WORKSPACE this key belongs to. The one fact that says whether the API
|
|
250
|
+
// transport and the MCP transport are pointed at the same Linear at all —
|
|
251
|
+
// every other id could match by coincidence across two workspaces, but an
|
|
252
|
+
// organization id is the workspace.
|
|
253
|
+
//
|
|
254
|
+
// API-only, like `readTeam`.
|
|
255
|
+
async readOrganization() {
|
|
256
|
+
const data = await call('query { organization { id name urlKey } }')
|
|
257
|
+
return (data && data.organization) || null
|
|
258
|
+
},
|
|
259
|
+
// One project by id, with the teams it belongs to — enough to answer both
|
|
260
|
+
// halves of the `project` doctor row: does this id resolve at all, and is it
|
|
261
|
+
// a project of the team this repo files into?
|
|
262
|
+
//
|
|
263
|
+
// `teams`, not `team`: a Linear project can span several teams, so belonging
|
|
264
|
+
// is a membership test. Treating it as a single field would report a healthy
|
|
265
|
+
// shared project as foreign.
|
|
266
|
+
//
|
|
267
|
+
// API-only, like `readTeam` — the adapter may add ops, it may only never be
|
|
268
|
+
// missing one (see the operation-contract test).
|
|
269
|
+
async readProject(projectId) {
|
|
270
|
+
const data = await call(
|
|
271
|
+
`query($id: String!) { project(id: $id) { id name teams { nodes { id key } } } }`,
|
|
272
|
+
{ id: projectId },
|
|
273
|
+
)
|
|
274
|
+
const project = (data && data.project) || null
|
|
275
|
+
if (!project) return null
|
|
276
|
+
const teams = (project.teams && project.teams.nodes) || []
|
|
277
|
+
return { id: project.id, name: project.name, teams }
|
|
278
|
+
},
|
|
249
279
|
// The workspace's issue workflow states, in the shape `--workspace-states`
|
|
250
280
|
// already accepts, so the existing state check is reused rather than forked.
|
|
251
281
|
async listIssueStates(teamId) {
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
const fs = require('node:fs')
|
|
24
24
|
const path = require('node:path')
|
|
25
25
|
|
|
26
|
-
const { BUCKETS, findSpecFolder } = require('../../env/resolve.js')
|
|
26
|
+
const { BUCKETS, findSpecFolder, branchFor, splitPrefix, currentBranch } = require('../../env/resolve.js')
|
|
27
|
+
const { loadEnvConfig } = require('../../env/config.js')
|
|
28
|
+
const { ticketsInRange } = require('./released.js')
|
|
29
|
+
const { execFileSync } = require('node:child_process')
|
|
27
30
|
const {
|
|
28
31
|
normalizeLocal,
|
|
29
32
|
readSnapshot,
|
|
@@ -604,6 +607,14 @@ function verifyLines(snapshotDir, config, stored, identifier) {
|
|
|
604
607
|
*/
|
|
605
608
|
async function specSyncDoctor(dir, flags, out) {
|
|
606
609
|
const state = gatherState(dir, flags)
|
|
610
|
+
if (flags.mcp) {
|
|
611
|
+
const read = readMcpFacts(flags.mcp)
|
|
612
|
+
if (read.error) {
|
|
613
|
+
out.write(`spec-sync doctor: cannot read --mcp ${flags.mcp}: ${read.error}\n`)
|
|
614
|
+
return 1
|
|
615
|
+
}
|
|
616
|
+
state.mcp = read.facts
|
|
617
|
+
}
|
|
607
618
|
if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
|
|
608
619
|
|
|
609
620
|
const report = runChecks(state)
|
|
@@ -675,7 +686,74 @@ async function checkRemote(state, flags) {
|
|
|
675
686
|
fix: '/spec-linear-setup',
|
|
676
687
|
}
|
|
677
688
|
}
|
|
678
|
-
|
|
689
|
+
// The team resolved, so the key works. Only now is it worth spending further
|
|
690
|
+
// calls — on the workspace this key belongs to (so the MCP row has an API side
|
|
691
|
+
// to compare against, and only when there is an MCP side to compare with), and
|
|
692
|
+
// on the project, when one is configured.
|
|
693
|
+
let organization = null
|
|
694
|
+
if (state.mcp && state.mcp.workspace && typeof adapter.readOrganization === 'function') {
|
|
695
|
+
try {
|
|
696
|
+
organization = await adapter.readOrganization()
|
|
697
|
+
} catch {
|
|
698
|
+
// Unexamined, exactly like the project below: the team read already proved
|
|
699
|
+
// the key works, so a failure here is not evidence about the config.
|
|
700
|
+
organization = null
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
let project = null
|
|
706
|
+
// `readProject` is API-only. An adapter without it cannot answer the question,
|
|
707
|
+
// and a `TypeError` caught below would be dressed up as Linear refusing the
|
|
708
|
+
// request — accusing the user's config of a gap that is ours. Unexamined.
|
|
709
|
+
if (state.project && state.project.configured && typeof adapter.readProject === 'function') {
|
|
710
|
+
try {
|
|
711
|
+
const found = await adapter.readProject(state.project.configured)
|
|
712
|
+
project = found
|
|
713
|
+
? {
|
|
714
|
+
resolved: true,
|
|
715
|
+
name: found.name,
|
|
716
|
+
// Membership, not equality: a Linear project can span teams.
|
|
717
|
+
belongsToTeam: (found.teams || []).some((t) => t && t.id === state.tracker.teamId),
|
|
718
|
+
}
|
|
719
|
+
: { resolved: false }
|
|
720
|
+
} catch (error) {
|
|
721
|
+
// Same rule as the team read: NEVER ANSWERED is not ANSWERED NO. A
|
|
722
|
+
// transport failure leaves the project unexamined rather than accused.
|
|
723
|
+
const failure = classifyRemoteFailure(error)
|
|
724
|
+
project = failure.reached === false ? null : { resolved: false, reason: failure.reason }
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The MCP server's view of where this repo files, as the calling skill read it.
|
|
733
|
+
*
|
|
734
|
+
* The engine never speaks MCP — the same split `--workspace-states` and
|
|
735
|
+
* `verify --stored` use — so a skill fetches `get_workspace` / `get_team` /
|
|
736
|
+
* `get_project` and writes them here:
|
|
737
|
+
*
|
|
738
|
+
* { "workspace": {"id","name"}, "team": {"id","key"}, "project": {"id","name"} }
|
|
739
|
+
*
|
|
740
|
+
* Every field is optional. A field the skill could not fetch is ABSENT, and
|
|
741
|
+
* absent means unchecked — never mismatched. That distinction is the whole
|
|
742
|
+
* safety of the row: it is a comparison, so it can only speak about the pairs it
|
|
743
|
+
* actually has both halves of.
|
|
744
|
+
*/
|
|
745
|
+
function readMcpFacts(file) {
|
|
746
|
+
let parsed
|
|
747
|
+
try {
|
|
748
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
749
|
+
} catch (error) {
|
|
750
|
+
return { error: error.message }
|
|
751
|
+
}
|
|
752
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
753
|
+
return { error: 'expected an object with workspace / team / project keys' }
|
|
754
|
+
}
|
|
755
|
+
const pick = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
756
|
+
return { facts: { workspace: pick(parsed.workspace), team: pick(parsed.team), project: pick(parsed.project) } }
|
|
679
757
|
}
|
|
680
758
|
|
|
681
759
|
// Map a thrown API error onto our own short reason. Matched on the shapes
|
|
@@ -708,7 +786,7 @@ function classifyRemoteFailure(error) {
|
|
|
708
786
|
// Read the project's real state for `runChecks`. Never throws: every probe that
|
|
709
787
|
// can fail reports the failure as data.
|
|
710
788
|
function gatherState(dir, flags) {
|
|
711
|
-
const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, remote: { checked: false } }
|
|
789
|
+
const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, project: {}, remote: { checked: false } }
|
|
712
790
|
|
|
713
791
|
const specs = path.join(dir, 'specs')
|
|
714
792
|
state.scaffold.specsDir = fs.existsSync(specs)
|
|
@@ -739,6 +817,10 @@ function gatherState(dir, flags) {
|
|
|
739
817
|
state.tracker.parsed = true
|
|
740
818
|
state.tracker.teamId = (config.linear && config.linear.teamId) || ''
|
|
741
819
|
state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
|
|
820
|
+
// Offline this is all that can be known: whether a string is there. That a
|
|
821
|
+
// well-formed id names a LIVE project is only answerable with
|
|
822
|
+
// --check-remote, which is why the row says what it checked.
|
|
823
|
+
state.project.configured = (config.linear && config.linear.projectId) || ''
|
|
742
824
|
} catch (error) {
|
|
743
825
|
state.tracker.parsed = false
|
|
744
826
|
state.tracker.error = error.message
|
|
@@ -781,6 +863,176 @@ function countSkills(dir) {
|
|
|
781
863
|
}
|
|
782
864
|
}
|
|
783
865
|
|
|
866
|
+
/**
|
|
867
|
+
* `spec-sync released [<range>] [--json]` — the tickets a release contains.
|
|
868
|
+
*
|
|
869
|
+
* Read-only, and deliberately so: it reports what shipped, it does not move
|
|
870
|
+
* anything. A release can be cut and never deployed, and workflow state is
|
|
871
|
+
* pushed from a spec's lifecycle bucket — "released" is not one, so moving
|
|
872
|
+
* tickets here would be a new kind of write with no dry run.
|
|
873
|
+
*
|
|
874
|
+
* The default range is the most recent tag reachable from HEAD, resolved with
|
|
875
|
+
* `git describe` rather than by knowing a tag scheme. This repo tags
|
|
876
|
+
* `name@version`, but a consumer may tag `v1.2.3` — and the scheme lives in
|
|
877
|
+
* `scripts/`, which is not shipped. The chosen range is always printed, so a
|
|
878
|
+
* wrong guess is visible rather than silent.
|
|
879
|
+
*/
|
|
880
|
+
async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
881
|
+
const git = (argv) => {
|
|
882
|
+
try {
|
|
883
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()
|
|
884
|
+
} catch {
|
|
885
|
+
return null
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let range = rangeArg
|
|
890
|
+
if (!range) {
|
|
891
|
+
const tag = (git(['describe', '--tags', '--abbrev=0']) || '').trim()
|
|
892
|
+
if (!tag) {
|
|
893
|
+
out.write(
|
|
894
|
+
'spec-sync released: no range given and no tag to default from.\n' +
|
|
895
|
+
' Pass one explicitly, e.g. spec-sync released v1.2.0..HEAD\n',
|
|
896
|
+
)
|
|
897
|
+
return 1
|
|
898
|
+
}
|
|
899
|
+
range = `${tag}..HEAD`
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// NUL-delimited so a subject or body containing the separator cannot split a
|
|
903
|
+
// record; RS between commits for the same reason.
|
|
904
|
+
const raw = git(['log', '--format=%H%x00%s%x00%b%x1e', range])
|
|
905
|
+
if (raw === null) {
|
|
906
|
+
out.write(`spec-sync released: git could not resolve the range "${range}".\n`)
|
|
907
|
+
return 1
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const commits = raw
|
|
911
|
+
.split('\x1e')
|
|
912
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
913
|
+
.filter((r) => r.trim())
|
|
914
|
+
.map((record) => {
|
|
915
|
+
const [sha, subject, ...rest] = record.split('\x00')
|
|
916
|
+
return { sha, subject, body: rest.join('\x00') }
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const report = ticketsInRange(commits)
|
|
920
|
+
|
|
921
|
+
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
922
|
+
// transport, or a read failure degrades to bare refs — never a failure.
|
|
923
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
924
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
925
|
+
let titles = null
|
|
926
|
+
let titleNote = ''
|
|
927
|
+
if (report.tickets.length && transport === 'api') {
|
|
928
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
929
|
+
titles = {}
|
|
930
|
+
for (const t of report.tickets) {
|
|
931
|
+
try {
|
|
932
|
+
const issue = await adapter.readIssue(t.ref)
|
|
933
|
+
if (issue && issue.title) titles[t.ref] = issue.title
|
|
934
|
+
} catch {
|
|
935
|
+
titleNote = ' (titles unavailable — Linear could not be read)'
|
|
936
|
+
titles = null
|
|
937
|
+
break
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
} else if (report.tickets.length) {
|
|
941
|
+
titleNote = ` (titles unavailable — transport = ${transport})`
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (flags.json) {
|
|
945
|
+
out.write(JSON.stringify({ range, ...report, titles }, null, 2) + '\n')
|
|
946
|
+
return 0
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const lines = [`spec-sync released: ${range}`, '']
|
|
950
|
+
if (!report.tickets.length) lines.push(' no ticket references found')
|
|
951
|
+
for (const t of report.tickets) {
|
|
952
|
+
const title = titles && titles[t.ref] ? ` ${titles[t.ref]}` : ''
|
|
953
|
+
lines.push(` ${t.ref}${title}`)
|
|
954
|
+
}
|
|
955
|
+
if (titleNote) lines.push(titleNote)
|
|
956
|
+
lines.push('')
|
|
957
|
+
lines.push(` ${report.tickets.length} ticket(s) in ${report.total} commit(s)`)
|
|
958
|
+
// Said even when zero: a chore commit legitimately carries no ticket and a
|
|
959
|
+
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
960
|
+
// accounted for".
|
|
961
|
+
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
962
|
+
out.write(lines.join('\n') + '\n')
|
|
963
|
+
return 0
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* `spec-sync ref [--json]` — the ticket this branch's work belongs to.
|
|
968
|
+
*
|
|
969
|
+
* Exists so neither a person nor a model has to go spelunking for the id when
|
|
970
|
+
* writing a commit: `Refs: $(spec-sync ref)`. With a fast-forward-only history
|
|
971
|
+
* the commit message is the ONLY place a ticket survives into the range a
|
|
972
|
+
* release scans — branch names never reach it.
|
|
973
|
+
*
|
|
974
|
+
* The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
|
|
975
|
+
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
976
|
+
* than by re-deriving the pattern here. A second implementation of the naming
|
|
977
|
+
* rule would drift from the one that created the branch.
|
|
978
|
+
*
|
|
979
|
+
* **Every no-ref case prints nothing on stdout** and exits non-zero. A commit on
|
|
980
|
+
* `main`, or on a spec kept deliberately local, has no ticket — and a command
|
|
981
|
+
* that wrote an error message to stdout would see a shell splice it straight
|
|
982
|
+
* into the commit body via `$(…)`.
|
|
983
|
+
*/
|
|
984
|
+
function specSyncRef(dir, config, flags, out, err) {
|
|
985
|
+
const say = (msg) => err.write(`spec-sync ref: ${msg}\n`)
|
|
986
|
+
|
|
987
|
+
const git = (argv) => {
|
|
988
|
+
try {
|
|
989
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
990
|
+
} catch {
|
|
991
|
+
return null
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const branch = currentBranch(git)
|
|
995
|
+
if (!branch) {
|
|
996
|
+
say('not on a git branch (detached HEAD, or not a git repository)')
|
|
997
|
+
return 1
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const env = loadEnvConfig(dir).config
|
|
1001
|
+
let match = null
|
|
1002
|
+
for (const bucket of BUCKETS) {
|
|
1003
|
+
let entries
|
|
1004
|
+
try {
|
|
1005
|
+
entries = fs.readdirSync(path.join(dir, 'specs', bucket), { withFileTypes: true })
|
|
1006
|
+
} catch {
|
|
1007
|
+
continue
|
|
1008
|
+
}
|
|
1009
|
+
for (const entry of entries) {
|
|
1010
|
+
if (!entry.isDirectory()) continue
|
|
1011
|
+
const specPath = path.join(dir, 'specs', bucket, entry.name)
|
|
1012
|
+
const { type, slug } = splitPrefix(entry.name)
|
|
1013
|
+
if (branchFor({ type, slug, path: specPath }, env) === branch) {
|
|
1014
|
+
match = { folder: entry.name, bucket, path: specPath }
|
|
1015
|
+
break
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (match) break
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (!match) {
|
|
1022
|
+
say(`branch "${branch}" is not a spec branch — no ticket to reference`)
|
|
1023
|
+
return 1
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const identifier = linkedIdentifier(path.join(match.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
|
|
1027
|
+
if (!identifier) {
|
|
1028
|
+
say(`${match.folder} is not linked to Linear — /spec-push to mirror it`)
|
|
1029
|
+
return 1
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
out.write(flags.json ? JSON.stringify({ ref: identifier, spec: match.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
|
|
1033
|
+
return 0
|
|
1034
|
+
}
|
|
1035
|
+
|
|
784
1036
|
/**
|
|
785
1037
|
* `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
|
|
786
1038
|
*
|
|
@@ -1706,7 +1958,19 @@ async function credentialsSet(file, teamId, label, flags, out) {
|
|
|
1706
1958
|
|
|
1707
1959
|
let key
|
|
1708
1960
|
if (flags.stdin) {
|
|
1709
|
-
|
|
1961
|
+
const piped = flags.input || process.stdin
|
|
1962
|
+
// A TTY on stdin is positive evidence that nothing was piped: `--stdin` then
|
|
1963
|
+
// waits for an EOF a terminal never sends, printing nothing while it does.
|
|
1964
|
+
// Refuse and name the two working forms rather than blocking forever.
|
|
1965
|
+
if (piped.isTTY) {
|
|
1966
|
+
out.write(
|
|
1967
|
+
'spec-sync credentials: --stdin expects a pipe, but stdin is a terminal.\n' +
|
|
1968
|
+
' Run it without --stdin to be prompted (input is hidden), or pipe the key:\n' +
|
|
1969
|
+
' <command that prints the key> | skitterspec spec-sync credentials set --stdin\n',
|
|
1970
|
+
)
|
|
1971
|
+
return 1
|
|
1972
|
+
}
|
|
1973
|
+
key = (await readAllStdin(piped)).trim()
|
|
1710
1974
|
if (!key) {
|
|
1711
1975
|
out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
|
|
1712
1976
|
return 1
|
|
@@ -1778,19 +2042,33 @@ function readAllStdin(input) {
|
|
|
1778
2042
|
}
|
|
1779
2043
|
|
|
1780
2044
|
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
1781
|
-
// hook —
|
|
2045
|
+
// hook — filtering it is what keeps the key off the screen (and out of a
|
|
1782
2046
|
// screen-shared terminal or a recorded session).
|
|
2047
|
+
//
|
|
2048
|
+
// READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
|
|
2049
|
+
// the interface loses it: readline clears from the cursor to the end of the
|
|
2050
|
+
// screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
|
|
2051
|
+
// it appeared and the user was left staring at a blank line while the process
|
|
2052
|
+
// waited for a key — indistinguishable from a hang.
|
|
2053
|
+
//
|
|
2054
|
+
// The hook is assigned BEFORE `question`, so the very first redraw goes through
|
|
2055
|
+
// it. readline hands it `prompt + what has been typed`; re-writing only the
|
|
2056
|
+
// prompt is what keeps the key hidden while the prompt survives every redraw.
|
|
1783
2057
|
function promptHidden(question, input, out) {
|
|
1784
2058
|
const readline = require('node:readline')
|
|
1785
2059
|
return new Promise((resolve) => {
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2060
|
+
// `out`, never `process.stdout`: they are the same stream in production, and
|
|
2061
|
+
// hardcoding one half meant readline cleared a screen the prompt had not
|
|
2062
|
+
// been written to under test — the split that hid this bug from the suite.
|
|
2063
|
+
const rl = readline.createInterface({ input, output: out, terminal: true })
|
|
2064
|
+
rl._writeToOutput = (s) => {
|
|
2065
|
+
if (s.includes(question)) out.write(question)
|
|
2066
|
+
}
|
|
2067
|
+
rl.question(question, (answer) => {
|
|
1789
2068
|
out.write('\n')
|
|
1790
2069
|
rl.close()
|
|
1791
2070
|
resolve(answer)
|
|
1792
2071
|
})
|
|
1793
|
-
rl._writeToOutput = () => {}
|
|
1794
2072
|
})
|
|
1795
2073
|
}
|
|
1796
2074
|
|
|
@@ -1805,12 +2083,13 @@ async function specSync(rest, io = {}) {
|
|
|
1805
2083
|
// after the loop.
|
|
1806
2084
|
const unknownFlags = []
|
|
1807
2085
|
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
|
|
1808
|
-
force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
2086
|
+
mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
1809
2087
|
for (let i = 0; i < args.length; i++) {
|
|
1810
2088
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1811
2089
|
else if (args[i] === '--json') flags.json = true
|
|
1812
2090
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
1813
2091
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
2092
|
+
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
1814
2093
|
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
1815
2094
|
else if (args[i] === '--via') flags.via = args[++i]
|
|
1816
2095
|
else if (args[i] === '--all') flags.all = args[++i]
|
|
@@ -1880,6 +2159,10 @@ async function specSync(rest, io = {}) {
|
|
|
1880
2159
|
flags.env = io.env || process.env
|
|
1881
2160
|
if (io.adapter) flags.adapter = io.adapter
|
|
1882
2161
|
if (io.fetch) flags.fetch = io.fetch
|
|
2162
|
+
// The stdin seam. `credentials set` branches on whether stdin is a TTY, and
|
|
2163
|
+
// with no way to inject one the suite could only ever exercise the non-TTY
|
|
2164
|
+
// half — which is how a prompt that erased itself reached a release.
|
|
2165
|
+
if (io.input) flags.input = io.input
|
|
1883
2166
|
|
|
1884
2167
|
// Dispatched ahead of the load on purpose: this is the command you run when
|
|
1885
2168
|
// there is no config, and `--force` must be able to replace one that is
|
|
@@ -1915,6 +2198,10 @@ async function specSync(rest, io = {}) {
|
|
|
1915
2198
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
1916
2199
|
case 'states':
|
|
1917
2200
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
2201
|
+
case 'released':
|
|
2202
|
+
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2203
|
+
case 'ref':
|
|
2204
|
+
return specSyncRef(dir, config, flags, out, err) || 0
|
|
1918
2205
|
case 'retarget':
|
|
1919
2206
|
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
1920
2207
|
case 'apply':
|
|
@@ -1937,8 +2224,10 @@ async function specSync(rest, io = {}) {
|
|
|
1937
2224
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
1938
2225
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
1939
2226
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2227
|
+
' skitterspec spec-sync ref [--json]\n' +
|
|
2228
|
+
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
1940
2229
|
' skitterspec spec-sync retarget [--yes]\n' +
|
|
1941
|
-
' skitterspec spec-sync doctor [--check-remote] [--json]\n' +
|
|
2230
|
+
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
1942
2231
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
1943
2232
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
1944
2233
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
@@ -1946,4 +2235,4 @@ async function specSync(rest, io = {}) {
|
|
|
1946
2235
|
}
|
|
1947
2236
|
}
|
|
1948
2237
|
|
|
1949
|
-
module.exports = { specSync, listSpecs }
|
|
2238
|
+
module.exports = { specSync, listSpecs, promptHidden }
|
|
@@ -54,8 +54,10 @@ function runChecks(state = {}) {
|
|
|
54
54
|
scaffoldCheck(state.scaffold),
|
|
55
55
|
isolationCheck(state.isolation),
|
|
56
56
|
trackerCheck(state.tracker),
|
|
57
|
+
projectCheck(state.project, state.tracker, state.remote),
|
|
57
58
|
keyCheck(state.key, state.tracker),
|
|
58
59
|
remoteCheck(state.remote),
|
|
60
|
+
mcpCheck(state.mcp, state.tracker, state.project, state.remote),
|
|
59
61
|
]
|
|
60
62
|
// `missing` is a declined opt-in, so it must not fail the run. Only a
|
|
61
63
|
// configured-but-wrong layer does.
|
|
@@ -123,6 +125,53 @@ function trackerCheck(s = {}) {
|
|
|
123
125
|
// caller resolves all three before this sees it. `s.error` carries WHY when one
|
|
124
126
|
// of them failed; passing it through is what keeps a broken keyCommand from
|
|
125
127
|
// being reported as a key the user never set.
|
|
128
|
+
// Where specs get filed. `projectId` is the picker's DEFAULT, not a mandate
|
|
129
|
+
// (`config.js`), so an unset one is a declined opt-in and NEVER fails the run —
|
|
130
|
+
// filing to the team and choosing a project each push is a supported way to work.
|
|
131
|
+
//
|
|
132
|
+
// BLIND SPOT: offline this can only see that a string is present. A well-formed
|
|
133
|
+
// id naming a deleted project, or one belonging to another team, reads `ok`
|
|
134
|
+
// until `--check-remote` resolves it — so the detail says which of the two was
|
|
135
|
+
// actually established rather than implying the stronger one.
|
|
136
|
+
function projectCheck(s = {}, tracker = {}, remote = {}) {
|
|
137
|
+
if (!tracker.present) return row('project', 'project', 'skipped', 'no tracker configured')
|
|
138
|
+
if (!s.configured) {
|
|
139
|
+
return row(
|
|
140
|
+
'project',
|
|
141
|
+
'project',
|
|
142
|
+
'missing',
|
|
143
|
+
'no linear.projectId — specs file to the team, and the picker asks each push',
|
|
144
|
+
'/spec-linear-setup',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const found = remote && remote.project
|
|
149
|
+
// Configured but unexamined — either --check-remote was not passed, or it was
|
|
150
|
+
// and Linear never answered. Both are "we did not look", not "it is wrong".
|
|
151
|
+
if (!found) {
|
|
152
|
+
return row('project', 'project', 'ok', `${s.configured} — configured, not checked against Linear`)
|
|
153
|
+
}
|
|
154
|
+
if (!found.resolved) {
|
|
155
|
+
return row(
|
|
156
|
+
'project',
|
|
157
|
+
'project',
|
|
158
|
+
'broken',
|
|
159
|
+
found.reason || `linear.projectId ${s.configured} does not resolve in this workspace`,
|
|
160
|
+
'/spec-linear-setup',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (!found.belongsToTeam) {
|
|
164
|
+
return row(
|
|
165
|
+
'project',
|
|
166
|
+
'project',
|
|
167
|
+
'broken',
|
|
168
|
+
`"${found.name}" is not a project of team ${tracker.teamKey || tracker.teamId} — specs would file out of the team`,
|
|
169
|
+
'/spec-linear-setup',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return row('project', 'project', 'ok', `"${found.name}" (${s.configured}) in team ${tracker.teamKey || tracker.teamId}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
126
175
|
function keyCheck(s = {}, tracker = {}) {
|
|
127
176
|
// Without a tracker there is nothing for a key to authenticate, so asking for
|
|
128
177
|
// one would be noise.
|
|
@@ -176,4 +225,65 @@ function remoteCheck(s = {}) {
|
|
|
176
225
|
return row('remote', 'remote', 'ok', `team ${s.teamKey} resolves, key accepted`)
|
|
177
226
|
}
|
|
178
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Do the two transports point at the same place?
|
|
230
|
+
*
|
|
231
|
+
* A repo reaches Linear over the API or over MCP, chosen per invocation, and
|
|
232
|
+
* they are configured independently: the API key belongs to whatever workspace
|
|
233
|
+
* issued it, the MCP server to whatever workspace it was connected to. Nothing
|
|
234
|
+
* made them agree, so the destination could depend on which transport ran.
|
|
235
|
+
*
|
|
236
|
+
* `s` is what a skill read over MCP (see `readMcpFacts`). Three sources are
|
|
237
|
+
* compared — the repo's config, the API key's workspace, and the MCP server's —
|
|
238
|
+
* and a disagreement is `broken` because writes would land in the wrong place.
|
|
239
|
+
*
|
|
240
|
+
* IDS, NEVER NAMES: a renamed workspace, team or project keeps its id, and
|
|
241
|
+
* `retarget` exists precisely because a team KEY is not identity.
|
|
242
|
+
*
|
|
243
|
+
* BLIND SPOT: the file is a snapshot the skill took, so `ok` means the sources
|
|
244
|
+
* agreed WHEN IT WAS FETCHED. And a field the skill could not fetch is absent —
|
|
245
|
+
* absence is unchecked, so it never produces `broken`. The row can only speak
|
|
246
|
+
* about pairs it holds both halves of, which is why it names them.
|
|
247
|
+
*/
|
|
248
|
+
function mcpCheck(s, tracker = {}, project = {}, remote = {}) {
|
|
249
|
+
if (!s) {
|
|
250
|
+
return row('mcp', 'mcp', 'skipped', 'pass --mcp <file> to check the MCP server points at the same place')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const apiOrg = remote && remote.organization
|
|
254
|
+
const pairs = [
|
|
255
|
+
['workspace', s.workspace && s.workspace.id, apiOrg && apiOrg.id, s.workspace && s.workspace.name, apiOrg && apiOrg.name, "the API key's workspace"],
|
|
256
|
+
['team', s.team && s.team.id, tracker.teamId, s.team && s.team.key, tracker.teamKey, 'the config'],
|
|
257
|
+
['project', s.project && s.project.id, project && project.configured, s.project && s.project.name, null, 'the config'],
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
const checked = []
|
|
261
|
+
for (const [what, mcpId, otherId, mcpName, otherName, whose] of pairs) {
|
|
262
|
+
// Both halves, or nothing to compare. An absent id is a question nobody
|
|
263
|
+
// asked, not an answer of "no".
|
|
264
|
+
if (!mcpId || !otherId) continue
|
|
265
|
+
checked.push(what)
|
|
266
|
+
if (mcpId !== otherId) {
|
|
267
|
+
return row(
|
|
268
|
+
'mcp',
|
|
269
|
+
'mcp',
|
|
270
|
+
'broken',
|
|
271
|
+
`${what} mismatch — the MCP server says ${describe(mcpName, mcpId)}, ${whose} says ` +
|
|
272
|
+
`${describe(otherName, otherId)}; writes land wherever the transport does`,
|
|
273
|
+
'/spec-linear-setup',
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!checked.length) {
|
|
279
|
+
return row('mcp', 'mcp', 'skipped', 'the --mcp file names nothing that can be compared yet')
|
|
280
|
+
}
|
|
281
|
+
const where = (s.workspace && s.workspace.name) || (s.team && s.team.key) || 'the same place'
|
|
282
|
+
return row('mcp', 'mcp', 'ok', `${where} — ${checked.join(', ')} agree across both transports`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// `Name (id)` when a name is known, the bare id otherwise — the id is what was
|
|
286
|
+
// compared, so it is always shown.
|
|
287
|
+
const describe = (name, id) => (name ? `"${name}" (${id})` : String(id))
|
|
288
|
+
|
|
179
289
|
module.exports = { runChecks, STATES }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which tickets a release contains.
|
|
5
|
+
*
|
|
6
|
+
* The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
7
|
+
* names never reach it. A commit's `Refs:` trailer is therefore the only place
|
|
8
|
+
* the ticket survives into the range a release scans — see the
|
|
9
|
+
* `commit-trailers` rule.
|
|
10
|
+
*
|
|
11
|
+
* The parsing half is pure and takes commits as data, so the rules below are
|
|
12
|
+
* testable without git. Reading the range, and enriching refs with issue titles,
|
|
13
|
+
* belong to the caller.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// An issue identifier: uppercase team key, dash, number.
|
|
17
|
+
const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
|
|
18
|
+
// A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
|
|
19
|
+
// that happens to mention the word.
|
|
20
|
+
const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The refs a single commit body claims.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately blind to three things that LOOK like trailers and are not:
|
|
26
|
+
*
|
|
27
|
+
* - a line inside a ``` fence — a commit explaining the convention quotes it;
|
|
28
|
+
* - a quoted line (`> Refs: …`) — same reason, in review replies;
|
|
29
|
+
* - an indented line (4+ spaces) — markdown code, e.g. a sample message.
|
|
30
|
+
*
|
|
31
|
+
* Counting any of those would make a release claim work it does not contain,
|
|
32
|
+
* which is worse than missing a ticket: the missing one is noticed when someone
|
|
33
|
+
* looks for it, the invented one never is.
|
|
34
|
+
*/
|
|
35
|
+
function refsInBody(body) {
|
|
36
|
+
const found = []
|
|
37
|
+
let fenced = false
|
|
38
|
+
for (const raw of String(body || '').split('\n')) {
|
|
39
|
+
const line = raw.replace(/\r$/, '')
|
|
40
|
+
if (/^\s*```/.test(line)) {
|
|
41
|
+
fenced = !fenced
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (fenced) continue
|
|
45
|
+
if (/^\s*>/.test(line)) continue
|
|
46
|
+
if (/^ {4,}|\t/.test(line)) continue
|
|
47
|
+
const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
|
|
48
|
+
if (!m) continue
|
|
49
|
+
for (const id of m[1].match(IDENTIFIER_RE) || []) found.push(id)
|
|
50
|
+
}
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fold commits into the report a release needs.
|
|
56
|
+
*
|
|
57
|
+
* @param {Array<{sha?:string, subject?:string, body?:string}>} commits
|
|
58
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, total:number}}
|
|
59
|
+
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
60
|
+
* is listed once, where it first appears, not eight times.
|
|
61
|
+
*/
|
|
62
|
+
function ticketsInRange(commits) {
|
|
63
|
+
const seen = new Map()
|
|
64
|
+
let unreferenced = 0
|
|
65
|
+
for (const commit of commits || []) {
|
|
66
|
+
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
67
|
+
if (!refs.length) {
|
|
68
|
+
unreferenced++
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
75
|
+
unreferenced,
|
|
76
|
+
total: (commits || []).length,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { ticketsInRange, refsInBody }
|