@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.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +27 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +21 -10
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +25 -35
- package/package.json +1 -1
- package/src/init.js +5 -0
- package/src/vendor/linear/api.js +1 -1
- package/src/vendor/linear/cli-sync.js +359 -104
- package/src/vendor/linear/doctor.js +146 -308
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/normalize.js +17 -2
- package/src/vendor/sync-core/src/retarget.js +274 -0
- package/src/vendor/sync-core/src/verify.js +5 -0
|
@@ -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 {
|
|
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]` —
|
|
588
|
+
* `spec-sync doctor [--json]` — is this project set up, across every layer?
|
|
584
589
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
846
|
+
out.write(`spec-sync retarget: Linear returned no team for ${teamId}\n`)
|
|
637
847
|
return 1
|
|
638
848
|
}
|
|
639
849
|
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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 (
|
|
657
|
-
out.write(
|
|
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
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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.
|
|
696
|
-
lines.push(' run with --
|
|
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(' --
|
|
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(` --
|
|
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
|
|
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
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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,
|
|
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] === '--
|
|
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 '
|
|
1665
|
-
return (await
|
|
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
|
|
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')
|