@skitterbyte/skitterspec-linear 10.4.0 → 10.5.2

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,449 @@ 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.mcp) {
608
+ const read = readMcpFacts(flags.mcp)
609
+ if (read.error) {
610
+ out.write(`spec-sync doctor: cannot read --mcp ${flags.mcp}: ${read.error}\n`)
611
+ return 1
612
+ }
613
+ state.mcp = read.facts
614
+ }
615
+ if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
616
+
617
+ const report = runChecks(state)
618
+
619
+ if (flags.json) {
620
+ out.write(JSON.stringify(report, null, 2) + '\n')
621
+ return report.ok ? 0 : 1
622
+ }
623
+
624
+ const width = Math.max(...report.checks.map((c) => c.label.length))
625
+ const lines = ['skitterspec doctor:']
626
+ for (const c of report.checks) {
627
+ lines.push(` ${c.label.padEnd(width)} ${c.state.padEnd(8)} ${c.detail}`)
628
+ // The fix sits under the row it belongs to, indented past the state column,
629
+ // so a wall of rows still reads as "this one, and here is the command".
630
+ if (c.fix) lines.push(` ${' '.repeat(width)} ${' '.repeat(8)} → ${c.fix}`)
631
+ }
632
+ // Everything not `ok`/`skipped` needs a human's attention — including a
633
+ // `missing` one, which is why the count and the EXIT CODE differ: a declined
634
+ // opt-in is worth reporting but must not fail the run (see `runChecks`).
635
+ const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
636
+ lines.push('')
637
+ lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
638
+
639
+ out.write(lines.join('\n') + '\n')
640
+ return report.ok ? 0 : 1
641
+ }
642
+
643
+ /**
644
+ * The live half of the report: one `team(id:)` call proving the id resolves and
645
+ * the key is accepted. Well-formed config is not working config.
646
+ *
647
+ * Opt-in because it is the only part that needs the network — the offline checks
648
+ * have to stay usable with no connectivity, which is exactly when a setup
649
+ * problem is most annoying to diagnose.
650
+ *
651
+ * **No API message is ever relayed.** A GraphQL error body can echo the request
652
+ * back, and this is a command a skill prints; so a failure is CLASSIFIED into a
653
+ * short reason of our own words.
654
+ */
655
+ async function checkRemote(state, flags) {
656
+ if (!state.tracker.parsed || !state.tracker.teamId) {
657
+ return { checked: true, skipped: true, reason: 'no usable tracker config to check against' }
658
+ }
659
+ if (!state.key.ok) {
660
+ return { checked: true, skipped: true, reason: 'no key, so there is nothing to check with' }
661
+ }
662
+
663
+ const key = resolveApiKey(state._config, flags.env || process.env)
664
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
665
+ let team
666
+ try {
667
+ team = await adapter.readTeam(state.tracker.teamId)
668
+ } catch (error) {
669
+ const failure = classifyRemoteFailure(error)
670
+ // NEVER ANSWERED is not the same as ANSWERED NO. An unreachable API or a
671
+ // rate-limit means the check did not run — the setup is unexamined, not
672
+ // wrong — so it reports `skipped`, the same state as "you didn't ask for
673
+ // it". Calling it `broken` exited 1 on a healthy project that merely had no
674
+ // network, and every skill branching on that code failed with it.
675
+ if (failure.reached === false) return { checked: true, skipped: true, reason: failure.reason }
676
+ return { checked: true, ok: false, ...failure }
677
+ }
678
+ if (!team || !team.key) {
679
+ return {
680
+ checked: true,
681
+ ok: false,
682
+ reason: 'the key was accepted, but no team has that id',
683
+ fix: '/spec-linear-setup',
684
+ }
685
+ }
686
+ // The team resolved, so the key works. Only now is it worth spending further
687
+ // calls — on the workspace this key belongs to (so the MCP row has an API side
688
+ // to compare against, and only when there is an MCP side to compare with), and
689
+ // on the project, when one is configured.
690
+ let organization = null
691
+ if (state.mcp && state.mcp.workspace && typeof adapter.readOrganization === 'function') {
692
+ try {
693
+ organization = await adapter.readOrganization()
694
+ } catch {
695
+ // Unexamined, exactly like the project below: the team read already proved
696
+ // the key works, so a failure here is not evidence about the config.
697
+ organization = null
698
+ }
699
+ }
700
+
701
+
702
+ let project = null
703
+ // `readProject` is API-only. An adapter without it cannot answer the question,
704
+ // and a `TypeError` caught below would be dressed up as Linear refusing the
705
+ // request — accusing the user's config of a gap that is ours. Unexamined.
706
+ if (state.project && state.project.configured && typeof adapter.readProject === 'function') {
707
+ try {
708
+ const found = await adapter.readProject(state.project.configured)
709
+ project = found
710
+ ? {
711
+ resolved: true,
712
+ name: found.name,
713
+ // Membership, not equality: a Linear project can span teams.
714
+ belongsToTeam: (found.teams || []).some((t) => t && t.id === state.tracker.teamId),
715
+ }
716
+ : { resolved: false }
717
+ } catch (error) {
718
+ // Same rule as the team read: NEVER ANSWERED is not ANSWERED NO. A
719
+ // transport failure leaves the project unexamined rather than accused.
720
+ const failure = classifyRemoteFailure(error)
721
+ project = failure.reached === false ? null : { resolved: false, reason: failure.reason }
722
+ }
723
+ }
724
+
725
+ return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project }
726
+ }
727
+
728
+ /**
729
+ * The MCP server's view of where this repo files, as the calling skill read it.
730
+ *
731
+ * The engine never speaks MCP — the same split `--workspace-states` and
732
+ * `verify --stored` use — so a skill fetches `get_workspace` / `get_team` /
733
+ * `get_project` and writes them here:
734
+ *
735
+ * { "workspace": {"id","name"}, "team": {"id","key"}, "project": {"id","name"} }
736
+ *
737
+ * Every field is optional. A field the skill could not fetch is ABSENT, and
738
+ * absent means unchecked — never mismatched. That distinction is the whole
739
+ * safety of the row: it is a comparison, so it can only speak about the pairs it
740
+ * actually has both halves of.
741
+ */
742
+ function readMcpFacts(file) {
743
+ let parsed
744
+ try {
745
+ parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
746
+ } catch (error) {
747
+ return { error: error.message }
748
+ }
749
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
750
+ return { error: 'expected an object with workspace / team / project keys' }
751
+ }
752
+ const pick = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
753
+ return { facts: { workspace: pick(parsed.workspace), team: pick(parsed.team), project: pick(parsed.project) } }
754
+ }
755
+
756
+ // Map a thrown API error onto our own short reason. Matched on the shapes
757
+ // `api.js` raises; anything unrecognised degrades to a generic line rather than
758
+ // leaking the message.
759
+ //
760
+ // BLIND SPOT: matching on message text, so an api.js rewording lands here as the
761
+ // unrecognised case. That is why the fallback stays `broken` — Linear ANSWERED
762
+ // and refused, which is evidence of a problem even when we cannot name it. Only
763
+ // a request that got no answer at all (`reached: false`) is un-evidence.
764
+ function classifyRemoteFailure(error) {
765
+ const m = String((error && error.message) || '')
766
+ if (/rejected the API key|HTTP 401|HTTP 403/.test(m)) {
767
+ return { reason: 'Linear rejected the key — it may be revoked or for another workspace', fix: 'skitterspec spec-sync credentials set' }
768
+ }
769
+ // `reached: false` — the request never got an answer, so it says nothing about
770
+ // whether this project is set up correctly. See the caller.
771
+ if (/unreachable/.test(m)) {
772
+ return { reason: 'Linear could not be reached — check your connection', fix: null, reached: false }
773
+ }
774
+ if (/rate-limited/.test(m)) {
775
+ return { reason: 'Linear rate-limited the request and did not recover', fix: null, reached: false }
776
+ }
777
+ if (/Entity not found|not found/i.test(m)) {
778
+ return { reason: 'no team with that id in this workspace', fix: '/spec-linear-setup' }
779
+ }
780
+ return { reason: 'Linear did not accept the request', fix: null }
781
+ }
782
+
783
+ // Read the project's real state for `runChecks`. Never throws: every probe that
784
+ // can fail reports the failure as data.
785
+ function gatherState(dir, flags) {
786
+ const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, project: {}, remote: { checked: false } }
787
+
788
+ const specs = path.join(dir, 'specs')
789
+ state.scaffold.specsDir = fs.existsSync(specs)
790
+ if (state.scaffold.specsDir) {
791
+ state.scaffold.core = fs.existsSync(path.join(specs, '.core'))
792
+ // Reported for context only — a missing bucket is normal (see scaffoldCheck).
793
+ state.scaffold.buckets = BUCKETS.filter((b) => fs.existsSync(path.join(specs, b)))
794
+ state.scaffold.skills = countSkills(dir)
795
+ }
796
+
797
+ const envFile = path.join(dir, 'specs', '.core', 'env.config.json')
798
+ state.isolation.present = fs.existsSync(envFile)
799
+ if (state.isolation.present) {
800
+ try {
801
+ JSON.parse(fs.readFileSync(envFile, 'utf-8'))
802
+ state.isolation.parsed = true
803
+ } catch (error) {
804
+ state.isolation.parsed = false
805
+ state.isolation.error = error.message
806
+ }
807
+ }
808
+
809
+ state.tracker.present = fs.existsSync(path.join(dir, CONFIG_FILE))
810
+ let config = null
811
+ if (state.tracker.present) {
812
+ try {
813
+ config = loadLinearConfig(dir).config
814
+ state.tracker.parsed = true
815
+ state.tracker.teamId = (config.linear && config.linear.teamId) || ''
816
+ state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
817
+ // Offline this is all that can be known: whether a string is there. That a
818
+ // well-formed id names a LIVE project is only answerable with
819
+ // --check-remote, which is why the row says what it checked.
820
+ state.project.configured = (config.linear && config.linear.projectId) || ''
821
+ } catch (error) {
822
+ state.tracker.parsed = false
823
+ state.tracker.error = error.message
824
+ }
825
+ }
826
+
827
+ // Without a parsed config there is no `auth.keyEnv` and no team to key on, so
828
+ // there is nothing to look up — the key row reports as skipped instead.
829
+ if (config) {
830
+ const resolved = resolveApiKey(config, flags.env || process.env)
831
+ // A key resolves from the environment, the store, or a `keyCommand` the
832
+ // store runs — `resolveApiKey` covers all three, so the row must not be read
833
+ // as "env var unset".
834
+ //
835
+ // When it does NOT resolve, `resolveApiKey` appends the reason on later
836
+ // lines: a store that is world-readable, or a keyCommand that failed.
837
+ // Dropping it reported a broken command as `no key for SKS` and sent the
838
+ // user to set a key they had already set (`credentials status` keeps it for
839
+ // the same reason).
840
+ const why = resolved.ok ? '' : resolved.error.split('\n').slice(1).map((l) => l.trim()).filter(Boolean).join('; ')
841
+ state.key = resolved.ok
842
+ ? { ok: true, source: resolved.source === 'env' ? `the environment (${resolved.envVar})` : resolved.source, fingerprint: fingerprint(resolved.key) }
843
+ : { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
844
+ }
845
+
846
+ state._config = config
847
+ return state
848
+ }
849
+
850
+ // How many skills are installed in the target project.
851
+ function countSkills(dir) {
852
+ const skills = path.join(dir, '.claude', 'skills')
853
+ try {
854
+ return fs
855
+ .readdirSync(skills, { withFileTypes: true })
856
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(skills, e.name, 'SKILL.md')))
857
+ .length
858
+ } catch {
859
+ return 0
860
+ }
861
+ }
862
+
863
+ /**
864
+ * `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
865
+ *
866
+ * Renaming a Linear team rewrites the key in every issue identifier, and the
867
+ * repo stamps those in three places. Nothing moved them, so afterwards
868
+ * `/spec-push` fails with `no Linear issue found for SKI-7` — the right failure,
869
+ * with no way out but a hand rewrite (done twice by hand on 2026-09-02).
870
+ *
871
+ * The rewrite itself is provider-neutral and lives in `sync-core/retarget.js`.
872
+ * Only two things here touch Linear:
873
+ *
874
+ * 1. **Detection.** `teamId` survives a rename, so ask Linear for that team's
875
+ * CURRENT key and compare it with the recorded one. A difference IS the
876
+ * rename. It is never taken as an argument: nothing stops a typo rewriting
877
+ * every stamp to a key that does not exist.
878
+ * 2. **One spot-check.** Resolve the first remapped identifier and compare its
879
+ * TITLE to the spec's. That is what makes the number-preservation assumption
880
+ * safe, and it tests identity rather than mere existence — `SKS-7` existing
881
+ * does not make it the issue that was `SKI-7`. One read, which is also what
882
+ * keeps the MCP path viable.
883
+ */
884
+ async function specSyncRetarget(dir, config, flags, out) {
885
+ const teamId = (config.linear && config.linear.teamId) || ''
886
+ if (!teamId) {
887
+ out.write('spec-sync retarget: no linear.teamId in specs/.core/linear.config.json — nothing to compare against.\n')
888
+ return 1
889
+ }
890
+
891
+ const recorded = deriveRecordedKey(dir, config)
892
+ if (!recorded.key) {
893
+ out.write(`spec-sync retarget: cannot tell which key this repo is stamped with.\n ${recorded.reason}\n`)
894
+ return 1
895
+ }
896
+
603
897
  const key = resolveApiKey(config, flags.env || process.env)
604
898
  const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
605
899
 
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.
900
+ // Linear's MCP `get_team` does not return the team key (observed 2026-09-02),
901
+ // so the one fact detection needs is unavailable there. Report what IS known
902
+ // and let the operator supply it, rather than guessing or silently skipping.
610
903
  if (transport === 'mcp') {
611
904
  out.write(
612
905
  [
613
- 'spec-sync doctor: needs the api transport (nothing was read)',
906
+ 'spec-sync retarget: transport = mcp — cannot read the team key (nothing was changed)',
614
907
  ` ${key.ok ? '--via mcp was requested' : key.error}`,
615
- ' it reads one issue per drifted ref, which MCP would route through the model.',
908
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
909
+ " Linear's MCP get_team does not return a team key, so a rename cannot be detected here.",
910
+ ' Confirm the current key in Linear, then set an API key and re-run for the plan.',
616
911
  ].join('\n') + '\n',
617
912
  )
618
913
  return 1
619
914
  }
620
915
 
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
916
  const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
628
917
  let team
629
918
  try {
630
919
  team = await adapter.readTeam(teamId)
631
920
  } catch (error) {
632
- out.write(`spec-sync doctor: could not read the team: ${error.message}\n`)
921
+ out.write(`spec-sync retarget: could not read the team: ${error.message}\n`)
633
922
  return 1
634
923
  }
635
924
  if (!team || !team.key) {
636
- out.write(`spec-sync doctor: Linear returned no team for ${teamId}\n`)
925
+ out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
637
926
  return 1
638
927
  }
639
928
 
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
- }
929
+ const header = [
930
+ `spec-sync retarget: team ${teamId} (${team.name || team.key})`,
931
+ ` recorded key: ${recorded.key} (from ${recorded.source})`,
932
+ ` linear key: ${team.key}${team.key === recorded.key ? '' : ' <- renamed'}`,
933
+ ]
655
934
 
656
- if (flags.json) {
657
- out.write(JSON.stringify({ team: { id: team.id, key: team.key, name: team.name }, ...drift, missing }, null, 2) + '\n')
935
+ if (team.key === recorded.key) {
936
+ out.write([...header, '', ' already current nothing to retarget'].join('\n') + '\n')
658
937
  return 0
659
938
  }
660
939
 
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')
940
+ const plan = planRetarget({ dir, oldKey: recorded.key, newKey: team.key, config })
941
+ if (isEmptyRetarget(plan)) {
942
+ out.write([...header, '', ' the key moved, but nothing in the repo is stamped with it'].join('\n') + '\n')
666
943
  return 0
667
944
  }
668
945
 
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
- )
946
+ // Spot-check BEFORE reporting the plan as safe: a wrong mapping is caught
947
+ // here, not after the files have moved.
948
+ const check = await spotCheck({ dir, config, plan, adapter, oldKey: recorded.key, newKey: team.key })
949
+ const lines = [
950
+ ...header,
951
+ '',
952
+ ' would rewrite:',
953
+ ` ${plan.stamps.length} frontmatter stamp(s) (linear_identifier, linear_url, linear_issue_id)`,
954
+ ` ${plan.snapshots.length} base snapshot(s) (rename + re-key subIssues)`,
955
+ ` ${plan.configKey ? 1 : 0} config key (linear.teamKey)`,
956
+ '',
957
+ ` spot-check: ${check.line}`,
958
+ ]
959
+
960
+ if (!check.ok) {
961
+ lines.push(' refusing the mapping is not safe to apply')
962
+ out.write(lines.join('\n') + '\n')
963
+ return 1
690
964
  }
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
965
 
695
- if (!flags.write) {
696
- lines.push(' run with --write to repair (requires a clean git tree)')
966
+ if (!flags.yes) {
967
+ lines.push(' dry-run — re-run with --yes to apply.')
697
968
  out.write(lines.join('\n') + '\n')
698
969
  return 0
699
970
  }
700
971
 
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
972
  const dirty = dirtyPaths(dir)
706
973
  if (dirty === null) {
707
- lines.push(' --write refused: not a git repository, so the rewrite would not be reviewable')
974
+ lines.push(' --yes refused: not a git repository, so the rewrite would not be reviewable')
708
975
  out.write(lines.join('\n') + '\n')
709
976
  return 1
710
977
  }
711
978
  if (dirty.length) {
712
- lines.push(` --write refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
979
+ lines.push(` --yes refused: ${dirty.length} uncommitted change(s) — commit or stash first`)
713
980
  for (const d of dirty.slice(0, 10)) lines.push(` ${d}`)
714
981
  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')
982
+ lines.push(' the rewrite must land as one reviewable, revertable change')
716
983
  out.write(lines.join('\n') + '\n')
717
984
  return 1
718
985
  }
719
986
 
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')
987
+ const changed = applyRetarget(plan, { dir, config })
988
+ lines.push(
989
+ ' applied:',
990
+ ` ${changed.files.length} spec file(s)`,
991
+ ` ${changed.snapshots.length} snapshot(s) moved`,
992
+ ...(changed.configKey ? [' config linear.teamKey'] : []),
993
+ " nothing was pushed — only the repo's stamps moved; the mirror is untouched.",
994
+ ' review the diff, then commit it.',
995
+ )
730
996
  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
997
+ return 0
998
+ }
999
+
1000
+ /**
1001
+ * Resolve the first remapped SPEC issue and compare its title to the spec's.
1002
+ *
1003
+ * Deliberately ONE read. Checking every ref instead would cost a request each
1004
+ * (~198 on a real repo), force an MCP refusal, and still only prove the issues
1005
+ * exist — not that they are the same issues.
1006
+ */
1007
+ async function spotCheck({ dir, config, plan, adapter, oldKey, newKey }) {
1008
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
1009
+ const stamp = plan.stamps.find((s) => path.basename(s.file) === overviewFile)
1010
+ if (!stamp) return { ok: true, line: 'skipped — no spec issue in the plan to check' }
1011
+
1012
+ const before = parseFrontmatter(stamp.from).data.linear_identifier
1013
+ if (!before) return { ok: true, line: 'skipped — no linear_identifier in the plan to check' }
1014
+ const from = String(before).trim()
1015
+ const to = `${newKey}-${from.slice(oldKey.length + 1)}`
1016
+
1017
+ let issue
1018
+ try {
1019
+ issue = await adapter.readIssue(to)
1020
+ } catch (error) {
1021
+ return { ok: false, line: `${to} could not be read — ${error.message}` }
1022
+ }
1023
+ if (!issue) return { ok: false, line: `${from} → ${to}, but ${to} does not exist in Linear` }
1024
+
1025
+ const expected = readSnapshot(path.dirname(path.join(dir, stamp.file)), config).title
1026
+ const got = issue.title || ''
1027
+ if (expected && got && expected.trim() !== got.trim()) {
1028
+ return { ok: false, line: `${to} resolves, but its title is "${got}" — the spec's is "${expected}"` }
1029
+ }
1030
+ return { ok: true, line: `${to} resolves, title matches the spec` }
734
1031
  }
735
1032
 
736
1033
  /**
@@ -1488,7 +1785,19 @@ async function credentialsSet(file, teamId, label, flags, out) {
1488
1785
 
1489
1786
  let key
1490
1787
  if (flags.stdin) {
1491
- key = (await readAllStdin(flags.input || process.stdin)).trim()
1788
+ const piped = flags.input || process.stdin
1789
+ // A TTY on stdin is positive evidence that nothing was piped: `--stdin` then
1790
+ // waits for an EOF a terminal never sends, printing nothing while it does.
1791
+ // Refuse and name the two working forms rather than blocking forever.
1792
+ if (piped.isTTY) {
1793
+ out.write(
1794
+ 'spec-sync credentials: --stdin expects a pipe, but stdin is a terminal.\n' +
1795
+ ' Run it without --stdin to be prompted (input is hidden), or pipe the key:\n' +
1796
+ ' <command that prints the key> | skitterspec spec-sync credentials set --stdin\n',
1797
+ )
1798
+ return 1
1799
+ }
1800
+ key = (await readAllStdin(piped)).trim()
1492
1801
  if (!key) {
1493
1802
  out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
1494
1803
  return 1
@@ -1560,19 +1869,33 @@ function readAllStdin(input) {
1560
1869
  }
1561
1870
 
1562
1871
  // Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
1563
- // hook — silencing it is what keeps the key off the screen (and out of a
1872
+ // hook — filtering it is what keeps the key off the screen (and out of a
1564
1873
  // screen-shared terminal or a recorded session).
1874
+ //
1875
+ // READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
1876
+ // the interface loses it: readline clears from the cursor to the end of the
1877
+ // screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
1878
+ // it appeared and the user was left staring at a blank line while the process
1879
+ // waited for a key — indistinguishable from a hang.
1880
+ //
1881
+ // The hook is assigned BEFORE `question`, so the very first redraw goes through
1882
+ // it. readline hands it `prompt + what has been typed`; re-writing only the
1883
+ // prompt is what keeps the key hidden while the prompt survives every redraw.
1565
1884
  function promptHidden(question, input, out) {
1566
1885
  const readline = require('node:readline')
1567
1886
  return new Promise((resolve) => {
1568
- const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
1569
- out.write(question)
1570
- rl.question('', (answer) => {
1887
+ // `out`, never `process.stdout`: they are the same stream in production, and
1888
+ // hardcoding one half meant readline cleared a screen the prompt had not
1889
+ // been written to under test — the split that hid this bug from the suite.
1890
+ const rl = readline.createInterface({ input, output: out, terminal: true })
1891
+ rl._writeToOutput = (s) => {
1892
+ if (s.includes(question)) out.write(question)
1893
+ }
1894
+ rl.question(question, (answer) => {
1571
1895
  out.write('\n')
1572
1896
  rl.close()
1573
1897
  resolve(answer)
1574
1898
  })
1575
- rl._writeToOutput = () => {}
1576
1899
  })
1577
1900
  }
1578
1901
 
@@ -1582,13 +1905,18 @@ async function specSync(rest, io = {}) {
1582
1905
  const [sub, ...args] = rest
1583
1906
  let dir = io.cwd || process.cwd()
1584
1907
  const positional = []
1908
+ // Anything `--`-prefixed that no branch below consumed. Collected rather than
1909
+ // pushed onto `positional`, where it was silently discarded — see the refusal
1910
+ // after the loop.
1911
+ const unknownFlags = []
1585
1912
  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 }
1913
+ mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1587
1914
  for (let i = 0; i < args.length; i++) {
1588
1915
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1589
1916
  else if (args[i] === '--json') flags.json = true
1590
1917
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
1591
1918
  else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
1919
+ else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
1592
1920
  else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
1593
1921
  else if (args[i] === '--via') flags.via = args[++i]
1594
1922
  else if (args[i] === '--all') flags.all = args[++i]
@@ -1599,7 +1927,8 @@ async function specSync(rest, io = {}) {
1599
1927
  else if (args[i] === '--url') flags.url = args[++i]
1600
1928
  else if (args[i] === '--sub') flags.subs.push(args[++i])
1601
1929
  else if (args[i] === '--force') flags.force = true
1602
- else if (args[i] === '--write') flags.write = true
1930
+ else if (args[i] === '--yes') flags.yes = true
1931
+ else if (args[i] === '--check-remote') flags.remoteCheck = true
1603
1932
  else if (args[i] === '--stdin') flags.stdin = true
1604
1933
  else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
1605
1934
  else if (args[i] === '--key') {
@@ -1622,8 +1951,34 @@ async function specSync(rest, io = {}) {
1622
1951
  const [bucket, ...rest] = String(args[++i] || '').split('=')
1623
1952
  flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
1624
1953
  } else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
1954
+ else if (args[i].startsWith('--')) unknownFlags.push(args[i])
1625
1955
  else positional.push(args[i])
1626
1956
  }
1957
+ // REFUSE AN UNKNOWN FLAG, before anything runs.
1958
+ //
1959
+ // These used to land in `positional` and vanish. That is merely untidy for a
1960
+ // typo, but it turned a RENAMED flag into a silent no-op: `--write` moved to
1961
+ // `--yes` when `doctor` became `retarget`, so `spec-sync doctor --write` — the
1962
+ // exact 10.4.0 invocation for repairing a renamed team — parsed, ran the
1963
+ // readiness report instead, ignored the flag and exited 0. A script would read
1964
+ // that as "repaired".
1965
+ //
1966
+ // A spec name never starts with `--`, so this cannot swallow a real argument.
1967
+ if (unknownFlags.length) {
1968
+ const lines = [`spec-sync: unknown flag ${unknownFlags.join(', ')}`]
1969
+ // Renamed flags get a specific hand-off; a bare "unknown flag" would leave
1970
+ // the caller to guess what replaced it.
1971
+ if (unknownFlags.includes('--write')) {
1972
+ lines.push(
1973
+ ' --write was replaced by --yes, and the command that repairs a renamed',
1974
+ ' team is now `spec-sync retarget --yes` (it was `doctor --write`).',
1975
+ )
1976
+ }
1977
+ lines.push(' run `skitterspec spec-sync` for the full usage.')
1978
+ out.write(lines.join('\n') + '\n')
1979
+ return 1
1980
+ }
1981
+
1627
1982
  dir = path.resolve(dir)
1628
1983
  // Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
1629
1984
  // `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
@@ -1631,12 +1986,21 @@ async function specSync(rest, io = {}) {
1631
1986
  flags.env = io.env || process.env
1632
1987
  if (io.adapter) flags.adapter = io.adapter
1633
1988
  if (io.fetch) flags.fetch = io.fetch
1989
+ // The stdin seam. `credentials set` branches on whether stdin is a TTY, and
1990
+ // with no way to inject one the suite could only ever exercise the non-TTY
1991
+ // half — which is how a prompt that erased itself reached a release.
1992
+ if (io.input) flags.input = io.input
1634
1993
 
1635
1994
  // Dispatched ahead of the load on purpose: this is the command you run when
1636
1995
  // there is no config, and `--force` must be able to replace one that is
1637
1996
  // malformed enough for the loader to throw on.
1638
1997
  if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
1639
1998
 
1999
+ // Same reason as init-config: this is the command you run when the config is
2000
+ // missing or malformed, so it must not sit behind a loader that throws on the
2001
+ // one and short-circuits on the other.
2002
+ if (sub === 'doctor') return await specSyncDoctor(dir, flags, out)
2003
+
1640
2004
  const { config, present } = loadLinearConfig(dir)
1641
2005
  if (!present) {
1642
2006
  out.write(
@@ -1661,8 +2025,8 @@ async function specSync(rest, io = {}) {
1661
2025
  return (await specSyncProjects(dir, config, flags, out)) || 0
1662
2026
  case 'states':
1663
2027
  return (await specSyncStates(dir, config, flags, out)) || 0
1664
- case 'doctor':
1665
- return (await specSyncDoctor(dir, config, flags, out)) || 0
2028
+ case 'retarget':
2029
+ return (await specSyncRetarget(dir, config, flags, out)) || 0
1666
2030
  case 'apply':
1667
2031
  return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
1668
2032
  case 'verify':
@@ -1683,7 +2047,8 @@ async function specSync(rest, io = {}) {
1683
2047
  ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
1684
2048
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
1685
2049
  ' skitterspec spec-sync linked [--json]\n' +
1686
- ' skitterspec spec-sync doctor [--write] [--json]\n' +
2050
+ ' skitterspec spec-sync retarget [--yes]\n' +
2051
+ ' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
1687
2052
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1688
2053
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1689
2054
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
@@ -1691,4 +2056,4 @@ async function specSync(rest, io = {}) {
1691
2056
  }
1692
2057
  }
1693
2058
 
1694
- module.exports = { specSync, listSpecs }
2059
+ module.exports = { specSync, listSpecs, promptHidden }