@skitterbyte/skitterspec-linear 11.0.0 → 13.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 (60) hide show
  1. package/MIGRATION.md +260 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +48 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +9 -3
  8. package/assets/core/env.config.md +102 -30
  9. package/assets/core/gating.config.json.example +4 -0
  10. package/assets/core/gating.config.md +81 -0
  11. package/assets/core/linear.config.md +67 -8
  12. package/assets/review/page.html +1501 -0
  13. package/assets/rules/spec-planning.md +224 -15
  14. package/assets/rules/spec-reports.md +269 -0
  15. package/assets/skills/spec/SKILL.md +64 -13
  16. package/assets/skills/spec-bug/SKILL.md +193 -27
  17. package/assets/skills/spec-cancel/SKILL.md +99 -8
  18. package/assets/skills/spec-claim/SKILL.md +114 -0
  19. package/assets/skills/spec-complete/SKILL.md +123 -22
  20. package/assets/skills/spec-diff/SKILL.md +564 -0
  21. package/assets/skills/spec-hotfix/SKILL.md +202 -22
  22. package/assets/skills/spec-init/SKILL.md +49 -9
  23. package/assets/skills/spec-linear-setup/SKILL.md +86 -7
  24. package/assets/skills/spec-list/SKILL.md +218 -0
  25. package/assets/skills/spec-next/SKILL.md +300 -7
  26. package/assets/skills/spec-push/SKILL.md +45 -22
  27. package/assets/skills/spec-review/SKILL.md +59 -11
  28. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  29. package/assets/skills/spec-start/SKILL.md +426 -66
  30. package/assets/skills/spec-status/SKILL.md +24 -2
  31. package/assets/skills/spec-sync/SKILL.md +47 -11
  32. package/assets/skills/spec-to-main/SKILL.md +42 -20
  33. package/package.json +11 -7
  34. package/src/cli.js +1710 -80
  35. package/src/env/building.js +143 -0
  36. package/src/env/classify.js +91 -0
  37. package/src/env/config.js +57 -9
  38. package/src/env/provision.js +192 -19
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +296 -9
  42. package/src/env/review.js +1329 -0
  43. package/src/env/serve.js +549 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/gating.js +155 -0
  46. package/src/init.js +124 -2
  47. package/src/prompts.js +10 -1
  48. package/src/vendor/linear/api.js +104 -1
  49. package/src/vendor/linear/cli-sync.js +874 -17
  50. package/src/vendor/linear/config.js +8 -0
  51. package/src/vendor/linear/credentials.js +94 -0
  52. package/src/vendor/linear/doctor.js +35 -0
  53. package/src/vendor/linear/identity.js +105 -0
  54. package/src/vendor/linear/mcp.js +26 -0
  55. package/src/vendor/sync-core/index.js +6 -2
  56. package/src/vendor/sync-core/src/compare.js +74 -5
  57. package/src/vendor/sync-core/src/normalize.js +30 -0
  58. package/src/vendor/sync-core/src/push.js +11 -1
  59. package/src/vendor/sync-core/src/write.js +38 -0
  60. package/LICENSE +0 -21
package/src/gating.js ADDED
@@ -0,0 +1,155 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Release gating — the loader, the header reader, and the check.
5
+ *
6
+ * The feature records ONE decision per spec: does this ship behind a feature
7
+ * flag, or land live? Skitterspec never learns how a project does flags; it asks
8
+ * the question, cites the project's own documentation, and reads back what was
9
+ * written. Strictly opt-in: with `specs/.core/gating.config.json` absent, every
10
+ * function here reports "not configured" and nothing else changes.
11
+ *
12
+ * Mirrors `src/env/config.js` (frozen defaults, merge known keys only, never
13
+ * throws on absence) so the two opt-in configs behave alike.
14
+ */
15
+
16
+ const fs = require('node:fs')
17
+ const path = require('node:path')
18
+
19
+ const CONFIG_FILE = path.join('specs', '.core', 'gating.config.json')
20
+
21
+ // Only these two buckets are ever read.
22
+ //
23
+ // A SPEC WRITTEN BEFORE GATING WAS ADOPTED HAS NO HEADER AND IS NOT BROKEN. That
24
+ // is the blind spot this check would otherwise walk into: "no Gating: line" is
25
+ // evidence of an unanswered question only for a spec that could have been asked,
26
+ // and every finished or abandoned spec predates the question by definition. They
27
+ // are excluded STRUCTURALLY rather than by a filter someone must remember — a
28
+ // completed spec is not in range, so no future edit can make it fire. Pre-existing
29
+ // specs still in flight ARE reported, deliberately: they are live work, the
30
+ // question genuinely still applies, and the report never blocks anything.
31
+ const ACTIVE_BUCKETS = ['backlog', 'in-progress']
32
+
33
+ const DEFAULT_CONFIG = Object.freeze({
34
+ guidance: '',
35
+ default: 'none: <reason>',
36
+ })
37
+
38
+ /**
39
+ * Load `specs/.core/gating.config.json`. Returns `{ config, present }`;
40
+ * `present:false` means the project has not adopted gating, which is read as
41
+ * "this project does not use feature flags" — never as an error.
42
+ */
43
+ function loadGatingConfig(dir = process.cwd()) {
44
+ const base = { ...DEFAULT_CONFIG }
45
+ let raw
46
+ try {
47
+ raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return { config: base, present: false }
50
+ throw error
51
+ }
52
+ let parsed
53
+ try {
54
+ parsed = JSON.parse(raw)
55
+ } catch (error) {
56
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
57
+ }
58
+ if (parsed && typeof parsed === 'object') {
59
+ if (typeof parsed.guidance === 'string') base.guidance = parsed.guidance.trim()
60
+ if (typeof parsed.default === 'string' && parsed.default.trim()) {
61
+ base.default = parsed.default.trim()
62
+ }
63
+ }
64
+ return { config: base, present: true }
65
+ }
66
+
67
+ /**
68
+ * Read a spec's `> **Gating:** …` blockquote field from `00-overview.md`.
69
+ *
70
+ * Returns `{ raw, kind }` with four kinds, because there are four states and
71
+ * collapsing them is what made the omission invisible in the first place:
72
+ *
73
+ * flag a flag name — ships behind it
74
+ * none `none: <reason>` — deliberately not flagged, and why
75
+ * invalid present but says nothing: empty, or a bare `none` with no reason
76
+ * missing no field at all
77
+ *
78
+ * `invalid` and `missing` are kept apart on purpose. A bare `none` is someone
79
+ * answering without deciding; a missing line is nobody having been asked. They
80
+ * want different words.
81
+ */
82
+ function readGatingField(specPath) {
83
+ const overview = path.join(specPath, '00-overview.md')
84
+ let raw
85
+ try {
86
+ raw = fs.readFileSync(overview, 'utf-8')
87
+ } catch {
88
+ return { raw: null, kind: 'missing' }
89
+ }
90
+ const m = /^>\s*\*\*Gating:\*\*\s*(.*)$/m.exec(raw)
91
+ if (!m) return { raw: null, kind: 'missing' }
92
+ const value = m[1].trim().replace(/^["'`]|["'`]$/g, '')
93
+ if (!value) return { raw: value, kind: 'invalid' }
94
+ const bare = /^none\b/i.test(value)
95
+ if (bare) {
96
+ // `none` alone, or `none:` with nothing after it, is a shrug rather than a
97
+ // decision — the reason half is the whole point of recording it.
98
+ const reason = value.replace(/^none\b:?/i, '').trim()
99
+ return { raw: value, kind: reason ? 'none' : 'invalid' }
100
+ }
101
+ return { raw: value, kind: 'flag' }
102
+ }
103
+
104
+ // Active specs on disk, as `{ folder, bucket, path }`. A bucket that does not
105
+ // exist is simply empty — git does not store empty directories, so a missing
106
+ // `specs/backlog/` is the ordinary state of a project with nothing queued.
107
+ function activeSpecs(dir) {
108
+ const out = []
109
+ for (const bucket of ACTIVE_BUCKETS) {
110
+ const root = path.join(dir, 'specs', bucket)
111
+ let entries
112
+ try {
113
+ entries = fs.readdirSync(root, { withFileTypes: true })
114
+ } catch {
115
+ continue
116
+ }
117
+ for (const e of entries) {
118
+ if (e.isDirectory()) out.push({ folder: e.name, bucket, path: path.join(root, e.name) })
119
+ }
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Check specs for a recorded gating decision.
126
+ *
127
+ * Advisory by construction: it returns findings and says nothing about what the
128
+ * caller should do. Nothing here exits, throws on a finding, or blocks.
129
+ *
130
+ * @returns {{configured: boolean, findings: Array<{folder, bucket, kind, raw}>,
131
+ * checked: number, guidance: string}}
132
+ */
133
+ function checkGating(dir, specs) {
134
+ const { config, present } = loadGatingConfig(dir)
135
+ if (!present) return { configured: false, findings: [], checked: 0, guidance: '' }
136
+ const targets = specs && specs.length ? specs : activeSpecs(dir)
137
+ const findings = []
138
+ for (const spec of targets) {
139
+ const { kind, raw } = readGatingField(spec.path)
140
+ if (kind === 'missing' || kind === 'invalid') {
141
+ findings.push({ folder: spec.folder, bucket: spec.bucket, kind, raw })
142
+ }
143
+ }
144
+ return { configured: true, findings, checked: targets.length, guidance: config.guidance }
145
+ }
146
+
147
+ module.exports = {
148
+ CONFIG_FILE,
149
+ DEFAULT_CONFIG,
150
+ ACTIVE_BUCKETS,
151
+ loadGatingConfig,
152
+ readGatingField,
153
+ activeSpecs,
154
+ checkGating,
155
+ }
package/src/init.js CHANGED
@@ -153,7 +153,7 @@ function assertComposedAssets() {
153
153
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
154
154
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
155
155
 
156
- const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
156
+ const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
157
157
 
158
158
  function resetReport() {
159
159
  for (const k of Object.keys(report)) report[k].length = 0
@@ -293,6 +293,25 @@ function writeFile(dir, target, content, { force }) {
293
293
  if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
294
294
  fs.unlinkSync(target)
295
295
  }
296
+ // A LIVE symlink is the opposite case, and `--force` is what makes it
297
+ // dangerous: `writeFileSync` follows the link, so forcing would write composed
298
+ // content — seam markers resolved, provider text spliced in — straight through
299
+ // it and into whatever it points at. In a checkout that dogfoods its own
300
+ // assets that is `packages/*/assets`, i.e. the SOURCE the link exists to keep
301
+ // live. Refuse: the staleness `--force` was reached for is a smaller problem
302
+ // than corrupting the file it would overwrite.
303
+ //
304
+ // WHAT WOULD MAKE THIS LIE: a HARD link. It has no distinguishing lstat — it
305
+ // simply is the file — so it takes the same corrupting path and nothing here
306
+ // can see it. Out of scope deliberately, and said out loud rather than left to
307
+ // be discovered; nothing in this project's install creates one.
308
+ //
309
+ // It cannot fire in an ordinary consumer install, because nothing there is
310
+ // linked — `skitterspec update` writes copies by design.
311
+ if (force && link && link.isSymbolicLink() && fs.existsSync(target)) {
312
+ report.refused.push(rel(dir, target))
313
+ return
314
+ }
296
315
  if (fs.existsSync(target)) {
297
316
  if (!force) {
298
317
  report.skipped.push(rel(dir, target))
@@ -444,6 +463,24 @@ function installIsolation(dir, { enabled, workspaceMode }, opts) {
444
463
  trustWorktreeRoot(dir)
445
464
  }
446
465
 
466
+ // Activate opt-in release gating: write specs/.core/gating.config.json from the
467
+ // example asset, so /spec and friends start asking whether a change ships behind
468
+ // a feature flag and recording the answer.
469
+ //
470
+ // Only called when the operator opts in, and NEVER on `update` — for the same
471
+ // reason as isolation: adopting a policy is a deliberate choice, not something a
472
+ // re-sync flips on. Idempotent; copyAsset never clobbers an existing config
473
+ // without --force, so an operator's edited `guidance` survives a re-init.
474
+ function installGating(dir, { enabled }, opts) {
475
+ if (!enabled) return
476
+ copyAsset(
477
+ dir,
478
+ path.join('core', 'gating.config.json.example'),
479
+ path.join(dir, 'specs', '.core', 'gating.config.json'),
480
+ opts,
481
+ )
482
+ }
483
+
447
484
  // Seed the absolute worktree root into .claude/settings.local.json (gitignored)
448
485
  // so the operator enabling isolation isn't prompted on every edit into a
449
486
  // freshly-provisioned worktree. Best-effort: an unreadable config or malformed
@@ -481,6 +518,67 @@ function trustWorktreeRoot(dir) {
481
518
  }
482
519
  }
483
520
 
521
+ // Is the CLAUDE.md section this project has installed the one we ship?
522
+ //
523
+ // THREE answers, not two. `differs` deliberately does NOT mean "stale": the
524
+ // block is a COPY, so a difference is either an out-of-date copy or the user's
525
+ // own edit, and from here those read identically. Rule 4 of
526
+ // `.claude/rules/negative-checks.md` — route the unknown case to the harmless
527
+ // branch, which here means reporting a difference and naming the fix rather
528
+ // than accusing them of being behind.
529
+ //
530
+ // WHAT WOULD FOOL THIS: absent markers mean the section was never installed, OR
531
+ // that someone stripped it deliberately (`stripClaudeMdSection` exists and is
532
+ // reachable from `reset`). Neither is a fault, so both answer `not installed`
533
+ // and neither is reported as a problem.
534
+ function claudeMdSectionState(dir) {
535
+ const target = path.join(dir, 'CLAUDE.md')
536
+ if (!fs.existsSync(target)) return 'not installed'
537
+ const existing = fs.readFileSync(target, 'utf8')
538
+ if (!existing.includes(SPEC_MARKER_START) || !existing.includes(SPEC_MARKER_END)) {
539
+ return 'not installed'
540
+ }
541
+ const shipped = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
542
+ const start = existing.indexOf(SPEC_MARKER_START) + SPEC_MARKER_START.length
543
+ const installed = existing.slice(start, existing.indexOf(SPEC_MARKER_END)).trim()
544
+ return installed === shipped ? 'fresh' : 'differs'
545
+ }
546
+
547
+ // `update --check`: say what `update` would change, write nothing, exit 0.
548
+ // It reports; `update` without the flag stays the only thing that touches a
549
+ // file. This exists because the section is a copy and a copy goes quietly out
550
+ // of date — this repo's own was a whole spec behind the template it ships,
551
+ // through a spec about that template, with every test green.
552
+ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
553
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
554
+ const manifest = readManifest(dir)
555
+ const rows = []
556
+ // Mirror `resyncManagedFile`'s decision exactly rather than re-deriving it:
557
+ // missing → it would create; customized → it would KEEP yours and say so;
558
+ // pristine → it would write only when the shipped content actually differs.
559
+ for (const { relPath, abs, bundled } of managedTargets(dir)) {
560
+ const state = managedState(dir, relPath, manifest, bundled)
561
+ if (state === 'missing') rows.push([relPath, 'missing — would be created'])
562
+ else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
563
+ else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
564
+ }
565
+ const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
566
+ // A healthy area says NOTHING. A report that lists what is already fine is a
567
+ // report people learn to skim, and then the one line that mattered is missed.
568
+ if (section === 'differs') {
569
+ rows.push([
570
+ 'CLAUDE.md (spec workflow section)',
571
+ 'differs from the shipped one — `update` would replace it (it is a copy, so this is either your edit or an out-of-date one)',
572
+ ])
573
+ }
574
+ if (!rows.length) log('skitterspec update --check: everything is up to date.')
575
+ else {
576
+ log('skitterspec update --check: `skitterspec update` would:')
577
+ for (const [name, why] of rows) log(` ${name} — ${why}`)
578
+ }
579
+ return { rows, section }
580
+ }
581
+
484
582
  function installClaudeMd(dir, { mode }) {
485
583
  const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
486
584
  const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
@@ -663,6 +761,15 @@ function printReport(dir, mode, { diff = false } = {}) {
663
761
  )
664
762
  line('manifest repaired', report.healed)
665
763
  line('unchanged', report.skipped)
764
+ if (report.refused.length) {
765
+ process.stdout.write('\nrefused (a symlink — writing would overwrite what it points at):\n')
766
+ for (const it of report.refused) process.stdout.write(` ${it}\n`)
767
+ process.stdout.write(
768
+ ' These are links into the shipped assets. --force would follow them and\n' +
769
+ ' write composed content into the source. Unlink one to take the copy\n' +
770
+ ' (rm <path>, then re-run), or leave it linked and edit the asset.\n',
771
+ )
772
+ }
666
773
  if (report.warnings.length) {
667
774
  process.stdout.write('\nwarnings:\n')
668
775
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
@@ -682,6 +789,13 @@ function printReport(dir, mode, { diff = false } = {}) {
682
789
  ' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
683
790
  : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
684
791
  ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
792
+ const gatingOn = fs.existsSync(path.join(dir, 'specs', '.core', 'gating.config.json'))
793
+ const gatingNote = gatingOn
794
+ ? 'Release gating is ON: /spec asks whether a change ships behind a feature' +
795
+ ' flag and records the answer on the spec (skitterspec gating check reports' +
796
+ ' any that have none).\n'
797
+ : 'Release gating is opt-in: re-run with --gating (or copy' +
798
+ ' specs/.core/gating.config.json.example → gating.config.json) to enable it.\n'
685
799
  // A provider superset ships its own `spec-<provider>-setup` skill; the base
686
800
  // ships none. Discovering it from what was actually installed keeps this file
687
801
  // tracker-free — it never has to know which tracker (if any) is in the box.
@@ -707,6 +821,7 @@ function printReport(dir, mode, { diff = false } = {}) {
707
821
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
708
822
  " project's stack, then run /spec.\n" +
709
823
  isolationNote +
824
+ gatingNote +
710
825
  trackerNote,
711
826
  )
712
827
  }
@@ -714,7 +829,7 @@ function printReport(dir, mode, { diff = false } = {}) {
714
829
  // `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
715
830
  // unrelated to the config's own `mode` key — which arrives as `workspaceMode`
716
831
  // precisely so the two cannot be confused at a call site.
717
- async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
832
+ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gating }) {
718
833
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
719
834
  resetReport()
720
835
 
@@ -726,6 +841,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
726
841
  installCore(dir, { force })
727
842
  // Adopting isolation writes the live env.config.json — init only, never update.
728
843
  if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
844
+ if (mode !== 'update') installGating(dir, { enabled: gating }, { force })
729
845
  if (claudeMd) installClaudeMd(dir, { mode })
730
846
 
731
847
  // Record what we wrote (and migrate a pre-manifest repo) so a later resync can
@@ -737,6 +853,10 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
737
853
 
738
854
  module.exports = {
739
855
  init,
856
+ // A snapshot of the last run's report, for tests that need to assert on what a
857
+ // run DECIDED rather than only on what it left on disk. Copied, so a caller
858
+ // cannot mutate the live report between phases of a run.
859
+ lastReport: () => JSON.parse(JSON.stringify(report)),
740
860
  SKILLS,
741
861
  COMMANDS,
742
862
  RULES,
@@ -747,6 +867,8 @@ module.exports = {
747
867
  writeManifest,
748
868
  managedTargets,
749
869
  managedState,
870
+ claudeMdSectionState,
871
+ checkSync,
750
872
  isExistingSetup,
751
873
  resync,
752
874
  reset,
package/src/prompts.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * is `'worktree'` otherwise (the value the config defaults to anyway).
12
12
  */
13
13
 
14
- async function promptSetup({ isolationSeed = false } = {}) {
14
+ async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
15
15
  const prompts = require('prompts')
16
16
 
17
17
  let cancelled = false
@@ -48,6 +48,14 @@ async function promptSetup({ isolationSeed = false } = {}) {
48
48
  },
49
49
  ],
50
50
  },
51
+ {
52
+ // Orthogonal to isolation, so it is asked unconditionally rather than
53
+ // nested under it — a project can adopt either, both, or neither.
54
+ type: 'confirm',
55
+ name: 'gating',
56
+ message: 'Record a release-gating decision on each spec — flag, or land live?',
57
+ initial: gatingSeed,
58
+ },
51
59
  ]
52
60
 
53
61
  const ans = await prompts(questions, { onCancel })
@@ -59,6 +67,7 @@ async function promptSetup({ isolationSeed = false } = {}) {
59
67
  return {
60
68
  isolation: Boolean(ans.isolation),
61
69
  mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
70
+ gating: Boolean(ans.gating),
62
71
  }
63
72
  }
64
73
 
@@ -29,6 +29,10 @@ const ENDPOINT = 'https://api.linear.app/graphql'
29
29
  const MAX_RETRIES = 5
30
30
  const MAX_BACKOFF_MS = 60_000
31
31
 
32
+ // Linear's hard per-page ceiling on a connection. `listIssues` pages against
33
+ // this rather than trusting a caller's `first` to be under it.
34
+ const PAGE_SIZE = 250
35
+
32
36
  const { storePath, readStore, resolveTeamKey } = require('./credentials.js')
33
37
 
34
38
  /**
@@ -96,7 +100,22 @@ function resolveApiKey(config, env = process.env, deps = {}) {
96
100
 
97
101
  // Fields we read back on every write. `identifier` and `url` are what the skill
98
102
  // stamps into the spec; `description` is what `spec-sync verify` compares.
99
- const ISSUE_FIELDS = 'id identifier url title description state { id name }'
103
+ // `assignee` rides along so `spec-sync status` can report assignment drift from
104
+ // the same read-back the state drift already uses — one read, not two; it is
105
+ // also the "who holds this" column of the listing.
106
+ // `priority`, `sortOrder` and `parent` are here for `listIssues`: one query has
107
+ // to answer every column the listing prints (Linear's own backlog order) and
108
+ // the discriminator it filters on (a phase sub-issue carries `parent`, a spec
109
+ // issue does not). Requesting them on the read/create/update paths too costs
110
+ // nothing and keeps one field list.
111
+ const ISSUE_FIELDS =
112
+ 'id identifier url title description priority sortOrder state { id name } assignee { id name } parent { id }'
113
+
114
+ // What we read back about a person. `name` is the handle Linear shows on an
115
+ // issue; `displayName` is the short @-handle; `active` distinguishes a current
116
+ // member from a deactivated one, which matters because a deactivated user still
117
+ // resolves by id and can still be assigned.
118
+ const USER_FIELDS = 'id name displayName email active'
100
119
 
101
120
  /**
102
121
  * A GraphQL caller bound to one key. Throws a clear Error on transport failure,
@@ -234,6 +253,90 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
234
253
  if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
235
254
  return (data && data.projects && data.projects.nodes) || []
236
255
  },
256
+ // WHO THIS KEY BELONGS TO. A personal API key is issued to a person, so the
257
+ // workspace can answer "who am I" without anyone configuring it — which is
258
+ // why identity needs no repo config in the common case.
259
+ //
260
+ // The exception this cannot see: a SHARED or bot key, where the viewer is
261
+ // the bot and not the human at the keyboard. That is what
262
+ // `spec-sync whoami --set` exists to override, and why nothing treats this
263
+ // answer as unarguable.
264
+ async readViewer() {
265
+ const data = await call(`query { viewer { ${USER_FIELDS} } }`)
266
+ return (data && data.viewer) || null
267
+ },
268
+ // Find a person by name or email — the fallback when the viewer is not the
269
+ // right answer, and how `/spec-claim --to` resolves a teammate.
270
+ //
271
+ // Search AND paging, not a choice between them: a bare listing is the whole
272
+ // team (fine for five people, useless for five hundred), while search alone
273
+ // cannot answer "show me everyone". `query` omitted lists; `cursor` walks.
274
+ // `first` is capped at 250 to match what Linear will return in one page.
275
+ async searchUsers(query, { limit = 50, cursor = null } = {}) {
276
+ const term = typeof query === 'string' ? query.trim() : ''
277
+ // `or` over name and email: someone searching "jane" and someone pasting
278
+ // "jane@acme.com" are asking the same question.
279
+ const filter = term
280
+ ? { or: [{ name: { containsIgnoreCase: term } }, { email: { containsIgnoreCase: term } }] }
281
+ : {}
282
+ const data = await call(
283
+ `query($filter: UserFilter, $first: Int, $after: String) {
284
+ users(filter: $filter, first: $first, after: $after) {
285
+ nodes { ${USER_FIELDS} }
286
+ pageInfo { hasNextPage endCursor }
287
+ } }`,
288
+ { filter, first: Math.min(Math.max(1, limit), 250), after: cursor || null },
289
+ )
290
+ const users = (data && data.users) || {}
291
+ const page = users.pageInfo || {}
292
+ return {
293
+ users: users.nodes || [],
294
+ // Null rather than absent when the page is the last one, so a caller
295
+ // loops on a value rather than on the presence of a key.
296
+ nextCursor: page.hasNextPage ? page.endCursor || null : null,
297
+ }
298
+ },
299
+ // The listing's one read. Paging is done HERE rather than by the caller so
300
+ // `first` means what it says — Linear caps a page at 250, and a caller that
301
+ // asked for 400 and silently got 250 is exactly the "no silent caps" failure
302
+ // this feature exists to avoid. `first: null` means "everything", which is
303
+ // what lets the listing report a truthful `showing 5 of 23` instead of a
304
+ // total it only assumed. The returned `pageInfo` is the LAST page's, so
305
+ // `hasNextPage` still tells a capped caller that more exist.
306
+ //
307
+ // API-only, like `listIssueStates` — see the operation-contract test.
308
+ async listIssues({ teamId, stateIds, assigneeId, parentless, first = null, after = null, includeArchived = false } = {}) {
309
+ const filter = {}
310
+ if (teamId) filter.team = { id: { eq: teamId } }
311
+ if (stateIds && stateIds.length) filter.state = { id: { in: stateIds } }
312
+ if (assigneeId) filter.assignee = { id: { eq: assigneeId } }
313
+ // Linear's IssueFilter spells "has no parent" as a null check on the
314
+ // relation; there is no `isOrphan`-style boolean.
315
+ if (parentless) filter.parent = { null: true }
316
+
317
+ const query = `query($filter: IssueFilter, $first: Int, $after: String, $includeArchived: Boolean) {
318
+ issues(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
319
+ nodes { ${ISSUE_FIELDS} }
320
+ pageInfo { hasNextPage endCursor }
321
+ }
322
+ }`
323
+
324
+ const nodes = []
325
+ let cursor = after
326
+ let pageInfo = { hasNextPage: false, endCursor: null }
327
+ for (;;) {
328
+ const want = first === null ? PAGE_SIZE : Math.min(PAGE_SIZE, first - nodes.length)
329
+ if (want <= 0) break
330
+ const data = await call(query, { filter, first: want, after: cursor, includeArchived: !!includeArchived })
331
+ const conn = (data && data.issues) || {}
332
+ for (const node of conn.nodes || []) nodes.push(node)
333
+ pageInfo = conn.pageInfo || { hasNextPage: false, endCursor: null }
334
+ if (!pageInfo.hasNextPage) break
335
+ cursor = pageInfo.endCursor
336
+ if (first !== null && nodes.length >= first) break
337
+ }
338
+ return { nodes, pageInfo }
339
+ },
237
340
  // The team's CURRENT key, which is what `retarget` compares stamped
238
341
  // identifiers against. Read from Linear rather than `config.linear.teamKey`
239
342
  // on purpose: the config key is itself one of the things that goes stale