@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
@@ -9,7 +9,7 @@
9
9
  * provider-neutral engine (`@skitterbyte/skitterspec-sync-core`):
10
10
  *
11
11
  * spec-sync normalize <spec> print the local projection (JSON)
12
- * spec-sync push <spec> print the create/update PLAN the skill applies
12
+ * spec-sync plan <spec> print the create/update PLAN the skill applies
13
13
  * (requires --workspace-states; see stateCheckFailure)
14
14
  * spec-sync stamp <spec> write returned ids back into the spec files
15
15
  * spec-sync record <spec> write the last-pushed snapshot (after apply)
@@ -44,6 +44,7 @@ const {
44
44
  stateSuggestions,
45
45
  lintPhases,
46
46
  writeFrontmatter,
47
+ deleteFrontmatter,
47
48
  stampSubIssueId,
48
49
  listPhaseFiles,
49
50
  compareStored,
@@ -52,6 +53,7 @@ const {
52
53
  deriveRecordedKey,
53
54
  isEmptyRetarget,
54
55
  dirtyPaths,
56
+ phaseModeFor,
55
57
  } = require('../sync-core')
56
58
 
57
59
  const {
@@ -69,11 +71,16 @@ const { runChecks } = require('./doctor.js')
69
71
  const {
70
72
  storePath,
71
73
  storeMode,
74
+ readStore,
75
+ userForTeam,
72
76
  fingerprint,
73
77
  writeKey,
78
+ writeUser,
74
79
  writeKeyCommand,
75
80
  removeKey,
81
+ removeUser,
76
82
  } = require('./credentials.js')
83
+ const { resolveIdentity, displayNameOf } = require('./identity.js')
77
84
 
78
85
  // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
79
86
  // under specs/** (preferred) or a literal path to a snapshot directory.
@@ -212,7 +219,7 @@ function specSyncNormalize(dir, config, specArg, out, err) {
212
219
  out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
213
220
  }
214
221
 
215
- // `spec-sync push <spec> [--json]` — print the create/update PLAN diffed against
222
+ // `spec-sync plan <spec> [--json]` — print the create/update PLAN diffed against
216
223
  // the last-pushed snapshot. Machine-readable by default; the /spec-push skill
217
224
  // applies it over MCP then calls `record`.
218
225
  function specSyncPush(dir, config, specArg, flags, out, err) {
@@ -231,7 +238,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
231
238
  return 0
232
239
  }
233
240
  const p = r.plan
234
- const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
241
+ const lines = [`spec-sync plan: ${identifier}`, ...warningLines(snapshotDir, config)]
235
242
  if (p.legacy) lines.push(...legacyLines(p.legacy))
236
243
  if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
237
244
  lines.push(...phaseModeLines(p.phaseMode, r.projection.status))
@@ -242,6 +249,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
242
249
  if (p.subIssues.update.length) lines.push(` sub-issues update: ${p.subIssues.update.map((s) => s.id).join(', ')}`)
243
250
  lines.push(' (run with --json for the full plan the skill applies)')
244
251
  }
252
+ lines.push(...unstampedLines(p))
245
253
  out.write(lines.join('\n') + '\n')
246
254
  return 0
247
255
  }
@@ -249,6 +257,24 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
249
257
  // `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
250
258
  // plan or a status report is printed, because the alternative reading of a spec
251
259
  // with no sub-issues is that its phase files failed to parse.
260
+ // Phases whose stamp is missing while the snapshot still remembers an id nobody
261
+ // claims. Reported OUTSIDE the empty-plan branch on purpose: a plan carrying
262
+ // nothing but these is "empty" for applying, and saying `up to date` about it
263
+ // would hide the one thing the operator has to act on.
264
+ function unstampedLines(plan) {
265
+ const u = (plan && plan.unstamped) || []
266
+ if (!u.length) return []
267
+ const lines = [
268
+ ` unstamped: ${u.length} phase(s) have no linear_issue_id, and ${
269
+ u[0].candidates.length
270
+ } sub-issue(s) from the last push are unclaimed —`,
271
+ ' not creating, since a lost stamp and a new phase look identical from here.',
272
+ ]
273
+ for (const item of u) lines.push(` ${item.ref} — could be: ${item.candidates.join(', ')}`)
274
+ lines.push(' re-stamp the phase file (linear_issue_id) and push again.')
275
+ return lines
276
+ }
277
+
252
278
  function deferredLines(n) {
253
279
  return [
254
280
  ` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
@@ -314,7 +340,7 @@ function stateCheckFailure(config, flags) {
314
340
  if (flags.skipStateCheck) return null
315
341
  if (!flags.workspaceStates) {
316
342
  return [
317
- 'spec-sync push: refusing — the configured issue states have not been validated',
343
+ 'spec-sync plan: refusing — the configured issue states have not been validated',
318
344
  ' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
319
345
  ' workflow-state names, which /spec-push fetches over MCP), or',
320
346
  ' --skip-state-check to push anyway.',
@@ -323,20 +349,20 @@ function stateCheckFailure(config, flags) {
323
349
  ]
324
350
  }
325
351
  if (!fs.existsSync(flags.workspaceStates)) {
326
- return [`spec-sync push: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
352
+ return [`spec-sync plan: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
327
353
  }
328
354
  let names
329
355
  try {
330
356
  names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
331
357
  } catch (error) {
332
- return [`spec-sync push: refusing — --workspace-states is not valid JSON: ${error.message}`]
358
+ return [`spec-sync plan: refusing — --workspace-states is not valid JSON: ${error.message}`]
333
359
  }
334
360
  const list = Array.isArray(names) ? names : []
335
361
  const missing = validateStates(config, list)
336
362
  if (missing.length) {
337
363
  // Say what IS available, and what to use instead. "Done is not a state" sends
338
364
  // you to the Linear UI to go and look; naming the replacement does not.
339
- const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
365
+ const lines = ['spec-sync plan: refusing — configured state name(s) not in the workspace', '']
340
366
  for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
341
367
  lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
342
368
  if (suggestion) lines.push(` use "${suggestion}" instead`)
@@ -435,6 +461,77 @@ function specSyncStamp(dir, config, specArg, flags, out) {
435
461
  return 0
436
462
  }
437
463
 
464
+ /**
465
+ * `spec-sync assign <spec> [--to <id> [--name <n>]] [--release] [--json]`
466
+ *
467
+ * Record — or release — who owns a spec, by stamping `linear_assignee_id` /
468
+ * `linear_assignee_name` into the overview frontmatter. The counterpart to
469
+ * `spec-sync stamp`, which does the same job for issue ids.
470
+ *
471
+ * **It writes the repo and nothing else.** Pushing is the caller's next step,
472
+ * deliberately: the repo is the source of truth, so ownership changes here and
473
+ * the mirror catches up like any other edit. Splitting it that way also means a
474
+ * failed push leaves the claim recorded rather than lost.
475
+ *
476
+ * **Stamping is explicit, and never inferred by a push.** Only this verb (and
477
+ * the skills that call it) writes the field. A push that resolved identity for
478
+ * itself would quietly re-assign a spec to whoever happened to run it — which is
479
+ * how a teammate pushing someone else's branch would steal their work.
480
+ */
481
+ function specSyncAssign(dir, config, specArg, flags, out) {
482
+ const snapshotDir = resolveOrExit(specArg, dir, out)
483
+ if (!snapshotDir) return 1
484
+
485
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
486
+ const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
487
+ const rel = path.relative(dir, snapshotDir)
488
+
489
+ const problems = []
490
+ if (flags.release && flags.to) problems.push('--release and --to are opposites — pass one')
491
+ if (!flags.release && !flags.to) problems.push('nothing to do — pass --to <user-id> or --release')
492
+ // An unlinked spec has no issue to assign. Refusing beats stamping a field
493
+ // that would sit in the file doing nothing until someone noticed.
494
+ if (!identifier) problems.push(`${rel} is not linked to Linear — /spec-push it first`)
495
+
496
+ if (problems.length) {
497
+ out.write(
498
+ ['spec-sync assign: refusing to write — nothing was changed', ...problems.map((p) => ` ${p}`)].join('\n') + '\n',
499
+ )
500
+ return 1
501
+ }
502
+
503
+ if (flags.release) {
504
+ const removed = deleteFrontmatter(snapshotDir, config, ['linear_assignee_id', 'linear_assignee_name'])
505
+ if (flags.json) {
506
+ out.write(JSON.stringify({ spec: rel, issue: identifier, released: removed.length > 0 }, null, 2) + '\n')
507
+ return 0
508
+ }
509
+ out.write(
510
+ (removed.length
511
+ ? `spec-sync assign: ${rel} released (${identifier})\n`
512
+ : `spec-sync assign: ${rel} records no assignee — nothing to release\n`) +
513
+ ' next: push it, so Linear is unassigned too\n',
514
+ )
515
+ return 0
516
+ }
517
+
518
+ writeFrontmatter(snapshotDir, config, {
519
+ linear_assignee_id: flags.to,
520
+ // Display only — the projection never sends it. It exists so a reader (and
521
+ // `/spec-claim`) can name the person without a Linear round trip.
522
+ linear_assignee_name: flags.name || null,
523
+ })
524
+ if (flags.json) {
525
+ out.write(
526
+ JSON.stringify({ spec: rel, issue: identifier, id: flags.to, name: flags.name || null }, null, 2) + '\n',
527
+ )
528
+ return 0
529
+ }
530
+ const who = flags.name ? `${flags.name} (${flags.to})` : flags.to
531
+ out.write(`spec-sync assign: ${rel} assigned to ${who} (${identifier})\n next: push it, so Linear agrees\n`)
532
+ return 0
533
+ }
534
+
438
535
  // `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
439
536
  // files. The skill calls this AFTER applying the plan and stamping new ids.
440
537
  function specSyncRecord(dir, config, specArg, out) {
@@ -483,6 +580,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
483
580
  const u = plan.subIssues.update.length
484
581
  lines.push(` push: pending — ${n} to create, ${u} to update${plan.issue ? ', issue changed' : ''}`)
485
582
  }
583
+ lines.push(...unstampedLines(plan))
486
584
 
487
585
  if (flags.remote && fs.existsSync(flags.remote)) {
488
586
  const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
@@ -501,12 +599,54 @@ function specSyncStatus(dir, config, specArg, flags, out) {
501
599
  } else {
502
600
  lines.push(' drift: none — Linear workflow-state matches the spec')
503
601
  }
602
+ lines.push(...assigneeLines(remote, projection, plan))
504
603
  }
505
604
 
506
605
  out.write(lines.join('\n') + '\n')
507
606
  return 0
508
607
  }
509
608
 
609
+ /**
610
+ * The assignee half of the drift report — nothing at all unless the repo opted
611
+ * `assignee` into `sync.fieldOwnership` (`projection.assignee` is then
612
+ * `undefined`, and a project that opted out must see no trace of the feature).
613
+ *
614
+ * The line says what will ACTUALLY happen, which is not the same as whether the
615
+ * two sides differ. A spec with no recorded assignee never overwrites Linear's,
616
+ * so printing "repo wins on next push" against a PM's assignment would be both
617
+ * an accusation and a lie — the same trap the `stage` branch above documents.
618
+ * The plan is the only honest witness to that, so it is what gets asked.
619
+ */
620
+ function assigneeLines(remote, projection, plan) {
621
+ if (!projection || projection.assignee === undefined) return []
622
+ const remoteUser = remote && remote.assignee ? remote.assignee : null
623
+ const remoteName = remoteUser ? remoteUser.name || remoteUser.id : null
624
+ const local = projection.assignee || null
625
+ const willPush = !!(plan && plan.issue && 'assignee' in plan.issue)
626
+
627
+ if (!remoteUser && !local) return [' assignee: none — the spec records nobody, and neither does Linear']
628
+ if (remoteUser && local && remoteUser.id === local) return [` assignee: ${remoteName} — matches the spec`]
629
+
630
+ // The two sides differ. WHICH LINE IS HONEST depends on the plan, not on the
631
+ // disagreement — there are three ways to differ and only one is drift.
632
+ if (willPush) {
633
+ const to = local || 'nobody'
634
+ return [` drift: Linear assignee is ${remoteName || 'nobody'} but the spec records ${to} (repo wins on next push)`]
635
+ }
636
+ if (!local) {
637
+ // Decision 6, reported: the spec never recorded anyone, so nothing is sent
638
+ // and this assignment is safe where it is.
639
+ return [` assignee: Linear has ${remoteName}; the spec records nobody and will not overwrite it`]
640
+ }
641
+ // Recorded, already pushed, and changed in Linear since. The snapshot says we
642
+ // are in sync, so nothing re-asserts it — and claiming "repo wins" here would
643
+ // promise a correction that will never come.
644
+ return [
645
+ ` assignee: Linear shows ${remoteName || 'nobody'} but the spec records ${local} — ` +
646
+ 'already pushed, so it will not be re-sent',
647
+ ]
648
+ }
649
+
510
650
  /**
511
651
  * `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
512
652
  * against what we sent, and report any lost text.
@@ -879,6 +1019,21 @@ function gatherState(dir, flags) {
879
1019
  : { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
880
1020
  }
881
1021
 
1022
+ // Identity, read from the CACHE ONLY — deliberately no network call, because
1023
+ // doctor is offline until `--check-remote`. A key that could derive an identity
1024
+ // but has not yet reports as "not cached", with `whoami` as the fix, rather
1025
+ // than as a fault: not having asked is not the same as having no answer.
1026
+ if (config) {
1027
+ const owned = !!(config.sync && config.sync.fieldOwnership && 'assignee' in config.sync.fieldOwnership)
1028
+ state.identity = { owned }
1029
+ if (owned) {
1030
+ const store = readStore(storePath(flags.env || process.env))
1031
+ const cached = store.ok ? userForTeam(store.store, state.tracker.teamId) : null
1032
+ if (cached) Object.assign(state.identity, { ok: true, id: cached.id, name: cached.name, source: 'store' })
1033
+ else state.identity.error = 'no identity cached yet — run `spec-sync whoami` to resolve it'
1034
+ }
1035
+ }
1036
+
882
1037
  state._config = config
883
1038
  // Declared here, checked only if `--check-remote` fetches the state TYPES the
884
1039
  // shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
@@ -1733,6 +1888,451 @@ async function specSyncProjects(dir, config, flags, out) {
1733
1888
  return 0
1734
1889
  }
1735
1890
 
1891
+ // Linear's PRIORITY is an enum, not a magnitude: 1=Urgent, 2=High, 3=Medium,
1892
+ // 4=Low — and 0 means "nobody set one", not "lowest". Sorting on the raw number
1893
+ // would float every unprioritised issue to the top, which is exactly backwards,
1894
+ // so 0 is ranked last explicitly.
1895
+ const priorityRank = (issue) => {
1896
+ const p = Number(issue && issue.priority)
1897
+ return !Number.isFinite(p) || p === 0 ? Number.POSITIVE_INFINITY : p
1898
+ }
1899
+
1900
+ const UNPRIORITISED = (issues) => issues.length > 0 && issues.every((i) => priorityRank(i) === Infinity)
1901
+
1902
+ /**
1903
+ * Linear's own Backlog order, reproduced — which is the only reason to ask
1904
+ * Linear this rather than `ls specs/backlog/`.
1905
+ *
1906
+ * `sortOrder` is a float and LOWER sorts higher: it is the position Linear
1907
+ * stores when someone drags a card, never a score. Created-date is deliberately
1908
+ * not consulted — created order is the thing this ordering exists to replace.
1909
+ *
1910
+ * The identifier tie-break is what makes the listing reproducible: without it
1911
+ * two issues a person never dragged apart share a `sortOrder`, and the rows
1912
+ * reorder between runs on nothing but the order Linear happened to return.
1913
+ */
1914
+ function backlogOrder(a, b) {
1915
+ const pa = priorityRank(a)
1916
+ const pb = priorityRank(b)
1917
+ if (pa !== pb) return pa - pb
1918
+ const sa = Number(a && a.sortOrder)
1919
+ const sb = Number(b && b.sortOrder)
1920
+ const na = Number.isFinite(sa) ? sa : 0
1921
+ const nb = Number.isFinite(sb) ? sb : 0
1922
+ if (na !== nb) return na - nb
1923
+ return String((a && a.identifier) || '').localeCompare(
1924
+ String((b && b.identifier) || ''),
1925
+ 'en',
1926
+ { numeric: true },
1927
+ )
1928
+ }
1929
+
1930
+ // What a row says when Linear holds an issue this branch has no spec file for.
1931
+ const UNLINKED = '— (not linked here)'
1932
+
1933
+ /** `2/5 — Wire the toggle`, or nothing at all when no phase is in progress. */
1934
+ function phaseLabel(phase) {
1935
+ if (!phase) return ''
1936
+ const base = `${phase.n}/${phase.total}${phase.title ? ` — ${phase.title}` : ''}`
1937
+ return phase.alsoInProgress > 0 ? `${base} (+${phase.alsoInProgress} more in progress)` : base
1938
+ }
1939
+
1940
+ const sameState = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
1941
+
1942
+ /**
1943
+ * Phase order among a spec issue's children — the order `/spec-push` minted them
1944
+ * in, which is phase-file order. That is the **identifier**, numerically, so
1945
+ * `SKS-9` stays ahead of `SKS-10`.
1946
+ *
1947
+ * `sortOrder` is deliberately NOT consulted, though it is fetched for `--next`.
1948
+ * It is the position in Linear's Backlog view, and for sub-issues it tracks
1949
+ * nothing about the plan: this repo's own SKS-115 carries -105486, -108489,
1950
+ * -109484, -5066, -10982 across phases 1-5, which sorts phase 4 last and would
1951
+ * have printed a spec on phase 4 as "5/5". Nor is `backlogOrder` reused — a
1952
+ * priority set on one phase would renumber the spec, and "phase 3 of 5" is a
1953
+ * position in the plan, never a ranking.
1954
+ *
1955
+ * KNOWN BLIND SPOT: a phase inserted between two existing ones mints a HIGHER
1956
+ * identifier than its position, so it numbers as if appended. Phase files are
1957
+ * appended in practice, and the alternative — reading the local phase files —
1958
+ * cannot number a spec that is not on this branch, which is the case the
1959
+ * listing exists for.
1960
+ */
1961
+ function phaseOrder(a, b) {
1962
+ return String((a && a.identifier) || '').localeCompare(
1963
+ String((b && b.identifier) || ''),
1964
+ 'en',
1965
+ { numeric: true },
1966
+ )
1967
+ }
1968
+
1969
+ /**
1970
+ * `spec-sync list [--state <name> …|--all] [--next N] [--limit N] [--archived]
1971
+ * [--json]`
1972
+ * — every spec issue Linear holds, with the local spec folder that owns it.
1973
+ *
1974
+ * Linear answers what is live and who holds it; the repo answers what each
1975
+ * issue is CALLED on disk, because `/spec-start` moves a spec to
1976
+ * `specs/in-progress/` on that spec's own branch — so `ls specs/` on the base
1977
+ * branch is wrong about exactly the specs you most want to see.
1978
+ */
1979
+ async function specSyncList(dir, config, flags, out) {
1980
+ // `--next N` is the top of the BACKLOG in Linear's own order, so it fixes the
1981
+ // scope itself. Combined with --state/--all one of them would have to lose,
1982
+ // silently — the failure this feature exists to avoid. Refuse instead, before
1983
+ // any transport work, so the answer does not depend on whether a key is set.
1984
+ const next = Number.isFinite(flags.next) && flags.next > 0 ? flags.next : null
1985
+
1986
+ // Exactly one scope selector. Each of these FIXES the set of states, so any
1987
+ // two of them means one wins silently — and a scope the user did not get is
1988
+ // the same failure as a cap they were not told about (decision 4). Checked
1989
+ // before any transport work, so the answer never depends on whether a key is
1990
+ // set. `--state` is repeatable and counts once: several names are one scope.
1991
+ const scopeFlags = [
1992
+ next !== null && '--next',
1993
+ flags.all === true && '--all',
1994
+ flags.stateArgs.length > 0 && '--state',
1995
+ flags.inProgress === true && '--in-progress',
1996
+ ].filter(Boolean)
1997
+ if (scopeFlags.length > 1) {
1998
+ out.write(
1999
+ [
2000
+ `spec-sync list: ${scopeFlags.join(' and ')} each pick the scope, so they cannot be combined`,
2001
+ ' use one of them — they are alternatives, not filters that stack.',
2002
+ ].join('\n') + '\n',
2003
+ )
2004
+ return 1
2005
+ }
2006
+
2007
+ // `--mine` and `--by` are the same question asked two ways. Combining them
2008
+ // has no reading, and picking one would answer a question nobody asked.
2009
+ if (flags.mine && flags.by) {
2010
+ out.write(
2011
+ [
2012
+ 'spec-sync list: --mine and --by both filter by assignee, so they cannot be combined',
2013
+ ' use --mine for your own, or --by <user> for someone else.',
2014
+ ].join('\n') + '\n',
2015
+ )
2016
+ return 1
2017
+ }
2018
+
2019
+ const key = resolveApiKey(config, flags.env || process.env)
2020
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
2021
+ const teamId = (config.linear && config.linear.teamId) || null
2022
+
2023
+ const degrade = (reason) => {
2024
+ if (flags.json) out.write(JSON.stringify({ transport, specs: null, reason }, null, 2) + '\n')
2025
+ else out.write(`spec-sync list: ${reason}\n`)
2026
+ return 0
2027
+ }
2028
+ if (transport === 'mcp') {
2029
+ return degrade(
2030
+ `transport = mcp — ${key.ok ? '--via mcp was requested' : key.error}; list issues over MCP instead`,
2031
+ )
2032
+ }
2033
+ if (!key.ok) return degrade(key.error)
2034
+
2035
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
2036
+
2037
+ // The scope. `--all` drops the state filter; `--state` names states directly;
2038
+ // the default reads the two live buckets out of `config.states` rather than
2039
+ // restating them, so a workspace that renamed "Backlog" needs no second edit.
2040
+ const wantedNames = next !== null
2041
+ ? [config.states.backlog]
2042
+ : flags.inProgress === true
2043
+ ? [config.states['in-progress']]
2044
+ : flags.all === true
2045
+ ? null
2046
+ : flags.stateArgs.length
2047
+ ? flags.stateArgs
2048
+ : [config.states.backlog, config.states['in-progress']]
2049
+
2050
+ let stateIds = null
2051
+ if (wantedNames) {
2052
+ let workspaceStates
2053
+ try {
2054
+ workspaceStates = await adapter.listIssueStates(teamId)
2055
+ } catch (error) {
2056
+ return degrade(`could not read the workspace's issue states (${error.message})`)
2057
+ }
2058
+ // A POSITIVE signal, not an absence: this is the workspace's FULL state
2059
+ // list, freshly read, so a name missing from it is genuinely missing rather
2060
+ // than merely outside a narrower query. Linear silently ignores an unknown
2061
+ // state, so an unchecked name would return an empty listing that looks like
2062
+ // "no specs" — see .claude/rules/negative-checks.md.
2063
+ const byName = new Map(
2064
+ workspaceStates.filter((s) => s && s.name).map((s) => [String(s.name).toLowerCase(), s]),
2065
+ )
2066
+ const missing = wantedNames.filter((n) => !byName.has(String(n).toLowerCase()))
2067
+ if (missing.length) {
2068
+ out.write(
2069
+ [
2070
+ `spec-sync list: unknown state ${missing.map((n) => `"${n}"`).join(', ')}`,
2071
+ ` the workspace has: ${workspaceStates.map((s) => s.name).join(', ')}`,
2072
+ ' fix specs/.core/linear.config.json → states, or pass --state with one of those.',
2073
+ ].join('\n') + '\n',
2074
+ )
2075
+ return 1
2076
+ }
2077
+ stateIds = wantedNames.map((n) => byName.get(String(n).toLowerCase()).id)
2078
+ }
2079
+
2080
+ // --- who holds it (decision 9) --------------------------------------------
2081
+ //
2082
+ // Both filters reuse `feat-linear-assignment`'s identity rather than keeping a
2083
+ // second copy: `--mine` through `resolveIdentity`, `--by` through the same
2084
+ // user search `spec-sync users` runs.
2085
+ let assignee = null
2086
+ if (flags.mine) {
2087
+ const who = await resolveIdentity(config, flags.env || process.env, { adapter })
2088
+ if (!who.ok) {
2089
+ // Exit 0, list NOTHING, and never prompt. An unresolvable identity is an
2090
+ // ordinary state — a shared or bot key, an offline machine, a repo
2091
+ // mid-setup — not a fault. The one thing that must not happen is dropping
2092
+ // the filter and printing the whole team's work under a `--mine` heading.
2093
+ const line = `spec-sync list: --mine needs to know who you are, and it does not (${who.reason})`
2094
+ if (flags.json) {
2095
+ out.write(JSON.stringify({ transport: 'api', scope: null, specs: null, reason: line }, null, 2) + '\n')
2096
+ } else {
2097
+ out.write(
2098
+ [line, ' run `spec-sync whoami --set <id>` to say so, or use --by <user>.'].join('\n') + '\n',
2099
+ )
2100
+ }
2101
+ return 0
2102
+ }
2103
+ assignee = { id: who.id, name: who.name }
2104
+ } else if (flags.by) {
2105
+ // Resolved through Linear, never a hand-typed id — a wrong uuid filters to
2106
+ // nothing and looks exactly like "that person has no specs".
2107
+ let found
2108
+ try {
2109
+ found = await adapter.searchUsers(String(flags.by), { limit: 50 })
2110
+ } catch (error) {
2111
+ return degrade(`could not search users (${error.message}); Linear is unreachable`)
2112
+ }
2113
+ const users = (found && found.users) || []
2114
+ // Unlike --mine above, these two exit NON-ZERO: the argument is wrong and
2115
+ // only the caller can fix it, which is the same shape as an unknown --state.
2116
+ // Never fall back to the whole team — the heading would promise one person.
2117
+ if (!users.length) {
2118
+ out.write(
2119
+ [
2120
+ `spec-sync list: no Linear user matches ${JSON.stringify(String(flags.by))}`,
2121
+ ' `spec-sync users <term>` lists who the workspace has.',
2122
+ ].join('\n') + '\n',
2123
+ )
2124
+ return 1
2125
+ }
2126
+ if (users.length > 1) {
2127
+ const lines = [`spec-sync list: ${users.length} users match ${JSON.stringify(String(flags.by))}`]
2128
+ for (const u of users.slice(0, 10)) {
2129
+ lines.push(` ${displayNameOf(u) || '(unnamed)'}${u.email ? ` ${u.email}` : ''}`)
2130
+ }
2131
+ if (users.length > 10) lines.push(` … and ${users.length - 10} more`)
2132
+ lines.push(' narrow it — an email address matches exactly one person.')
2133
+ out.write(lines.join('\n') + '\n')
2134
+ return 1
2135
+ }
2136
+ assignee = { id: users[0].id, name: displayNameOf(users[0]) }
2137
+ }
2138
+
2139
+ let page
2140
+ try {
2141
+ page = await adapter.listIssues({
2142
+ teamId,
2143
+ stateIds,
2144
+ assigneeId: assignee ? assignee.id : null,
2145
+ parentless: true,
2146
+ first: null,
2147
+ includeArchived: !!flags.archived,
2148
+ })
2149
+ } catch (error) {
2150
+ return degrade(`could not list issues (${error.message}); Linear is unreachable`)
2151
+ }
2152
+
2153
+ // Filtered again here, not merely in the query: `parent { id }` is the
2154
+ // structural discriminator between a spec issue and a phase sub-issue
2155
+ // (decision 2), and it is cheap enough to assert on the data we actually got
2156
+ // rather than trust that the filter was applied.
2157
+ const issues = (page.nodes || []).filter((n) => n && !(n.parent && n.parent.id))
2158
+
2159
+ // Ordered BEFORE projection, so the comparator reads Linear's own fields
2160
+ // (`priority`, `sortOrder`) rather than a row shape that never carried them.
2161
+ // Only `--next` reorders: the plain listing keeps Linear's return order, which
2162
+ // is what every existing caller already reads.
2163
+ const ordered = next === null ? issues : issues.slice().sort(backlogOrder)
2164
+
2165
+ const byIdentifier = new Map(
2166
+ listSpecs(dir, config)
2167
+ .filter((s) => s.identifier)
2168
+ .map((s) => [s.identifier, s]),
2169
+ )
2170
+ // Identifier → Linear uuid, for the phase lookup below. Kept beside the rows
2171
+ // rather than on them, so the --json shape stays exactly what it prints.
2172
+ const uuidOf = new Map(ordered.map((i) => [i.identifier, i.id]))
2173
+
2174
+ const rows = ordered.map((issue) => {
2175
+ const local = byIdentifier.get(issue.identifier) || null
2176
+ return {
2177
+ identifier: issue.identifier || '',
2178
+ title: issue.title || '',
2179
+ state: (issue.state && issue.state.name) || '',
2180
+ // An issue with no local match is REPORTED, not dropped. A teammate's
2181
+ // unlanded spec, or one authored inside another spec's worktree, is not
2182
+ // on this branch at all — which is the very gap the listing exists to
2183
+ // close, so hiding it would defeat the command.
2184
+ spec: local ? local.spec : null,
2185
+ bucket: local ? local.bucket : null,
2186
+ assignee: (issue.assignee && issue.assignee.name) || null,
2187
+ // Filled in below for in-progress rows only; `null` on every row that was
2188
+ // never looked up, so the shape does not vary by state.
2189
+ phase: null,
2190
+ url: issue.url || null,
2191
+ }
2192
+ })
2193
+
2194
+ const total = rows.length
2195
+ // `--next N` IS a cap, and announces itself as one (decision 4) — the count
2196
+ // line below reports it the same way `--limit` is reported.
2197
+ const limit = next !== null
2198
+ ? next
2199
+ : Number.isFinite(flags.limit) && flags.limit > 0
2200
+ ? flags.limit
2201
+ : null
2202
+ const shown = limit === null ? rows : rows.slice(0, limit)
2203
+ const capFlag = next !== null ? '--next' : '--limit'
2204
+ // The heading has to name the filter as well as the states: `showing 2 of 2`
2205
+ // under a bare "live" heading reads as the whole team's work when it is one
2206
+ // person's.
2207
+ const held = assignee ? `, assigned to ${assignee.name || assignee.id}` : ''
2208
+ const scope =
2209
+ (next !== null
2210
+ ? `next ${next}`
2211
+ : flags.inProgress === true
2212
+ ? `in progress (${wantedNames.join(', ')})`
2213
+ : flags.all === true
2214
+ ? 'all states'
2215
+ : `${flags.stateArgs.length ? 'states' : 'live'} (${wantedNames.join(', ')})`) + held
2216
+ // Said whenever it is true, not treated as an edge case: a workspace where
2217
+ // nobody has set a priority is the common one, and there the order is a
2218
+ // person's drag-order rather than a ranking. Reading it as a ranking is the
2219
+ // mistake this line exists to prevent.
2220
+ const unprioritised = next !== null && UNPRIORITISED(ordered)
2221
+
2222
+ // --- the current phase, on in-progress rows only (decision 8) --------------
2223
+ //
2224
+ // One extra Linear call per in-progress row, and NONE for any other — a
2225
+ // backlog listing costs exactly what it did before. Run over `shown`, not
2226
+ // `rows`, so a capped listing does not pay for phases it will not print.
2227
+ const inProgressState = config.states['in-progress']
2228
+ const phaseFailures = []
2229
+ for (const row of shown) {
2230
+ if (!sameState(row.state, inProgressState)) continue
2231
+ // An `inline`-mode spec keeps its phases in the issue DESCRIPTION, so there
2232
+ // are no children to read. Asking anyway would report "no phase in progress"
2233
+ // for a spec that is mid-build — an absence that means nothing. The bucket
2234
+ // is the local one where we have it; where we do not, Linear's own state is
2235
+ // in-progress by construction, which is the bucket that mode resolves for.
2236
+ if (phaseModeFor(row.bucket || 'in-progress', config) === 'inline') continue
2237
+
2238
+ const uuid = uuidOf.get(row.identifier)
2239
+ if (!uuid) continue
2240
+ let children
2241
+ try {
2242
+ children = await adapter.listSubIssues(uuid)
2243
+ } catch (error) {
2244
+ // Bias the unknown case to inaction: a failed lookup leaves the row
2245
+ // without a phase and says so once, rather than failing the whole
2246
+ // listing or printing a phase it never read.
2247
+ phaseFailures.push(row.identifier)
2248
+ continue
2249
+ }
2250
+ const phases = (children || []).slice().sort(phaseOrder)
2251
+ const live = phases.filter((c) => c && sameState(c.state && c.state.name, inProgressState))
2252
+ if (!live.length) continue
2253
+ row.phase = {
2254
+ n: phases.indexOf(live[0]) + 1,
2255
+ total: phases.length,
2256
+ title: (live[0].title || '').trim(),
2257
+ // More than one phase in progress is a real shape, not an error — two
2258
+ // people can work a spec at once. Report the count rather than picking
2259
+ // one silently.
2260
+ alsoInProgress: live.length - 1,
2261
+ }
2262
+ }
2263
+
2264
+ if (flags.json) {
2265
+ out.write(
2266
+ JSON.stringify(
2267
+ {
2268
+ transport: 'api',
2269
+ scope,
2270
+ archived: !!flags.archived,
2271
+ showing: shown.length,
2272
+ total,
2273
+ ...(next !== null ? { unprioritised } : {}),
2274
+ assignedTo: assignee,
2275
+ specs: shown,
2276
+ },
2277
+ null,
2278
+ 2,
2279
+ ) + '\n',
2280
+ )
2281
+ return 0
2282
+ }
2283
+
2284
+ const lines = [
2285
+ `spec-sync list: transport = api, ${scope} — showing ${shown.length} of ${total}` +
2286
+ (next !== null ? ' in backlog' : ''),
2287
+ ]
2288
+ if (unprioritised) {
2289
+ lines.push(" every candidate is unprioritised — this is Linear's manual Backlog order, not a ranking")
2290
+ }
2291
+ // Say what was NOT shown, every time (decisions 4 and 5). A listing that
2292
+ // implies a completeness it never verified is this command's failure mode.
2293
+ lines.push(
2294
+ flags.archived
2295
+ ? ' archived issues included'
2296
+ : ' archived issues excluded (--archived to include)',
2297
+ )
2298
+ if (limit !== null && total > shown.length) {
2299
+ lines.push(` ${total - shown.length} more not shown — ${capFlag} ${total} for all of them`)
2300
+ }
2301
+ if (page.pageInfo && page.pageInfo.hasNextPage) {
2302
+ lines.push(' ! Linear reported further pages it did not return — this listing may be short')
2303
+ }
2304
+ if (phaseFailures.length) {
2305
+ lines.push(
2306
+ ` ! could not read the phases of ${phaseFailures.join(', ')} — those rows show no phase`,
2307
+ )
2308
+ }
2309
+ lines.push('')
2310
+ if (!shown.length) {
2311
+ lines.push(' no spec issues in scope')
2312
+ } else {
2313
+ const idW = Math.max(...shown.map((r) => r.identifier.length))
2314
+ const stW = Math.max(...shown.map((r) => r.state.length))
2315
+ const pad = ' '.repeat(idW + stW + 4)
2316
+ const specW = Math.max(...shown.map((r) => (r.spec || UNLINKED).length))
2317
+ for (const row of shown) {
2318
+ // Who holds it, and where the work is — the two things the repo could not
2319
+ // have told them. Appended rather than columnised further: both are empty
2320
+ // on most rows, and padding for a column that is usually blank buys
2321
+ // nothing but width.
2322
+ const held = [row.assignee, phaseLabel(row.phase)].filter(Boolean).join(' · ')
2323
+ lines.push(
2324
+ ` ${row.identifier.padEnd(idW)} ${row.state.padEnd(stW)} ` +
2325
+ (held ? `${(row.spec || UNLINKED).padEnd(specW)} ${held}` : row.spec || UNLINKED),
2326
+ )
2327
+ if (row.title) lines.push(` ${pad}${row.title}`)
2328
+ }
2329
+ lines.push('')
2330
+ lines.push(' start one with: /spec-start <name>')
2331
+ }
2332
+ out.write(lines.join('\n') + '\n')
2333
+ return 0
2334
+ }
2335
+
1736
2336
  /**
1737
2337
  * Apply one spec's plan. Returns what happened rather than printing it, so the
1738
2338
  * single-spec command and the bulk loop report in their own voices while sharing
@@ -1764,6 +2364,9 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1764
2364
  projectId: project || (config.linear && config.linear.projectId) || null,
1765
2365
  description: plan.issue && plan.issue.description,
1766
2366
  stateId: stateId(plan.issue && plan.issue.state),
2367
+ // Assert only. An issue being minted has no assignee to retract, so
2368
+ // `withoutNull` dropping a null here is exactly right.
2369
+ assigneeId: (plan.issue && plan.issue.assignee) || null,
1767
2370
  }))
1768
2371
  if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
1769
2372
  parentId = created.id
@@ -1777,10 +2380,18 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1777
2380
  parentId = existing.id
1778
2381
  result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
1779
2382
  if (plan.issue) {
1780
- await adapter.updateIssue(existing.id, withoutNull({
2383
+ const updates = withoutNull({
1781
2384
  description: plan.issue.description,
1782
2385
  stateId: stateId(plan.issue.state),
1783
- }))
2386
+ })
2387
+ // APPLIED AFTER `withoutNull`, and that ordering is the whole point: null
2388
+ // is MEANINGFUL for this field — it is how Linear unassigns an issue — so
2389
+ // passing it through the stripper would drop exactly the clear that
2390
+ // finishing a spec depends on, and the mirror would keep a finished spec
2391
+ // assigned forever. The plan only carries the key when there is something
2392
+ // to say, so `in` is the test, not truthiness.
2393
+ if ('assignee' in plan.issue) updates.assigneeId = plan.issue.assignee ?? null
2394
+ await adapter.updateIssue(existing.id, updates)
1784
2395
  lines.push(` issue updated: ${identifier}`)
1785
2396
  }
1786
2397
  }
@@ -1841,6 +2452,196 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1841
2452
  return { result, lines, lost }
1842
2453
  }
1843
2454
 
2455
+ /**
2456
+ * `spec-sync whoami [--json] [--set <id> [--name <n>]] [--unset]`
2457
+ *
2458
+ * Report — and cache — who the operator is in Linear, which is the fact
2459
+ * assignment rests on. See `identity.js` for why it is derived rather than
2460
+ * configured.
2461
+ *
2462
+ * **Exit code 0 even when the answer is "unknown".** This deliberately differs
2463
+ * from `credentials status`, which exits 1 with no key: that command is a
2464
+ * READINESS check, and a missing key means the thing you asked about cannot
2465
+ * work. This one answers a question, and "nobody is identified here" is a valid
2466
+ * answer — a shared key, an offline machine, a repo mid-setup. Exiting non-zero
2467
+ * would make every caller treat an ordinary state as a fault, which is how an
2468
+ * advisory check turns into one that accuses.
2469
+ *
2470
+ * A successful DERIVE is cached; a read from the cache is not re-written. The
2471
+ * caching lives here rather than in `resolveIdentity` so that asking the
2472
+ * question never has a side effect — see that module's header.
2473
+ */
2474
+ async function specSyncWhoami(dir, config, flags, out) {
2475
+ const env = flags.env || process.env
2476
+ const file = storePath(env)
2477
+ const teamId = (config.linear && config.linear.teamId) || ''
2478
+ const teamKey = (config.linear && config.linear.teamKey) || ''
2479
+ const label = teamKey ? `${teamId} (${teamKey})` : teamId
2480
+
2481
+ // The store is keyed by team, so without one there is nowhere to read from or
2482
+ // write to. That IS a broken config rather than an ordinary unknown, so unlike
2483
+ // the rest of this command it exits non-zero.
2484
+ if (!teamId) {
2485
+ out.write(
2486
+ 'spec-sync whoami: no linear.teamId in specs/.core/linear.config.json.\n' +
2487
+ ' Identity is stored per team — run `spec-sync init-config` first.\n',
2488
+ )
2489
+ return 1
2490
+ }
2491
+
2492
+ if (flags.unset) {
2493
+ const r = removeUser(file, teamId, {})
2494
+ if (!r.ok) {
2495
+ out.write(`spec-sync whoami: ${r.reason}\n`)
2496
+ return 1
2497
+ }
2498
+ out.write(
2499
+ r.removed
2500
+ ? `spec-sync whoami: forgot the identity for ${label} (the API key is untouched)\n`
2501
+ : `spec-sync whoami: no identity stored for ${label} — nothing to forget\n`,
2502
+ )
2503
+ return 0
2504
+ }
2505
+
2506
+ if (flags.set) {
2507
+ const r = writeUser(file, teamId, { id: flags.set, name: flags.name }, {})
2508
+ if (!r.ok) {
2509
+ out.write(`spec-sync whoami: ${r.reason}\n`)
2510
+ return 1
2511
+ }
2512
+ if (flags.json) {
2513
+ out.write(
2514
+ JSON.stringify({ id: flags.set, name: flags.name || null, source: 'set', store: r.path }, null, 2) + '\n',
2515
+ )
2516
+ return 0
2517
+ }
2518
+ const shown = flags.name ? `${flags.name} (${flags.set})` : flags.set
2519
+ out.write(`spec-sync whoami: identity for ${label} set to ${shown} in ${r.path} (600)\n`)
2520
+ return 0
2521
+ }
2522
+
2523
+ const identity = await resolveIdentity(config, env, { adapter: flags.adapter, fetch: flags.fetch })
2524
+
2525
+ if (identity.ok && identity.source === 'viewer') {
2526
+ // Cache what the network just told us, so the next `/spec-start` costs no
2527
+ // call. A failed write is not fatal: we HAVE the answer, and re-deriving it
2528
+ // next time is a slower success, not a wrong one.
2529
+ const cached = writeUser(file, teamId, { id: identity.id, name: identity.name }, {})
2530
+ if (!cached.ok) identity.cacheWarning = cached.reason
2531
+ }
2532
+
2533
+ if (flags.json) {
2534
+ out.write(
2535
+ JSON.stringify(
2536
+ {
2537
+ team: label,
2538
+ store: file,
2539
+ ok: identity.ok,
2540
+ id: identity.id || null,
2541
+ name: identity.name || null,
2542
+ source: identity.source,
2543
+ reason: identity.ok ? null : identity.reason || null,
2544
+ },
2545
+ null,
2546
+ 2,
2547
+ ) + '\n',
2548
+ )
2549
+ return 0
2550
+ }
2551
+
2552
+ if (!identity.ok) {
2553
+ out.write(
2554
+ [
2555
+ 'spec-sync whoami: unknown — no identity for this workspace',
2556
+ ` ${identity.reason}`,
2557
+ ' Set one by hand:',
2558
+ ' skitterspec spec-sync whoami --set <linear-user-id> --name "<your name>"',
2559
+ ' Find the id with: skitterspec spec-sync users <name-or-email>',
2560
+ ].join('\n') + '\n',
2561
+ )
2562
+ return 0
2563
+ }
2564
+
2565
+ const where = identity.source === 'store' ? `cached in ${file}` : 'derived from the API key'
2566
+ const lines = [
2567
+ `spec-sync whoami: ${identity.name || '(unnamed)'} <${identity.id}>`,
2568
+ ` team: ${label}`,
2569
+ ` source: ${where}`,
2570
+ ]
2571
+ if (identity.cacheWarning) lines.push(` note: not cached — ${identity.cacheWarning}`)
2572
+ out.write(lines.join('\n') + '\n')
2573
+ return 0
2574
+ }
2575
+
2576
+ /**
2577
+ * `spec-sync users [<query>] [--json] [--limit N] [--cursor C]`
2578
+ *
2579
+ * Find a person by name or email. Both halves matter: search is what makes this
2580
+ * usable in a workspace of hundreds, and the bare listing is what answers "show
2581
+ * me everyone" in one of five. Linear's user query does both, so this is one
2582
+ * command rather than a `users` and a `search-users`.
2583
+ *
2584
+ * The engine answers on the API path and steps aside on the MCP path — the same
2585
+ * contract `states` and `projects` follow.
2586
+ */
2587
+ async function specSyncUsers(dir, config, query, flags, out) {
2588
+ const key = resolveApiKey(config, flags.env || process.env)
2589
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
2590
+
2591
+ if (transport === 'mcp') {
2592
+ if (flags.json) {
2593
+ out.write(
2594
+ JSON.stringify({ transport: 'mcp', reason: key.ok ? 'requested' : key.error, users: null }, null, 2) + '\n',
2595
+ )
2596
+ return 0
2597
+ }
2598
+ out.write(
2599
+ [
2600
+ 'spec-sync users: transport = mcp',
2601
+ ` ${key.ok ? '--via mcp was requested' : key.error}`,
2602
+ ' search the workspace over MCP with the user-list tool',
2603
+ ].join('\n') + '\n',
2604
+ )
2605
+ return 0
2606
+ }
2607
+ if (!key.ok) {
2608
+ out.write(`spec-sync users: refusing — ${key.error}\n`)
2609
+ return 1
2610
+ }
2611
+
2612
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
2613
+ let page
2614
+ try {
2615
+ page = await adapter.searchUsers(query || '', { limit: flags.limit || 50, cursor: flags.cursor || null })
2616
+ } catch (error) {
2617
+ out.write(`spec-sync users: ${error.message}\n`)
2618
+ return 1
2619
+ }
2620
+
2621
+ const users = (page && page.users) || []
2622
+ if (flags.json) {
2623
+ out.write(
2624
+ JSON.stringify({ transport: 'api', users, nextCursor: (page && page.nextCursor) || null }, null, 2) + '\n',
2625
+ )
2626
+ return 0
2627
+ }
2628
+ if (!users.length) {
2629
+ out.write(`spec-sync users: no match for ${JSON.stringify(query || '')}\n`)
2630
+ return 0
2631
+ }
2632
+ const lines = [`spec-sync users: ${users.length} match(es)`]
2633
+ for (const u of users) {
2634
+ // A deactivated user still resolves by id and can still be assigned, so the
2635
+ // marker is the difference between a valid pick and a silently dead one.
2636
+ const inactive = u && u.active === false ? ' [deactivated]' : ''
2637
+ const email = u && u.email ? ` ${u.email}` : ''
2638
+ lines.push(` ${displayNameOf(u) || '(unnamed)'} <${u && u.id}>${email}${inactive}`)
2639
+ }
2640
+ if (page && page.nextCursor) lines.push(` … more — re-run with --cursor ${page.nextCursor}`)
2641
+ out.write(lines.join('\n') + '\n')
2642
+ return 0
2643
+ }
2644
+
1844
2645
  /**
1845
2646
  * `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
1846
2647
  * `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
@@ -1886,7 +2687,7 @@ async function specSyncApply(dir, config, specArg, flags, out) {
1886
2687
  if (!flags.plan) {
1887
2688
  out.write(
1888
2689
  'spec-sync apply: refusing to run without --plan <file>.\n' +
1889
- ' Get one with: skitterspec spec-sync push <spec> --json > plan.json\n' +
2690
+ ' Get one with: skitterspec spec-sync plan <spec> --json > plan.json\n' +
1890
2691
  ' Or apply a whole bucket at once with --all <bucket>.\n',
1891
2692
  )
1892
2693
  return 1
@@ -2136,6 +2937,12 @@ function specSyncInitConfig(dir, flags, out) {
2136
2937
  // shape — a blank key or a duplicate fails there, in one place, rather than
2137
2938
  // being re-checked here and drifting from the loader.
2138
2939
  if (flags.stages.length) draft.release = { stages: flags.stages }
2940
+ // Assignment is opt-in and rides `fieldOwnership` rather than a key of its
2941
+ // own. Only `assignee` is written: the loader merges this map PER KEY onto the
2942
+ // defaults, so restating the other three would freeze today's defaults into
2943
+ // the file and quietly opt the repo out of any later change to them — the very
2944
+ // thing "only the keys that differ" exists to avoid.
2945
+ if (flags.assign) draft.sync = { fieldOwnership: { assignee: 'push' } }
2139
2946
 
2140
2947
  for (const bucket of Object.keys(flags.stateNames)) {
2141
2948
  if (!LIFECYCLE_BUCKETS.includes(bucket)) {
@@ -2492,7 +3299,7 @@ async function specSync(rest, io = {}) {
2492
3299
  // after the loop.
2493
3300
  const unknownFlags = []
2494
3301
  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,
2495
- mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null, stages: [] }
3302
+ mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, stateArgs: [], statesFile: null, stages: [], limit: null, next: null, archived: false, assign: false, mine: false, by: null, inProgress: false }
2496
3303
  for (let i = 0; i < args.length; i++) {
2497
3304
  if (args[i] === '--dir') dir = path.resolve(args[++i])
2498
3305
  else if (args[i] === '--json') flags.json = true
@@ -2504,7 +3311,16 @@ async function specSync(rest, io = {}) {
2504
3311
  else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
2505
3312
  else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
2506
3313
  else if (args[i] === '--via') flags.via = args[++i]
2507
- else if (args[i] === '--all') flags.all = args[++i]
3314
+ // `apply --all <bucket>` takes a value; `list --all` is a bare boolean. Read
3315
+ // off the subcommand rather than guessing from the next token — `list --all
3316
+ // --json` would otherwise swallow `--json` as the bucket and silently drop it.
3317
+ else if (args[i] === '--all') flags.all = sub === 'list' ? true : args[++i]
3318
+ else if (args[i] === '--archived') flags.archived = true
3319
+ else if (args[i] === '--limit') flags.limit = Number(args[++i])
3320
+ else if (args[i] === '--next') flags.next = Number(args[++i])
3321
+ else if (args[i] === '--mine') flags.mine = true
3322
+ else if (args[i] === '--by') flags.by = args[++i]
3323
+ else if (args[i] === '--in-progress') flags.inProgress = true
2508
3324
  else if (args[i] === '--project') flags.project = args[++i]
2509
3325
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
2510
3326
  else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
@@ -2515,6 +3331,17 @@ async function specSync(rest, io = {}) {
2515
3331
  else if (args[i] === '--yes') flags.yes = true
2516
3332
  else if (args[i] === '--check-remote') flags.remoteCheck = true
2517
3333
  else if (args[i] === '--stdin') flags.stdin = true
3334
+ // `whoami --set <id> --name <n>`. Unlike `--key`, a user id and a name are
3335
+ // NOT secrets — they appear on every issue in Linear — so passing them as
3336
+ // arguments is safe and there is no hidden-prompt equivalent to reach for.
3337
+ else if (args[i] === '--set') flags.set = String(args[++i] || '').trim()
3338
+ else if (args[i] === '--name') flags.name = String(args[++i] || '').trim()
3339
+ else if (args[i] === '--unset') flags.unset = true
3340
+ else if (args[i] === '--to') flags.to = String(args[++i] || '').trim()
3341
+ else if (args[i] === '--release') flags.release = true
3342
+ else if (args[i] === '--assign') flags.assign = true
3343
+ else if (args[i] === '--limit') flags.limit = Number(args[++i]) || 0
3344
+ else if (args[i] === '--cursor') flags.cursor = String(args[++i] || '').trim()
2518
3345
  else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
2519
3346
  else if (args[i] === '--key') {
2520
3347
  // Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
@@ -2531,10 +3358,15 @@ async function specSync(rest, io = {}) {
2531
3358
  else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
2532
3359
  else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
2533
3360
  else if (args[i] === '--state') {
2534
- // `--state complete=Shipped`, repeatable. Only the buckets a workspace
2535
- // actually renamed get written; the rest keep the defaults.
2536
- const [bucket, ...rest] = String(args[++i] || '').split('=')
3361
+ // Two readings of one flag, kept apart rather than overloaded:
3362
+ // `init-config --state complete=Shipped` is a bucket=name PAIR, while
3363
+ // `list --state "In Progress"` is a bare Linear state NAME. Both are
3364
+ // recorded — `stateNames` for the former, the raw value in `stateArgs`
3365
+ // for the latter — so neither command has to infer which it was given.
3366
+ const raw = String(args[++i] || '').trim()
3367
+ const [bucket, ...rest] = raw.split('=')
2537
3368
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
3369
+ if (raw) flags.stateArgs.push(raw)
2538
3370
  } else if (args[i] === '--stage') {
2539
3371
  // `--stage test="On Test"`, repeatable. Order matters and is the order
2540
3372
  // given, so the ladder is written exactly as the operator listed it.
@@ -2603,8 +3435,20 @@ async function specSync(rest, io = {}) {
2603
3435
  switch (sub) {
2604
3436
  case 'normalize':
2605
3437
  return specSyncNormalize(dir, config, positional[0], out, err) || 0
2606
- case 'push':
3438
+ case 'plan':
2607
3439
  return specSyncPush(dir, config, positional[0], flags, out, err) || 0
3440
+ // RETIRED NAME, deliberately recognised rather than left to fall through to
3441
+ // the usage block. `push` named the one subcommand that writes nothing — it
3442
+ // computes a plan with no network access, while `apply` does the writing —
3443
+ // and it collided with both the /spec-push skill and `git push`. A silent
3444
+ // alias would keep the old name alive forever; a generic "unknown
3445
+ // subcommand" would throw away the one hint that makes the break cheap.
3446
+ case 'push':
3447
+ err.write(
3448
+ 'spec-sync push was renamed to spec-sync plan.\n' +
3449
+ 'It computes the create/update plan and writes nothing; `spec-sync apply` applies it.\n',
3450
+ )
3451
+ return 1
2608
3452
  case 'stamp':
2609
3453
  return specSyncStamp(dir, config, positional[0], flags, out)
2610
3454
  case 'record':
@@ -2613,8 +3457,16 @@ async function specSync(rest, io = {}) {
2613
3457
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
2614
3458
  case 'projects':
2615
3459
  return (await specSyncProjects(dir, config, flags, out)) || 0
3460
+ case 'list':
3461
+ return (await specSyncList(dir, config, flags, out)) || 0
2616
3462
  case 'states':
2617
3463
  return (await specSyncStates(dir, config, flags, out)) || 0
3464
+ case 'whoami':
3465
+ return (await specSyncWhoami(dir, config, flags, out)) || 0
3466
+ case 'assign':
3467
+ return specSyncAssign(dir, config, positional[0], flags, out)
3468
+ case 'users':
3469
+ return (await specSyncUsers(dir, config, positional[0], flags, out)) || 0
2618
3470
  case 'released':
2619
3471
  return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
2620
3472
  case 'stage':
@@ -2635,13 +3487,17 @@ async function specSync(rest, io = {}) {
2635
3487
  default:
2636
3488
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
2637
3489
  ' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
2638
- ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
3490
+ ' skitterspec spec-sync plan <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
2639
3491
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
2640
3492
  ' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
2641
3493
  ' skitterspec spec-sync projects [--via api|mcp] [--json]\n' +
3494
+ ' skitterspec spec-sync whoami [--set <id> [--name N]] [--unset] [--json]\n' +
3495
+ ' skitterspec spec-sync users [<name-or-email>] [--limit N] [--cursor C] [--json]\n' +
3496
+ ' skitterspec spec-sync assign <spec> --to <id> [--name N] | --release [--json]\n' +
2642
3497
  ' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
2643
3498
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
2644
3499
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
3500
+ ' skitterspec spec-sync list [--state <name> …|--all|--in-progress] [--next N] [--mine|--by <user>] [--limit N] [--archived] [--json]\n' +
2645
3501
  ' skitterspec spec-sync linked [--json]\n' +
2646
3502
  ' skitterspec spec-sync ref [<spec>] [--json]\n' +
2647
3503
  ' skitterspec spec-sync released [<range>] [--json]\n' +
@@ -2650,6 +3506,7 @@ async function specSync(rest, io = {}) {
2650
3506
  ' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
2651
3507
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
2652
3508
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
3509
+ ' [--assign]\n' +
2653
3510
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
2654
3511
  return 0
2655
3512
  }