@skitterbyte/skitterspec-linear 10.7.0 → 11.0.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.
Files changed (37) hide show
  1. package/MIGRATION.md +36 -0
  2. package/README.md +3 -3
  3. package/assets/claude-md-section.md +13 -51
  4. package/assets/commands/spec-live.md +2 -2
  5. package/assets/core/SETUP.md +1 -1
  6. package/assets/core/env.config.json.example +4 -1
  7. package/assets/core/env.config.md +26 -6
  8. package/assets/core/linear.config.json.example +2 -1
  9. package/assets/core/linear.config.md +46 -4
  10. package/assets/rules/spec-planning.md +30 -10
  11. package/assets/skills/spec/SKILL.md +15 -10
  12. package/assets/skills/spec-bug/SKILL.md +43 -9
  13. package/assets/skills/spec-cancel/SKILL.md +12 -0
  14. package/assets/skills/spec-complete/SKILL.md +26 -5
  15. package/assets/skills/spec-hotfix/SKILL.md +42 -8
  16. package/assets/skills/spec-init/SKILL.md +16 -6
  17. package/assets/skills/spec-linear-setup/SKILL.md +1 -1
  18. package/assets/skills/spec-next/SKILL.md +141 -0
  19. package/assets/skills/spec-push/SKILL.md +2 -2
  20. package/assets/skills/spec-review/SKILL.md +5 -5
  21. package/assets/skills/spec-start/SKILL.md +146 -0
  22. package/assets/skills/spec-status/SKILL.md +2 -2
  23. package/assets/skills/spec-sync/SKILL.md +9 -1
  24. package/assets/skills/spec-to-main/SKILL.md +7 -5
  25. package/package.json +1 -1
  26. package/src/cli.js +234 -20
  27. package/src/env/config.js +24 -1
  28. package/src/env/integrate.js +61 -1
  29. package/src/env/live.js +27 -3
  30. package/src/env/provision.js +58 -1
  31. package/src/env/teardown.js +41 -3
  32. package/src/init.js +21 -12
  33. package/src/prompts.js +31 -3
  34. package/src/vendor/linear/cli-sync.js +55 -4
  35. package/src/vendor/linear/config.js +70 -3
  36. package/src/vendor/linear/released.js +65 -5
  37. package/assets/skills/spec-go/SKILL.md +0 -233
@@ -158,4 +158,61 @@ function planUp(spec, alloc, config) {
158
158
  }
159
159
  }
160
160
 
161
- module.exports = { planUp, seedCommandFor, worktreeCd }
161
+ /**
162
+ * Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
163
+ * checkout and there is no worktree at all.
164
+ *
165
+ * Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
166
+ *
167
+ * ctx: { current, base, onBase, clean, branchExists }
168
+ *
169
+ * What it refuses, and why each is a refusal rather than a warning:
170
+ * - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
171
+ * new branch. That is silent and it is the operator's work, so it is theirs
172
+ * to place, not ours.
173
+ * - standing on another branch, because switching away from it is a decision
174
+ * about someone else's unfinished spec. Being on THIS spec's branch is not a
175
+ * refusal — it is the re-run, and the answer is "already attached".
176
+ *
177
+ * There is no bootstrap and no opener: the primary checkout already has its
178
+ * dependencies, and no new session is being opened.
179
+ */
180
+ function planCheckoutUp(spec, ctx, config) {
181
+ const base = ctx.base || (config && config.baseBranch) || 'main'
182
+ const result = {
183
+ mode: 'checkout',
184
+ blocked: false,
185
+ reason: null,
186
+ attached: false,
187
+ branch: spec.branch,
188
+ checkoutPath: ctx.checkoutPath || null,
189
+ commands: [],
190
+ }
191
+ const block = (reason) => ({ ...result, blocked: true, reason })
192
+
193
+ // Order matters: report "already attached" before anything else, so a re-run
194
+ // on the spec's own branch is never refused for a dirty tree it legitimately
195
+ // has — you are mid-phase, with the phase's own edits in progress.
196
+ if (ctx.current && ctx.current === spec.branch) {
197
+ return { ...result, attached: true }
198
+ }
199
+ if (!ctx.clean) {
200
+ return block(
201
+ 'the primary checkout has uncommitted changes — commit or stash them first ' +
202
+ '(switching would carry them onto the new branch)',
203
+ )
204
+ }
205
+ if (!ctx.onBase) {
206
+ return block(
207
+ `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
208
+ 'finish or park that branch first; checkout mode holds one spec at a time',
209
+ )
210
+ }
211
+
212
+ result.commands.push(
213
+ ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
214
+ )
215
+ return result
216
+ }
217
+
218
+ module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
@@ -105,7 +105,7 @@ function planDown(spec, config, flags, ctx) {
105
105
  // different question from ours: it also declines a branch that is ahead of its
106
106
  // upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
107
107
  // even though it is merged to HEAD`. That fires on the ordinary spec flow —
108
- // `/spec-go` pushes the branch when it provisions, and the phase commits after
108
+ // `/spec-start` pushes the branch when it provisions, and the phase commits after
109
109
  // it are landed locally rather than pushed — so teardown meets a branch whose
110
110
  // every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
111
111
  // of base) already establishes what we actually care about, and establishes it
@@ -122,7 +122,7 @@ function planDown(spec, config, flags, ctx) {
122
122
 
123
123
  // --- delete the branch on the remote (planned, never run here) ---
124
124
  //
125
- // `/spec-go` pushes the branch at provision time, so a completed spec otherwise
125
+ // `/spec-start` pushes the branch at provision time, so a completed spec otherwise
126
126
  // leaves a merged branch on the remote forever. Cleaning that up is the goal;
127
127
  // doing it safely is the constraint.
128
128
  //
@@ -185,4 +185,42 @@ function blocked(reason) {
185
185
  }
186
186
  }
187
187
 
188
- module.exports = { planDown }
188
+ /**
189
+ * Pure teardown planner for CHECKOUT mode.
190
+ *
191
+ * There is no worktree to remove, no slot and no volumes — the only thing a
192
+ * finished spec leaves behind is its branch, and the checkout standing on it.
193
+ *
194
+ * Order is load-bearing: git refuses to delete the branch you are on, so the
195
+ * switch to base must come first. It is also what returns the checkout to a
196
+ * state the next `spec-env up` will accept.
197
+ *
198
+ * The same "are these commits recoverable?" question decides `-d` vs `-D`, and
199
+ * it is asked exactly as worktree-mode teardown asks it — a landed branch is
200
+ * safe to force-delete because its commits are on base (or under a tag); an
201
+ * unlanded one is not, and is refused rather than quietly dropped.
202
+ *
203
+ * ctx: { dirty, landed, onBranch, base, checkoutPath }
204
+ */
205
+ function planDownCheckout(spec, config, flags, ctx) {
206
+ const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
207
+ const force = Boolean(flags && flags.force)
208
+ const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
209
+ const block = (reason) => ({ ...result, blocked: true, reason })
210
+
211
+ if (!force) {
212
+ if (config.guards.refuseTeardownIfDirty && dirty) {
213
+ return block('the checkout has uncommitted changes')
214
+ }
215
+ if (!landed) {
216
+ return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
217
+ }
218
+ }
219
+
220
+ const commands = []
221
+ if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
222
+ commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
223
+ return { ...result, commands }
224
+ }
225
+
226
+ module.exports = { planDown, planDownCheckout }
package/src/init.js CHANGED
@@ -423,18 +423,24 @@ function installCore(dir, opts) {
423
423
  }
424
424
 
425
425
  // Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
426
- // example asset so /spec-go provisions a worktree for every in-progress spec.
426
+ // example asset so /spec-start provisions a worktree for every in-progress spec.
427
427
  // Only called when the operator opts in, and never on `update` (adopting isolation
428
428
  // is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
429
429
  // never clobbers an existing env.config.json without --force.
430
- function installIsolation(dir, { enabled }, opts) {
430
+ function installIsolation(dir, { enabled, workspaceMode }, opts) {
431
431
  if (!enabled) return
432
- copyAsset(
433
- dir,
434
- path.join('core', 'env.config.json.example'),
435
- path.join(dir, 'specs', '.core', 'env.config.json'),
436
- opts,
437
- )
432
+ const target = path.join(dir, 'specs', '.core', 'env.config.json')
433
+ copyAsset(dir, path.join('core', 'env.config.json.example'), target, opts)
434
+
435
+ // Only 'checkout' is written; 'worktree' is already what the template says and
436
+ // what the loader defaults to, so the common path leaves the file untouched.
437
+ // Guarded by existsSync because copyAsset legitimately declines to overwrite a
438
+ // config the operator already customized — rewriting it here would undo that.
439
+ if (workspaceMode === 'checkout' && fs.existsSync(target)) {
440
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'))
441
+ parsed.mode = 'checkout'
442
+ fs.writeFileSync(target, `${JSON.stringify(parsed, null, 2)}\n`)
443
+ }
438
444
  trustWorktreeRoot(dir)
439
445
  }
440
446
 
@@ -673,7 +679,7 @@ function printReport(dir, mode, { diff = false } = {}) {
673
679
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
674
680
  const isolationNote = isolationOn
675
681
  ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
676
- ' at /spec-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
682
+ ' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
677
683
  : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
678
684
  ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
679
685
  // A provider superset ships its own `spec-<provider>-setup` skill; the base
@@ -696,7 +702,7 @@ function printReport(dir, mode, { diff = false } = {}) {
696
702
  ' (it discovers your workspace and writes the config), or see' +
697
703
  ' specs/.core/SETUP.md.\n'
698
704
  process.stdout.write(
699
- '\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
705
+ '\nDone. Skills resolve as /spec, /spec-start, /spec-next, /spec-complete,' +
700
706
  ' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
701
707
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
702
708
  " project's stack, then run /spec.\n" +
@@ -705,7 +711,10 @@ function printReport(dir, mode, { diff = false } = {}) {
705
711
  )
706
712
  }
707
713
 
708
- async function init({ dir, force, claudeMd, mode, isolation }) {
714
+ // `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
715
+ // unrelated to the config's own `mode` key — which arrives as `workspaceMode`
716
+ // precisely so the two cannot be confused at a call site.
717
+ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
709
718
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
710
719
  resetReport()
711
720
 
@@ -716,7 +725,7 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
716
725
  removeRetiredFiles(dir)
717
726
  installCore(dir, { force })
718
727
  // Adopting isolation writes the live env.config.json — init only, never update.
719
- if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
728
+ if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
720
729
  if (claudeMd) installClaudeMd(dir, { mode })
721
730
 
722
731
  // Record what we wrote (and migrate a pre-manifest repo) so a later resync can
package/src/prompts.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * test suite never imports the interactive UI.
8
8
  *
9
9
  * `isolationSeed` pre-fills the per-spec isolation question. Returns
10
- * `{ isolation }`.
10
+ * `{ isolation, mode }` — `mode` is only asked when isolation is enabled, and
11
+ * is `'worktree'` otherwise (the value the config defaults to anyway).
11
12
  */
12
13
 
13
14
  async function promptSetup({ isolationSeed = false } = {}) {
@@ -23,15 +24,42 @@ async function promptSetup({ isolationSeed = false } = {}) {
23
24
  {
24
25
  type: 'confirm',
25
26
  name: 'isolation',
26
- message: 'Enable per-spec isolation — a git worktree per spec?',
27
+ message: 'Enable per-spec isolation — build each spec on its own branch?',
27
28
  initial: isolationSeed,
28
29
  },
30
+ {
31
+ // Only reachable when isolation was accepted: `prev` is the previous
32
+ // answer, and returning null skips the question entirely.
33
+ type: (prev) => (prev ? 'select' : null),
34
+ name: 'mode',
35
+ message: 'Where should a spec be built?',
36
+ hint: '- this is the trade, not a preference',
37
+ initial: 0,
38
+ choices: [
39
+ {
40
+ title: 'Its own worktree (default)',
41
+ value: 'worktree',
42
+ description: 'several specs at once, main left free — one terminal session per spec',
43
+ },
44
+ {
45
+ title: 'The checkout you are in',
46
+ value: 'checkout',
47
+ description: 'one spec at a time, no second session — your terminal follows the work',
48
+ },
49
+ ],
50
+ },
29
51
  ]
30
52
 
31
53
  const ans = await prompts(questions, { onCancel })
32
54
  if (cancelled) throw new Error('Setup cancelled')
33
55
 
34
- return { isolation: Boolean(ans.isolation) }
56
+ // Anything other than an explicit 'checkout' resolves to the default, so a
57
+ // skipped or cancelled-into-default answer can never select the mode that
58
+ // puts a spec's work in the primary checkout.
59
+ return {
60
+ isolation: Boolean(ans.isolation),
61
+ mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
62
+ }
35
63
  }
36
64
 
37
65
  /**
@@ -61,6 +61,7 @@ const {
61
61
  CONFIG_FILE,
62
62
  LIFECYCLE_BUCKETS,
63
63
  releaseStages,
64
+ releaseIgnorePaths,
64
65
  stageFor,
65
66
  } = require('./config.js')
66
67
  const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
@@ -251,7 +252,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
251
252
  function deferredLines(n) {
252
253
  return [
253
254
  ` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
254
- ' they are created on the push that follows /spec-go',
255
+ ' they are created on the push that follows /spec-start',
255
256
  ]
256
257
  }
257
258
 
@@ -942,6 +943,38 @@ function unreachableBase(git, range) {
942
943
  return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
943
944
  }
944
945
 
946
+ /**
947
+ * The paths each commit in the range changed, as sha → string[], or null when
948
+ * git could not be asked at all.
949
+ *
950
+ * A SECOND `git log` rather than `--name-only` on the first one: that format is
951
+ * NUL-delimited so a body cannot split a record, and appending a file list to
952
+ * the same stream would put one commit's files inside the next commit's record.
953
+ * `-z` also stops git C-quoting a non-ASCII filename (`"specs/\303\251.md"`),
954
+ * which a prefix match would then miss.
955
+ *
956
+ * Returning null on failure is deliberate: the caller leaves every commit's
957
+ * paths unknown, nothing is filtered, and the report is exactly what it was
958
+ * before the filter existed. A read that failed must not be able to empty a
959
+ * release.
960
+ */
961
+ function readChangedPaths(git, range) {
962
+ const raw = git(['log', '-z', '--format=%x1e%H', '--name-only', range])
963
+ if (raw === null) return null
964
+ const bySha = new Map()
965
+ for (const chunk of raw.split('\x1e')) {
966
+ if (!chunk.trim()) continue
967
+ const [sha, ...names] = chunk.split('\x00')
968
+ const id = String(sha || '').trim()
969
+ if (!id) continue
970
+ bySha.set(
971
+ id,
972
+ names.map((n) => n.replace(/^\n+/, '').trim()).filter(Boolean),
973
+ )
974
+ }
975
+ return bySha
976
+ }
977
+
945
978
  /**
946
979
  * Resolve a commit range and read it, shared by `released` (which reports on it)
947
980
  * and `stage` (which acts on it). Both must agree on what a release contains,
@@ -1007,6 +1040,14 @@ function readCommitRange(dir, rangeArg, verb) {
1007
1040
  return { sha, subject, body: rest.join('\x00') }
1008
1041
  })
1009
1042
 
1043
+ // What each commit CHANGED, so bookkeeping can be told from shipped work. A
1044
+ // commit git listed no files for keeps `paths: null` — unknown, never
1045
+ // "changed nothing" — see `onlyIgnoredPaths`.
1046
+ const pathsBySha = readChangedPaths(git, range)
1047
+ for (const commit of commits) {
1048
+ commit.paths = (pathsBySha && pathsBySha.get(commit.sha)) || null
1049
+ }
1050
+
1010
1051
  return { range, commits }
1011
1052
  }
1012
1053
 
@@ -1018,7 +1059,7 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
1018
1059
  }
1019
1060
  const { range, commits } = read
1020
1061
 
1021
- const report = ticketsInRange(commits)
1062
+ const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
1022
1063
 
1023
1064
  // Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
1024
1065
  // transport, or a read failure degrades to bare refs — never a failure.
@@ -1061,6 +1102,12 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
1061
1102
  // MISSED trailer looks identical, so silence here would read as "everything is
1062
1103
  // accounted for".
1063
1104
  lines.push(` ${report.unreferenced} commit(s) carry no ref`)
1105
+ // A filter that removes commits without saying so reads as "there was nothing
1106
+ // there". Named only when it actually fired — on a project with no paperwork
1107
+ // in the range there is nothing to disclose.
1108
+ if (report.ignored) {
1109
+ lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
1110
+ }
1064
1111
  out.write(lines.join('\n') + '\n')
1065
1112
  return 0
1066
1113
  }
@@ -1110,7 +1157,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
1110
1157
  }
1111
1158
  const { range, commits } = read
1112
1159
 
1113
- const report = ticketsInRange(commits)
1160
+ const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
1114
1161
  const teamKey = (config.linear && config.linear.teamKey) || ''
1115
1162
  const parts = partitionStageMoves({
1116
1163
  tickets: report.tickets,
@@ -1211,6 +1258,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
1211
1258
  unreadable: unreadable.map((t) => t.ref),
1212
1259
  },
1213
1260
  unreferencedCommits: report.unreferenced,
1261
+ ignoredCommits: report.ignored,
1214
1262
  totalCommits: report.total,
1215
1263
  },
1216
1264
  null,
@@ -1248,6 +1296,9 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
1248
1296
  // legitimately carries no ref and a MISSED trailer looks identical, so silence
1249
1297
  // would read as "every commit is accounted for".
1250
1298
  lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
1299
+ if (report.ignored) {
1300
+ lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
1301
+ }
1251
1302
  if (!applying) lines.push(' dry run — pass --apply to move them')
1252
1303
  out.write(lines.join('\n') + '\n')
1253
1304
  return failed.length ? 1 : 0
@@ -1276,7 +1327,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
1276
1327
  * beats no ref for looking correct). An explicit override cannot be blinded, and
1277
1328
  * needs no answer for mixed staging.
1278
1329
  *
1279
- * The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
1330
+ * The branch→spec direction is the INVERSE of what `/spec-start` provisions with,
1280
1331
  * so it is computed by running `branchFor` over each spec and matching, rather
1281
1332
  * than by re-deriving the pattern here. A second implementation of the naming
1282
1333
  * rule would drift from the one that created the branch.
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
5
- * and the Linear-aware paths of `/spec` and `/spec-go`).
5
+ * and the Linear-aware paths of `/spec` and `/spec-next`).
6
6
  *
7
7
  * Reads `specs/.core/linear.config.json` from the project root and normalises it
8
8
  * over frozen defaults. The feature is strictly opt-in: when the file is absent
@@ -92,6 +92,23 @@ const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
92
92
  // LIFECYCLE_BUCKETS.
93
93
  const DEFAULT_RELEASE_STAGES = Object.freeze([])
94
94
 
95
+ // Repo-relative path prefixes whose commits are BOOKKEEPING, not shipped work.
96
+ //
97
+ // A spec's `chore(spec): complete <name>` commit carries the same `Refs:`
98
+ // trailer as the code it describes, but lands AFTER the tag that shipped that
99
+ // code — so without this the ticket appears in two consecutive release ranges:
100
+ // once for its code, once for its paperwork. Measured on one consumer, 14 of the
101
+ // 22 ref-carrying commits in 300 were spec bookkeeping, so this is the dominant
102
+ // case rather than an edge one.
103
+ //
104
+ // PATHS, not commit subjects. `chore(spec):` is a convention a mislabelled
105
+ // commit escapes; what a commit changed is a fact. A commit touching an ignored
106
+ // path AND a source file still counts — it shipped code.
107
+ //
108
+ // An explicit `[]` is the opt-out, and a project that keeps its paperwork
109
+ // elsewhere names its own directories here.
110
+ const DEFAULT_RELEASE_IGNORE_PATHS = Object.freeze(['specs/'])
111
+
95
112
  const DEFAULT_CONFIG = Object.freeze({
96
113
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
97
114
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -128,7 +145,9 @@ const DEFAULT_CONFIG = Object.freeze({
128
145
  // See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
129
146
  // for reporting and doctor's ladder check, and deliberately NOT enforced — a
130
147
  // rollback from test and a hotfix going straight to prod are both legitimate.
131
- release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES }),
148
+ // `ignorePaths` (see DEFAULT_RELEASE_IGNORE_PATHS) is what `released`/`stage`
149
+ // treat as bookkeeping rather than shipped work.
150
+ release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES, ignorePaths: DEFAULT_RELEASE_IGNORE_PATHS }),
132
151
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
133
152
  // `keyEnv` names the env var holding the personal API key. It is a NAME, not a
134
153
  // key: putting the secret itself here would commit it.
@@ -180,7 +199,10 @@ function defaults() {
180
199
  mapping: { ...DEFAULT_CONFIG.mapping },
181
200
  states: { ...DEFAULT_CONFIG.states },
182
201
  snapshot: { ...DEFAULT_CONFIG.snapshot },
183
- release: { stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })) },
202
+ release: {
203
+ stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
204
+ ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
205
+ },
184
206
  branch: { ...DEFAULT_CONFIG.branch },
185
207
  auth: { ...DEFAULT_CONFIG.auth },
186
208
  apply: { ...DEFAULT_CONFIG.apply },
@@ -317,6 +339,35 @@ function mergeReleaseStages(base, parsed) {
317
339
  base.stages = stages
318
340
  }
319
341
 
342
+ // Merge (and validate) release.ignorePaths — the path prefixes whose commits are
343
+ // bookkeeping. Loud on anything but an array of non-empty strings, like
344
+ // release.stages above: a bad value that quietly fell back to the default would
345
+ // let a project believe it had opted out and go on double-counting tickets.
346
+ //
347
+ // A BLANK entry is rejected rather than dropped. `""` is a prefix of every path,
348
+ // so a stray empty string would silently ignore every commit in the range and
349
+ // report a release as containing nothing — the loudest possible wrong answer,
350
+ // arriving as silence.
351
+ function mergeReleaseIgnorePaths(base, parsed) {
352
+ const value = parsed.ignorePaths
353
+ if (value === undefined) return
354
+ if (!Array.isArray(value)) {
355
+ throw new Error(
356
+ `Invalid ${CONFIG_FILE}: release.ignorePaths = ${JSON.stringify(value)} ` +
357
+ '(expected an array of repo-relative path prefixes)',
358
+ )
359
+ }
360
+ value.forEach((entry, i) => {
361
+ if (typeof entry !== 'string' || !entry.trim()) {
362
+ throw new Error(
363
+ `Invalid ${CONFIG_FILE}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
364
+ '(expected a non-empty repo-relative path prefix, e.g. "specs/")',
365
+ )
366
+ }
367
+ })
368
+ base.ignorePaths = stringList(value)
369
+ }
370
+
320
371
  // Merge (and validate) sync.keyedFields. Each value is the item's id property
321
372
  // name (a non-empty string); a field listed here is compared per item.
322
373
  function mergeKeyedFields(base, parsed) {
@@ -382,6 +433,7 @@ function mergeConfig(base, parsed) {
382
433
 
383
434
  if (isObject(parsed.release)) {
384
435
  mergeReleaseStages(base.release, parsed.release)
436
+ mergeReleaseIgnorePaths(base.release, parsed.release)
385
437
  }
386
438
 
387
439
  if (isObject(parsed.branch)) {
@@ -462,9 +514,23 @@ function stageFor(config, key) {
462
514
  return releaseStages(config).find((s) => s.key === key) || null
463
515
  }
464
516
 
517
+ /**
518
+ * The path prefixes whose commits are bookkeeping, always an array.
519
+ *
520
+ * A config object that predates the field gets the DEFAULT — the list
521
+ * `loadLinearConfig` would have produced — rather than an empty one, so an older
522
+ * object cannot quietly turn the filter off. An explicit `[]` survives
523
+ * `Array.isArray` and is honoured as the opt-out.
524
+ */
525
+ function releaseIgnorePaths(config) {
526
+ const paths = config && config.release && config.release.ignorePaths
527
+ return Array.isArray(paths) ? paths : [...DEFAULT_RELEASE_IGNORE_PATHS]
528
+ }
529
+
465
530
  module.exports = {
466
531
  loadLinearConfig,
467
532
  releaseStages,
533
+ releaseIgnorePaths,
468
534
  stageFor,
469
535
  mergeConfig,
470
536
  defaults,
@@ -476,4 +542,5 @@ module.exports = {
476
542
  LIFECYCLE_BUCKETS,
477
543
  TRANSPORTS,
478
544
  DEFAULT_KEY_ENV,
545
+ DEFAULT_RELEASE_IGNORE_PATHS,
479
546
  }
@@ -51,18 +51,77 @@ function refsInBody(body) {
51
51
  return found
52
52
  }
53
53
 
54
+ /**
55
+ * Whether a commit's changed paths are ENTIRELY bookkeeping.
56
+ *
57
+ * The problem it solves: a spec's `chore(spec): complete <name>` commit carries
58
+ * the same `Refs:` trailer as the code it describes, but lands after the tag
59
+ * that shipped that code — so the ticket turns up in two consecutive release
60
+ * ranges, once for its code and once for its paperwork.
61
+ *
62
+ * Deliberately a POSITIVE signal: it says yes only when it actually saw paths
63
+ * and every one of them sits under an ignored prefix. It never reasons from an
64
+ * absence.
65
+ *
66
+ * WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
67
+ * no files at all for a MERGE commit (without `-m`) — verified, not assumed —
68
+ * nor for a genuinely empty one, and a `git log` that failed outright yields no
69
+ * paths for anything. Under that phrasing every one of those becomes a release
70
+ * silently losing its tickets. Under this one they mean "nothing was seen, so
71
+ * nothing is known", which keeps the pre-filter behaviour: an over-claimed
72
+ * ticket is noticed when someone looks for it, whereas a ticket that quietly
73
+ * belongs to no release never is.
74
+ *
75
+ * Matching is path-prefix, not glob: `specs` and `specs/` both mean the
76
+ * directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
77
+ * never swallows `specs-archive/`.
78
+ *
79
+ * @param {string[]|null|undefined} paths repo-relative paths the commit changed
80
+ * @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
81
+ */
82
+ function onlyIgnoredPaths(paths, ignorePaths) {
83
+ if (!Array.isArray(paths) || !paths.length) return false
84
+ const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
85
+ .filter((p) => typeof p === 'string' && p.trim())
86
+ .map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
87
+ .filter(Boolean)
88
+ if (!prefixes.length) return false
89
+ return paths.every((raw) => {
90
+ const file = String(raw == null ? '' : raw)
91
+ .trim()
92
+ .replace(/^\.\//, '')
93
+ // An unreadable entry is an unknown, not an ignored one — it makes the whole
94
+ // commit count, per the bias above.
95
+ if (!file) return false
96
+ return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
97
+ })
98
+ }
99
+
54
100
  /**
55
101
  * Fold commits into the report a release needs.
56
102
  *
57
- * @param {Array<{sha?:string, subject?:string, body?:string}>} commits
58
- * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, total:number}}
103
+ * @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
104
+ * @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
105
+ * see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
106
+ * report is what it was before the filter existed.
107
+ * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
59
108
  * `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
60
- * is listed once, where it first appears, not eight times.
109
+ * is listed once, where it first appears, not eight times. `ignored` is
110
+ * reported rather than merely subtracted — a filter that removes commits in
111
+ * silence reads as "there was nothing there".
61
112
  */
62
- function ticketsInRange(commits) {
113
+ function ticketsInRange(commits, options = {}) {
114
+ const ignorePaths = (options && options.ignorePaths) || []
63
115
  const seen = new Map()
64
116
  let unreferenced = 0
117
+ let ignored = 0
65
118
  for (const commit of commits || []) {
119
+ if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
120
+ // Not counted as unreferenced either: that number exists to surface a
121
+ // MISSED trailer, and a paperwork commit is not a gap someone should hunt.
122
+ ignored++
123
+ continue
124
+ }
66
125
  const refs = [...new Set(refsInBody(commit && commit.body))]
67
126
  if (!refs.length) {
68
127
  unreferenced++
@@ -73,6 +132,7 @@ function ticketsInRange(commits) {
73
132
  return {
74
133
  tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
75
134
  unreferenced,
135
+ ignored,
76
136
  total: (commits || []).length,
77
137
  }
78
138
  }
@@ -161,4 +221,4 @@ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
161
221
  return null
162
222
  }
163
223
 
164
- module.exports = { ticketsInRange, refsInBody, partitionStageMoves, stageOrderWarning }
224
+ module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }