@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.
@@ -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
- ## 3b. Sync with the tracker (only if a provider is installed)
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
- **Only when a ticketing provider is installed** and the spec is linked to the
145
- tracker. Otherwise skip this step no provider means zero change to the flow
146
- below. Follow the provider's steps below (nothing to do here without one).
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` to send the now in-progress spec up.
156
- Whether that is optional depends on `mapping.phases`:
157
- - `"subissue"` (the default) **optional**. The phase sub-issues already
158
- exist; this push only moves their states. Refresh now or later.
159
- - `"deferred"` **do it now, without asking.** Under deferral a spec sitting
160
- in the backlog is mirrored as the issue alone, and this push is what mints
161
- its phase sub-issues. Skip it and a started spec stays mirrored as a
162
- phase-less issue until someone happens to run `/spec-push` by hand. Run it
163
- straight after the step-2 commit, so the sub-issues land with the same
164
- branch push that fires the tracker's automation.
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.** Run:
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. `retarget` after a Linear team is renamed
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
- ## 8. Report
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.5.0",
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",
@@ -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
- return { checked: true, ok: true, teamKey: team.key, recordedKey: state.tracker.teamKey }
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
- key = (await readAllStdin(flags.input || process.stdin)).trim()
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 — silencing it is what keeps the key off the screen (and out of a
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
- const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
1787
- out.write(question)
1788
- rl.question('', (answer) => {
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 }