@skitterbyte/skitterspec-linear 12.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 (55) hide show
  1. package/MIGRATION.md +208 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -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 +6 -3
  8. package/assets/core/env.config.md +77 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/review/page.html +1501 -0
  11. package/assets/rules/spec-planning.md +198 -10
  12. package/assets/rules/spec-reports.md +269 -0
  13. package/assets/skills/spec/SKILL.md +33 -5
  14. package/assets/skills/spec-bug/SKILL.md +172 -9
  15. package/assets/skills/spec-cancel/SKILL.md +98 -21
  16. package/assets/skills/spec-claim/SKILL.md +114 -0
  17. package/assets/skills/spec-complete/SKILL.md +94 -25
  18. package/assets/skills/spec-diff/SKILL.md +564 -0
  19. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  20. package/assets/skills/spec-init/SKILL.md +34 -7
  21. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  22. package/assets/skills/spec-list/SKILL.md +218 -0
  23. package/assets/skills/spec-next/SKILL.md +299 -6
  24. package/assets/skills/spec-push/SKILL.md +32 -8
  25. package/assets/skills/spec-review/SKILL.md +40 -5
  26. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  27. package/assets/skills/spec-start/SKILL.md +386 -106
  28. package/assets/skills/spec-status/SKILL.md +24 -2
  29. package/assets/skills/spec-sync/SKILL.md +40 -4
  30. package/assets/skills/spec-to-main/SKILL.md +28 -6
  31. package/package.json +11 -7
  32. package/src/cli.js +1513 -89
  33. package/src/env/building.js +143 -0
  34. package/src/env/config.js +42 -9
  35. package/src/env/provision.js +54 -15
  36. package/src/env/proxy.js +34 -1
  37. package/src/env/render.js +3 -12
  38. package/src/env/resolve.js +295 -9
  39. package/src/env/review.js +1329 -0
  40. package/src/env/serve.js +549 -0
  41. package/src/env/teardown.js +13 -6
  42. package/src/init.js +96 -1
  43. package/src/vendor/linear/api.js +104 -1
  44. package/src/vendor/linear/cli-sync.js +854 -17
  45. package/src/vendor/linear/config.js +8 -0
  46. package/src/vendor/linear/credentials.js +94 -0
  47. package/src/vendor/linear/doctor.js +35 -0
  48. package/src/vendor/linear/identity.js +105 -0
  49. package/src/vendor/linear/mcp.js +26 -0
  50. package/src/vendor/sync-core/index.js +6 -2
  51. package/src/vendor/sync-core/src/compare.js +49 -3
  52. package/src/vendor/sync-core/src/normalize.js +30 -0
  53. package/src/vendor/sync-core/src/push.js +11 -1
  54. package/src/vendor/sync-core/src/write.js +38 -0
  55. 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))
@@ -333,7 +340,7 @@ function stateCheckFailure(config, flags) {
333
340
  if (flags.skipStateCheck) return null
334
341
  if (!flags.workspaceStates) {
335
342
  return [
336
- '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',
337
344
  ' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
338
345
  ' workflow-state names, which /spec-push fetches over MCP), or',
339
346
  ' --skip-state-check to push anyway.',
@@ -342,20 +349,20 @@ function stateCheckFailure(config, flags) {
342
349
  ]
343
350
  }
344
351
  if (!fs.existsSync(flags.workspaceStates)) {
345
- 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}`]
346
353
  }
347
354
  let names
348
355
  try {
349
356
  names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
350
357
  } catch (error) {
351
- 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}`]
352
359
  }
353
360
  const list = Array.isArray(names) ? names : []
354
361
  const missing = validateStates(config, list)
355
362
  if (missing.length) {
356
363
  // Say what IS available, and what to use instead. "Done is not a state" sends
357
364
  // you to the Linear UI to go and look; naming the replacement does not.
358
- 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', '']
359
366
  for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
360
367
  lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
361
368
  if (suggestion) lines.push(` use "${suggestion}" instead`)
@@ -454,6 +461,77 @@ function specSyncStamp(dir, config, specArg, flags, out) {
454
461
  return 0
455
462
  }
456
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
+
457
535
  // `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
458
536
  // files. The skill calls this AFTER applying the plan and stamping new ids.
459
537
  function specSyncRecord(dir, config, specArg, out) {
@@ -521,12 +599,54 @@ function specSyncStatus(dir, config, specArg, flags, out) {
521
599
  } else {
522
600
  lines.push(' drift: none — Linear workflow-state matches the spec')
523
601
  }
602
+ lines.push(...assigneeLines(remote, projection, plan))
524
603
  }
525
604
 
526
605
  out.write(lines.join('\n') + '\n')
527
606
  return 0
528
607
  }
529
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
+
530
650
  /**
531
651
  * `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
532
652
  * against what we sent, and report any lost text.
@@ -899,6 +1019,21 @@ function gatherState(dir, flags) {
899
1019
  : { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
900
1020
  }
901
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
+
902
1037
  state._config = config
903
1038
  // Declared here, checked only if `--check-remote` fetches the state TYPES the
904
1039
  // shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
@@ -1753,6 +1888,451 @@ async function specSyncProjects(dir, config, flags, out) {
1753
1888
  return 0
1754
1889
  }
1755
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
+
1756
2336
  /**
1757
2337
  * Apply one spec's plan. Returns what happened rather than printing it, so the
1758
2338
  * single-spec command and the bulk loop report in their own voices while sharing
@@ -1784,6 +2364,9 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1784
2364
  projectId: project || (config.linear && config.linear.projectId) || null,
1785
2365
  description: plan.issue && plan.issue.description,
1786
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,
1787
2370
  }))
1788
2371
  if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
1789
2372
  parentId = created.id
@@ -1797,10 +2380,18 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1797
2380
  parentId = existing.id
1798
2381
  result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
1799
2382
  if (plan.issue) {
1800
- await adapter.updateIssue(existing.id, withoutNull({
2383
+ const updates = withoutNull({
1801
2384
  description: plan.issue.description,
1802
2385
  stateId: stateId(plan.issue.state),
1803
- }))
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)
1804
2395
  lines.push(` issue updated: ${identifier}`)
1805
2396
  }
1806
2397
  }
@@ -1861,6 +2452,196 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
1861
2452
  return { result, lines, lost }
1862
2453
  }
1863
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
+
1864
2645
  /**
1865
2646
  * `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
1866
2647
  * `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
@@ -1906,7 +2687,7 @@ async function specSyncApply(dir, config, specArg, flags, out) {
1906
2687
  if (!flags.plan) {
1907
2688
  out.write(
1908
2689
  'spec-sync apply: refusing to run without --plan <file>.\n' +
1909
- ' 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' +
1910
2691
  ' Or apply a whole bucket at once with --all <bucket>.\n',
1911
2692
  )
1912
2693
  return 1
@@ -2156,6 +2937,12 @@ function specSyncInitConfig(dir, flags, out) {
2156
2937
  // shape — a blank key or a duplicate fails there, in one place, rather than
2157
2938
  // being re-checked here and drifting from the loader.
2158
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' } }
2159
2946
 
2160
2947
  for (const bucket of Object.keys(flags.stateNames)) {
2161
2948
  if (!LIFECYCLE_BUCKETS.includes(bucket)) {
@@ -2512,7 +3299,7 @@ async function specSync(rest, io = {}) {
2512
3299
  // after the loop.
2513
3300
  const unknownFlags = []
2514
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,
2515
- 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 }
2516
3303
  for (let i = 0; i < args.length; i++) {
2517
3304
  if (args[i] === '--dir') dir = path.resolve(args[++i])
2518
3305
  else if (args[i] === '--json') flags.json = true
@@ -2524,7 +3311,16 @@ async function specSync(rest, io = {}) {
2524
3311
  else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
2525
3312
  else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
2526
3313
  else if (args[i] === '--via') flags.via = args[++i]
2527
- 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
2528
3324
  else if (args[i] === '--project') flags.project = args[++i]
2529
3325
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
2530
3326
  else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
@@ -2535,6 +3331,17 @@ async function specSync(rest, io = {}) {
2535
3331
  else if (args[i] === '--yes') flags.yes = true
2536
3332
  else if (args[i] === '--check-remote') flags.remoteCheck = true
2537
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()
2538
3345
  else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
2539
3346
  else if (args[i] === '--key') {
2540
3347
  // Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
@@ -2551,10 +3358,15 @@ async function specSync(rest, io = {}) {
2551
3358
  else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
2552
3359
  else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
2553
3360
  else if (args[i] === '--state') {
2554
- // `--state complete=Shipped`, repeatable. Only the buckets a workspace
2555
- // actually renamed get written; the rest keep the defaults.
2556
- 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('=')
2557
3368
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
3369
+ if (raw) flags.stateArgs.push(raw)
2558
3370
  } else if (args[i] === '--stage') {
2559
3371
  // `--stage test="On Test"`, repeatable. Order matters and is the order
2560
3372
  // given, so the ladder is written exactly as the operator listed it.
@@ -2623,8 +3435,20 @@ async function specSync(rest, io = {}) {
2623
3435
  switch (sub) {
2624
3436
  case 'normalize':
2625
3437
  return specSyncNormalize(dir, config, positional[0], out, err) || 0
2626
- case 'push':
3438
+ case 'plan':
2627
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
2628
3452
  case 'stamp':
2629
3453
  return specSyncStamp(dir, config, positional[0], flags, out)
2630
3454
  case 'record':
@@ -2633,8 +3457,16 @@ async function specSync(rest, io = {}) {
2633
3457
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
2634
3458
  case 'projects':
2635
3459
  return (await specSyncProjects(dir, config, flags, out)) || 0
3460
+ case 'list':
3461
+ return (await specSyncList(dir, config, flags, out)) || 0
2636
3462
  case 'states':
2637
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
2638
3470
  case 'released':
2639
3471
  return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
2640
3472
  case 'stage':
@@ -2655,13 +3487,17 @@ async function specSync(rest, io = {}) {
2655
3487
  default:
2656
3488
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
2657
3489
  ' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
2658
- ' 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' +
2659
3491
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
2660
3492
  ' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
2661
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' +
2662
3497
  ' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
2663
3498
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
2664
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' +
2665
3501
  ' skitterspec spec-sync linked [--json]\n' +
2666
3502
  ' skitterspec spec-sync ref [<spec>] [--json]\n' +
2667
3503
  ' skitterspec spec-sync released [<range>] [--json]\n' +
@@ -2670,6 +3506,7 @@ async function specSync(rest, io = {}) {
2670
3506
  ' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
2671
3507
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
2672
3508
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
3509
+ ' [--assign]\n' +
2673
3510
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
2674
3511
  return 0
2675
3512
  }