@skitterbyte/skitterspec-linear 10.4.0 → 10.5.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.
@@ -42,11 +42,16 @@ const {
42
42
  stampSubIssueId,
43
43
  listPhaseFiles,
44
44
  compareStored,
45
+ planRetarget,
46
+ applyRetarget,
47
+ deriveRecordedKey,
48
+ isEmptyRetarget,
49
+ dirtyPaths,
45
50
  } = require('../sync-core')
46
51
 
47
52
  const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
48
53
  const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
49
- const { scanDrift, isClean, fileCount, dirtyPaths, repairDrift } = require('./doctor.js')
54
+ const { runChecks } = require('./doctor.js')
50
55
  const {
51
56
  storePath,
52
57
  storeMode,
@@ -580,157 +585,370 @@ function verifyLines(snapshotDir, config, stored, identifier) {
580
585
  }
581
586
 
582
587
  /**
583
- * `spec-sync doctor [--json]` — identifier drift against the team's CURRENT key.
588
+ * `spec-sync doctor [--json]` — is this project set up, across every layer?
584
589
  *
585
- * A Linear team rename leaves every stamped identifier, the config `teamKey` and
586
- * every snapshot filename on the old prefix, and nothing noticed. This reports
587
- * it. Read-only: exit 0 whether or not it finds drift; non-zero only when it
588
- * could not look (no key, MCP, unreadable team).
590
+ * The scaffold, isolation, the tracker config and the key are each checked by a
591
+ * different command or by none, so nothing answered the whole question. This
592
+ * does, and every failing row names the command that fixes it.
589
593
  *
590
- * Two things it deliberately does NOT do:
594
+ * The report itself is `doctor.js`, which is pure — this half only GATHERS. It
595
+ * is the one place allowed to touch the filesystem for that, and it must not
596
+ * throw doing it: a doctor that dies on a malformed config is useless on exactly
597
+ * the repo that needs it. So every read is wrapped, and a parse failure becomes
598
+ * a `broken` row rather than a stack trace.
591
599
  *
592
- * 1. It does not trust `config.linear.teamKey` for the current key that value
593
- * is itself one of the things that goes stale, so trusting it would make the
594
- * drift invisible. `teamId` survives a rename; the key does not.
595
- * 2. It does not resolve refs with a bulk `team.issues` query. That connection
596
- * excludes archived issues AND caps at 250 per page, so the naive version
597
- * reports most of a healthy repo as missing — measured on a real workspace:
598
- * 328 issues, 149 archived, and the unpaginated default returns 179. Reading
599
- * each ref individually has no list to page and no archived flag to forget;
600
- * `issue(id:)` resolves an archived issue by identifier (also verified).
600
+ * Dispatched BEFORE the config load for the same reason (like `init-config`):
601
+ * the loader throws on malformed JSON and the dispatcher short-circuits when no
602
+ * config exists, so a doctor sitting after it could never report the two states
603
+ * it most needs to.
601
604
  */
602
- async function specSyncDoctor(dir, config, flags, out) {
605
+ async function specSyncDoctor(dir, flags, out) {
606
+ const state = gatherState(dir, flags)
607
+ if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
608
+
609
+ const report = runChecks(state)
610
+
611
+ if (flags.json) {
612
+ out.write(JSON.stringify(report, null, 2) + '\n')
613
+ return report.ok ? 0 : 1
614
+ }
615
+
616
+ const width = Math.max(...report.checks.map((c) => c.label.length))
617
+ const lines = ['skitterspec doctor:']
618
+ for (const c of report.checks) {
619
+ lines.push(` ${c.label.padEnd(width)} ${c.state.padEnd(8)} ${c.detail}`)
620
+ // The fix sits under the row it belongs to, indented past the state column,
621
+ // so a wall of rows still reads as "this one, and here is the command".
622
+ if (c.fix) lines.push(` ${' '.repeat(width)} ${' '.repeat(8)} → ${c.fix}`)
623
+ }
624
+ // Everything not `ok`/`skipped` needs a human's attention — including a
625
+ // `missing` one, which is why the count and the EXIT CODE differ: a declined
626
+ // opt-in is worth reporting but must not fail the run (see `runChecks`).
627
+ const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
628
+ lines.push('')
629
+ lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
630
+
631
+ out.write(lines.join('\n') + '\n')
632
+ return report.ok ? 0 : 1
633
+ }
634
+
635
+ /**
636
+ * The live half of the report: one `team(id:)` call proving the id resolves and
637
+ * the key is accepted. Well-formed config is not working config.
638
+ *
639
+ * Opt-in because it is the only part that needs the network — the offline checks
640
+ * have to stay usable with no connectivity, which is exactly when a setup
641
+ * problem is most annoying to diagnose.
642
+ *
643
+ * **No API message is ever relayed.** A GraphQL error body can echo the request
644
+ * back, and this is a command a skill prints; so a failure is CLASSIFIED into a
645
+ * short reason of our own words.
646
+ */
647
+ async function checkRemote(state, flags) {
648
+ if (!state.tracker.parsed || !state.tracker.teamId) {
649
+ return { checked: true, skipped: true, reason: 'no usable tracker config to check against' }
650
+ }
651
+ if (!state.key.ok) {
652
+ return { checked: true, skipped: true, reason: 'no key, so there is nothing to check with' }
653
+ }
654
+
655
+ const key = resolveApiKey(state._config, flags.env || process.env)
656
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
657
+ let team
658
+ try {
659
+ team = await adapter.readTeam(state.tracker.teamId)
660
+ } catch (error) {
661
+ const failure = classifyRemoteFailure(error)
662
+ // NEVER ANSWERED is not the same as ANSWERED NO. An unreachable API or a
663
+ // rate-limit means the check did not run — the setup is unexamined, not
664
+ // wrong — so it reports `skipped`, the same state as "you didn't ask for
665
+ // it". Calling it `broken` exited 1 on a healthy project that merely had no
666
+ // network, and every skill branching on that code failed with it.
667
+ if (failure.reached === false) return { checked: true, skipped: true, reason: failure.reason }
668
+ return { checked: true, ok: false, ...failure }
669
+ }
670
+ if (!team || !team.key) {
671
+ return {
672
+ checked: true,
673
+ ok: false,
674
+ reason: 'the key was accepted, but no team has that id',
675
+ fix: '/spec-linear-setup',
676
+ }
677
+ }
678
+ return { checked: true, ok: true, teamKey: team.key, recordedKey: state.tracker.teamKey }
679
+ }
680
+
681
+ // Map a thrown API error onto our own short reason. Matched on the shapes
682
+ // `api.js` raises; anything unrecognised degrades to a generic line rather than
683
+ // leaking the message.
684
+ //
685
+ // BLIND SPOT: matching on message text, so an api.js rewording lands here as the
686
+ // unrecognised case. That is why the fallback stays `broken` — Linear ANSWERED
687
+ // and refused, which is evidence of a problem even when we cannot name it. Only
688
+ // a request that got no answer at all (`reached: false`) is un-evidence.
689
+ function classifyRemoteFailure(error) {
690
+ const m = String((error && error.message) || '')
691
+ if (/rejected the API key|HTTP 401|HTTP 403/.test(m)) {
692
+ return { reason: 'Linear rejected the key — it may be revoked or for another workspace', fix: 'skitterspec spec-sync credentials set' }
693
+ }
694
+ // `reached: false` — the request never got an answer, so it says nothing about
695
+ // whether this project is set up correctly. See the caller.
696
+ if (/unreachable/.test(m)) {
697
+ return { reason: 'Linear could not be reached — check your connection', fix: null, reached: false }
698
+ }
699
+ if (/rate-limited/.test(m)) {
700
+ return { reason: 'Linear rate-limited the request and did not recover', fix: null, reached: false }
701
+ }
702
+ if (/Entity not found|not found/i.test(m)) {
703
+ return { reason: 'no team with that id in this workspace', fix: '/spec-linear-setup' }
704
+ }
705
+ return { reason: 'Linear did not accept the request', fix: null }
706
+ }
707
+
708
+ // Read the project's real state for `runChecks`. Never throws: every probe that
709
+ // can fail reports the failure as data.
710
+ function gatherState(dir, flags) {
711
+ const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, remote: { checked: false } }
712
+
713
+ const specs = path.join(dir, 'specs')
714
+ state.scaffold.specsDir = fs.existsSync(specs)
715
+ if (state.scaffold.specsDir) {
716
+ state.scaffold.core = fs.existsSync(path.join(specs, '.core'))
717
+ // Reported for context only — a missing bucket is normal (see scaffoldCheck).
718
+ state.scaffold.buckets = BUCKETS.filter((b) => fs.existsSync(path.join(specs, b)))
719
+ state.scaffold.skills = countSkills(dir)
720
+ }
721
+
722
+ const envFile = path.join(dir, 'specs', '.core', 'env.config.json')
723
+ state.isolation.present = fs.existsSync(envFile)
724
+ if (state.isolation.present) {
725
+ try {
726
+ JSON.parse(fs.readFileSync(envFile, 'utf-8'))
727
+ state.isolation.parsed = true
728
+ } catch (error) {
729
+ state.isolation.parsed = false
730
+ state.isolation.error = error.message
731
+ }
732
+ }
733
+
734
+ state.tracker.present = fs.existsSync(path.join(dir, CONFIG_FILE))
735
+ let config = null
736
+ if (state.tracker.present) {
737
+ try {
738
+ config = loadLinearConfig(dir).config
739
+ state.tracker.parsed = true
740
+ state.tracker.teamId = (config.linear && config.linear.teamId) || ''
741
+ state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
742
+ } catch (error) {
743
+ state.tracker.parsed = false
744
+ state.tracker.error = error.message
745
+ }
746
+ }
747
+
748
+ // Without a parsed config there is no `auth.keyEnv` and no team to key on, so
749
+ // there is nothing to look up — the key row reports as skipped instead.
750
+ if (config) {
751
+ const resolved = resolveApiKey(config, flags.env || process.env)
752
+ // A key resolves from the environment, the store, or a `keyCommand` the
753
+ // store runs — `resolveApiKey` covers all three, so the row must not be read
754
+ // as "env var unset".
755
+ //
756
+ // When it does NOT resolve, `resolveApiKey` appends the reason on later
757
+ // lines: a store that is world-readable, or a keyCommand that failed.
758
+ // Dropping it reported a broken command as `no key for SKS` and sent the
759
+ // user to set a key they had already set (`credentials status` keeps it for
760
+ // the same reason).
761
+ const why = resolved.ok ? '' : resolved.error.split('\n').slice(1).map((l) => l.trim()).filter(Boolean).join('; ')
762
+ state.key = resolved.ok
763
+ ? { ok: true, source: resolved.source === 'env' ? `the environment (${resolved.envVar})` : resolved.source, fingerprint: fingerprint(resolved.key) }
764
+ : { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
765
+ }
766
+
767
+ state._config = config
768
+ return state
769
+ }
770
+
771
+ // How many skills are installed in the target project.
772
+ function countSkills(dir) {
773
+ const skills = path.join(dir, '.claude', 'skills')
774
+ try {
775
+ return fs
776
+ .readdirSync(skills, { withFileTypes: true })
777
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(skills, e.name, 'SKILL.md')))
778
+ .length
779
+ } catch {
780
+ return 0
781
+ }
782
+ }
783
+
784
+ /**
785
+ * `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
786
+ *
787
+ * Renaming a Linear team rewrites the key in every issue identifier, and the
788
+ * repo stamps those in three places. Nothing moved them, so afterwards
789
+ * `/spec-push` fails with `no Linear issue found for SKI-7` — the right failure,
790
+ * with no way out but a hand rewrite (done twice by hand on 2026-09-02).
791
+ *
792
+ * The rewrite itself is provider-neutral and lives in `sync-core/retarget.js`.
793
+ * Only two things here touch Linear:
794
+ *
795
+ * 1. **Detection.** `teamId` survives a rename, so ask Linear for that team's
796
+ * CURRENT key and compare it with the recorded one. A difference IS the
797
+ * rename. It is never taken as an argument: nothing stops a typo rewriting
798
+ * every stamp to a key that does not exist.
799
+ * 2. **One spot-check.** Resolve the first remapped identifier and compare its
800
+ * TITLE to the spec's. That is what makes the number-preservation assumption
801
+ * safe, and it tests identity rather than mere existence — `SKS-7` existing
802
+ * does not make it the issue that was `SKI-7`. One read, which is also what
803
+ * keeps the MCP path viable.
804
+ */
805
+ async function specSyncRetarget(dir, config, flags, out) {
806
+ const teamId = (config.linear && config.linear.teamId) || ''
807
+ if (!teamId) {
808
+ out.write('spec-sync retarget: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
809
+ return 1
810
+ }
811
+
812
+ const recorded = deriveRecordedKey(dir, config)
813
+ if (!recorded.key) {
814
+ out.write(`spec-sync retarget: cannot tell which key this repo is stamped with.\n ${recorded.reason}\n`)
815
+ return 1
816
+ }
817
+
603
818
  const key = resolveApiKey(config, flags.env || process.env)
604
819
  const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
605
820
 
606
- // One read PER DRIFTED REF is the whole design; over MCP that is a model
607
- // round-trip each, which is impractical at the scale this exists for (198 refs
608
- // in the run that motivated it). Refused rather than quietly slow the same
609
- // call `apply --all` makes.
821
+ // Linear's MCP `get_team` does not return the team key (observed 2026-09-02),
822
+ // so the one fact detection needs is unavailable there. Report what IS known
823
+ // and let the operator supply it, rather than guessing or silently skipping.
610
824
  if (transport === 'mcp') {
611
825
  out.write(
612
826
  [
613
- 'spec-sync doctor: needs the api transport (nothing was read)',
827
+ 'spec-sync retarget: transport = mcp — cannot read the team key (nothing was changed)',
614
828
  ` ${key.ok ? '--via mcp was requested' : key.error}`,
615
- ' it reads one issue per drifted ref, which MCP would route through the model.',
829
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
830
+ " Linear's MCP get_team does not return a team key, so a rename cannot be detected here.",
831
+ ' Confirm the current key in Linear, then set an API key and re-run for the plan.',
616
832
  ].join('\n') + '\n',
617
833
  )
618
834
  return 1
619
835
  }
620
836
 
621
- const teamId = (config.linear && config.linear.teamId) || ''
622
- if (!teamId) {
623
- out.write('spec-sync doctor: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
624
- return 1
625
- }
626
-
627
837
  const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
628
838
  let team
629
839
  try {
630
840
  team = await adapter.readTeam(teamId)
631
841
  } catch (error) {
632
- out.write(`spec-sync doctor: could not read the team: ${error.message}\n`)
842
+ out.write(`spec-sync retarget: could not read the team: ${error.message}\n`)
633
843
  return 1
634
844
  }
635
845
  if (!team || !team.key) {
636
- out.write(`spec-sync doctor: Linear returned no team for ${teamId}\n`)
846
+ out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
637
847
  return 1
638
848
  }
639
849
 
640
- const drift = scanDrift(dir, config, team.key)
641
-
642
- // Only the DISTINCT drifted identifiers are checked, so 221 stamps of 198
643
- // identifiers cost 198 reads.
644
- const missing = []
645
- for (const ref of drift.refs) {
646
- let got
647
- try {
648
- got = await adapter.readIssue(ref.to)
649
- } catch (error) {
650
- out.write(`spec-sync doctor: could not read ${ref.to}: ${error.message}\n`)
651
- return 1
652
- }
653
- if (!got) missing.push(ref)
654
- }
850
+ const header = [
851
+ `spec-sync retarget: team ${teamId} (${team.name || team.key})`,
852
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
853
+ ` linear key: ${team.key}${team.key === recorded.key ? '' : ' <- renamed'}`,
854
+ ]
655
855
 
656
- if (flags.json) {
657
- out.write(JSON.stringify({ team: { id: team.id, key: team.key, name: team.name }, ...drift, missing }, null, 2) + '\n')
856
+ if (team.key === recorded.key) {
857
+ out.write([...header, '', ' already current nothing to retarget'].join('\n') + '\n')
658
858
  return 0
659
859
  }
660
860
 
661
- const was = [...new Set(drift.refs.map((r) => r.from.split('-')[0]))]
662
- const lines = [`spec-sync doctor: team ${team.key}${was.length ? ` (stamps still on ${was.join(', ')})` : ''}`]
663
- if (isClean(drift)) {
664
- lines.push(' drift: none — every stamped identifier is on the current team key')
665
- out.write(lines.join('\n') + '\n')
861
+ const plan = planRetarget({ dir, oldKey: recorded.key, newKey: team.key, config })
862
+ if (isEmptyRetarget(plan)) {
863
+ out.write([...header, '', ' the key moved, but nothing in the repo is stamped with it'].join('\n') + '\n')
666
864
  return 0
667
865
  }
668
866
 
669
- const driftLines = []
670
- const stamped = drift.stamps.length + drift.urls.length
671
- if (stamped) driftLines.push(`${stamped} stamp(s) across ${fileCount(drift)} file(s), ${drift.refs.length} distinct ref(s)`)
672
- if (drift.snapshots.length || drift.snapshotKeys.length) {
673
- const parts = []
674
- if (drift.snapshots.length) parts.push(`${drift.snapshots.length} filename(s)`)
675
- if (drift.snapshotKeys.length) parts.push(`${drift.snapshotKeys.length} sub-issue key(s)`)
676
- driftLines.push(`snapshots: ${parts.join(' + ')} under ${config.sync.baseDir}`)
677
- }
678
- if (drift.config) driftLines.push(`config linear.teamKey = "${drift.config.from}"`)
679
- lines.push(` drift: ${driftLines[0]}`)
680
- for (const l of driftLines.slice(1)) lines.push(` ${l}`)
681
-
682
- // Reported SEPARATELY from drift: a ref that resolves is repairable, one that
683
- // does not is a different problem and must not be silently rewritten.
684
- // Prose mentions are NOT repaired, so they are reported outside the drift
685
- // block — a report that folded them in would imply --write fixes them.
686
- if (drift.mentions.length) {
687
- lines.push(
688
- ` mentions: ${drift.mentions.length} stale ref(s) in spec prose — reported, NOT repaired by --write`,
689
- )
867
+ // Spot-check BEFORE reporting the plan as safe: a wrong mapping is caught
868
+ // here, not after the files have moved.
869
+ const check = await spotCheck({ dir, config, plan, adapter, oldKey: recorded.key, newKey: team.key })
870
+ const lines = [
871
+ ...header,
872
+ '',
873
+ ' would rewrite:',
874
+ ` ${plan.stamps.length} frontmatter stamp(s) (linear_identifier, linear_url, linear_issue_id)`,
875
+ ` ${plan.snapshots.length} base snapshot(s) (rename + re-key subIssues)`,
876
+ ` ${plan.configKey ? 1 : 0} config key (linear.teamKey)`,
877
+ '',
878
+ ` spot-check: ${check.line}`,
879
+ ]
880
+
881
+ if (!check.ok) {
882
+ lines.push(' refusing the mapping is not safe to apply')
883
+ out.write(lines.join('\n') + '\n')
884
+ return 1
690
885
  }
691
- lines.push(` missing: ${missing.length} ref(s) that resolve to no issue under ${team.key}`)
692
- for (const m of missing.slice(0, 10)) lines.push(` ${m.from} → ${m.to} does not exist`)
693
- if (missing.length > 10) lines.push(` … and ${missing.length - 10} more`)
694
886
 
695
- if (!flags.write) {
696
- lines.push(' run with --write to repair (requires a clean git tree)')
887
+ if (!flags.yes) {
888
+ lines.push(' dry-run — re-run with --yes to apply.')
697
889
  out.write(lines.join('\n') + '\n')
698
890
  return 0
699
891
  }
700
892
 
701
- // A repair rewrites hundreds of stamps across dozens of files. That is only
702
- // safe to hand someone if it arrives as ONE reviewable diff they can throw
703
- // away with `git checkout -- .` — which a dirty tree destroys. Same guard
704
- // `spec-env integrate` uses, and for the same reason.
705
893
  const dirty = dirtyPaths(dir)
706
894
  if (dirty === null) {
707
- lines.push(' --write refused: not a git repository, so the rewrite would not be reviewable')
895
+ lines.push(' --yes refused: not a git repository, so the rewrite would not be reviewable')
708
896
  out.write(lines.join('\n') + '\n')
709
897
  return 1
710
898
  }
711
899
  if (dirty.length) {
712
- lines.push(` --write refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
900
+ lines.push(` --yes refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
713
901
  for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
714
902
  if (dirty.length > 10) lines.push(` … and ${dirty.length - 10} more`)
715
- lines.push(' the repair is one large diff; it must be reviewable on its own')
903
+ lines.push(' the rewrite must land as one reviewable, revertable change')
716
904
  out.write(lines.join('\n') + '\n')
717
905
  return 1
718
906
  }
719
907
 
720
- const skip = new Set(missing.map((m) => m.from))
721
- const changed = repairDrift(dir, config, drift, { skip })
722
- lines.push(' repaired:')
723
- lines.push(` ${changed.files.length} spec file(s)`)
724
- lines.push(` ${changed.snapshots.length} snapshot file(s) moved`)
725
- if (changed.config) lines.push(' config linear.teamKey')
726
- if (changed.skipped) {
727
- lines.push(` ${changed.skipped} ref(s) LEFT ALONE they resolve to no issue under ${team.key}`)
728
- }
729
- lines.push(' review the diff, then commit it')
908
+ const changed = applyRetarget(plan, { dir, config })
909
+ lines.push(
910
+ ' applied:',
911
+ ` ${changed.files.length} spec file(s)`,
912
+ ` ${changed.snapshots.length} snapshot(s) moved`,
913
+ ...(changed.configKey ? [' config linear.teamKey'] : []),
914
+ " nothing was pushed — only the repo's stamps moved; the mirror is untouched.",
915
+ ' review the diff, then commit it.',
916
+ )
730
917
  out.write(lines.join('\n') + '\n')
731
- // Non-zero when anything was left behind, so a caller cannot read a partial
732
- // repair as a complete one.
733
- return changed.skipped ? 1 : 0
918
+ return 0
919
+ }
920
+
921
+ /**
922
+ * Resolve the first remapped SPEC issue and compare its title to the spec's.
923
+ *
924
+ * Deliberately ONE read. Checking every ref instead would cost a request each
925
+ * (~198 on a real repo), force an MCP refusal, and still only prove the issues
926
+ * exist — not that they are the same issues.
927
+ */
928
+ async function spotCheck({ dir, config, plan, adapter, oldKey, newKey }) {
929
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
930
+ const stamp = plan.stamps.find((s) => path.basename(s.file) === overviewFile)
931
+ if (!stamp) return { ok: true, line: 'skipped — no spec issue in the plan to check' }
932
+
933
+ const before = parseFrontmatter(stamp.from).data.linear_identifier
934
+ if (!before) return { ok: true, line: 'skipped — no linear_identifier in the plan to check' }
935
+ const from = String(before).trim()
936
+ const to = `${newKey}-${from.slice(oldKey.length + 1)}`
937
+
938
+ let issue
939
+ try {
940
+ issue = await adapter.readIssue(to)
941
+ } catch (error) {
942
+ return { ok: false, line: `${to} could not be read — ${error.message}` }
943
+ }
944
+ if (!issue) return { ok: false, line: `${from} → ${to}, but ${to} does not exist in Linear` }
945
+
946
+ const expected = readSnapshot(path.dirname(path.join(dir, stamp.file)), config).title
947
+ const got = issue.title || ''
948
+ if (expected && got && expected.trim() !== got.trim()) {
949
+ return { ok: false, line: `${to} resolves, but its title is "${got}" — the spec's is "${expected}"` }
950
+ }
951
+ return { ok: true, line: `${to} resolves, title matches the spec` }
734
952
  }
735
953
 
736
954
  /**
@@ -1582,8 +1800,12 @@ async function specSync(rest, io = {}) {
1582
1800
  const [sub, ...args] = rest
1583
1801
  let dir = io.cwd || process.cwd()
1584
1802
  const positional = []
1803
+ // Anything `--`-prefixed that no branch below consumed. Collected rather than
1804
+ // pushed onto `positional`, where it was silently discarded — see the refusal
1805
+ // after the loop.
1806
+ const unknownFlags = []
1585
1807
  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,
1586
- force: false, write: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1808
+ force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1587
1809
  for (let i = 0; i < args.length; i++) {
1588
1810
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1589
1811
  else if (args[i] === '--json') flags.json = true
@@ -1599,7 +1821,8 @@ async function specSync(rest, io = {}) {
1599
1821
  else if (args[i] === '--url') flags.url = args[++i]
1600
1822
  else if (args[i] === '--sub') flags.subs.push(args[++i])
1601
1823
  else if (args[i] === '--force') flags.force = true
1602
- else if (args[i] === '--write') flags.write = true
1824
+ else if (args[i] === '--yes') flags.yes = true
1825
+ else if (args[i] === '--check-remote') flags.remoteCheck = true
1603
1826
  else if (args[i] === '--stdin') flags.stdin = true
1604
1827
  else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
1605
1828
  else if (args[i] === '--key') {
@@ -1622,8 +1845,34 @@ async function specSync(rest, io = {}) {
1622
1845
  const [bucket, ...rest] = String(args[++i] || '').split('=')
1623
1846
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
1624
1847
  } else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
1848
+ else if (args[i].startsWith('--')) unknownFlags.push(args[i])
1625
1849
  else positional.push(args[i])
1626
1850
  }
1851
+ // REFUSE AN UNKNOWN FLAG, before anything runs.
1852
+ //
1853
+ // These used to land in `positional` and vanish. That is merely untidy for a
1854
+ // typo, but it turned a RENAMED flag into a silent no-op: `--write` moved to
1855
+ // `--yes` when `doctor` became `retarget`, so `spec-sync doctor --write` — the
1856
+ // exact 10.4.0 invocation for repairing a renamed team — parsed, ran the
1857
+ // readiness report instead, ignored the flag and exited 0. A script would read
1858
+ // that as "repaired".
1859
+ //
1860
+ // A spec name never starts with `--`, so this cannot swallow a real argument.
1861
+ if (unknownFlags.length) {
1862
+ const lines = [`spec-sync: unknown flag ${unknownFlags.join(', ')}`]
1863
+ // Renamed flags get a specific hand-off; a bare "unknown flag" would leave
1864
+ // the caller to guess what replaced it.
1865
+ if (unknownFlags.includes('--write')) {
1866
+ lines.push(
1867
+ ' --write was replaced by --yes, and the command that repairs a renamed',
1868
+ ' team is now `spec-sync retarget --yes` (it was `doctor --write`).',
1869
+ )
1870
+ }
1871
+ lines.push(' run `skitterspec spec-sync` for the full usage.')
1872
+ out.write(lines.join('\n') + '\n')
1873
+ return 1
1874
+ }
1875
+
1627
1876
  dir = path.resolve(dir)
1628
1877
  // Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
1629
1878
  // `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
@@ -1637,6 +1886,11 @@ async function specSync(rest, io = {}) {
1637
1886
  // malformed enough for the loader to throw on.
1638
1887
  if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
1639
1888
 
1889
+ // Same reason as init-config: this is the command you run when the config is
1890
+ // missing or malformed, so it must not sit behind a loader that throws on the
1891
+ // one and short-circuits on the other.
1892
+ if (sub === 'doctor') return await specSyncDoctor(dir, flags, out)
1893
+
1640
1894
  const { config, present } = loadLinearConfig(dir)
1641
1895
  if (!present) {
1642
1896
  out.write(
@@ -1661,8 +1915,8 @@ async function specSync(rest, io = {}) {
1661
1915
  return (await specSyncProjects(dir, config, flags, out)) || 0
1662
1916
  case 'states':
1663
1917
  return (await specSyncStates(dir, config, flags, out)) || 0
1664
- case 'doctor':
1665
- return (await specSyncDoctor(dir, config, flags, out)) || 0
1918
+ case 'retarget':
1919
+ return (await specSyncRetarget(dir, config, flags, out)) || 0
1666
1920
  case 'apply':
1667
1921
  return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
1668
1922
  case 'verify':
@@ -1683,7 +1937,8 @@ async function specSync(rest, io = {}) {
1683
1937
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
1684
1938
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
1685
1939
  ' skitterspec spec-sync linked [--json]\n' +
1686
- ' skitterspec spec-sync doctor [--write] [--json]\n' +
1940
+ ' skitterspec spec-sync retarget [--yes]\n' +
1941
+ ' skitterspec spec-sync doctor [--check-remote] [--json]\n' +
1687
1942
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1688
1943
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1689
1944
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')