@skitterbyte/skitterspec-linear 10.1.0 → 10.2.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.
@@ -44,7 +44,8 @@ const {
44
44
  compareStored,
45
45
  } = require('../sync-core')
46
46
 
47
- const { loadLinearConfig } = require('./config.js')
47
+ const { loadLinearConfig, mergeConfig, defaults: configDefaults, CONFIG_FILE, LIFECYCLE_BUCKETS } = require('./config.js')
48
+ const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
48
49
 
49
50
  // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
50
51
  // under specs/** (preferred) or a literal path to a snapshot directory.
@@ -198,6 +199,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
198
199
  const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
199
200
  if (p.legacy) lines.push(...legacyLines(p.legacy))
200
201
  if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
202
+ lines.push(...phaseModeLines(p.phaseMode, r.projection.status))
201
203
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
202
204
  else {
203
205
  if (p.issue) lines.push(' issue: description/state')
@@ -219,6 +221,25 @@ function deferredLines(n) {
219
221
  ]
220
222
  }
221
223
 
224
+ // Which phase mode resolved for this spec, and what it means for the plan below.
225
+ //
226
+ // Silent for `subissue`: the sub-issue lines are right there and explain
227
+ // themselves. Said for anything else, because `mapping.phases` can now be a
228
+ // per-bucket map — so the mode that applied is no longer readable off the config
229
+ // without knowing which bucket the spec is in, and the alternative reading of a
230
+ // spec with no sub-issues is that its phase files failed to parse.
231
+ function phaseModeLines(mode, bucket) {
232
+ if (!mode || mode === 'subissue') return []
233
+ const why = {
234
+ inline: 'each phase is a section of this issue\'s description, not a sub-issue',
235
+ deferred: 'unlinked phases are held back until the spec leaves backlog/cancelled',
236
+ }[mode]
237
+ const where = bucket ? ` for this spec's bucket ("${bucket}")` : ''
238
+ const lines = [` phases: ${mode} — mapping.phases resolved${where}`]
239
+ if (why) lines.push(` ${why}`)
240
+ return lines
241
+ }
242
+
222
243
  // The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
223
244
  // ordinary — an all-creates plan for a spec that reads as unlinked — and
224
245
  // applying it mints a second mirror and abandons the first.
@@ -419,6 +440,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
419
440
  // From the projection, not the plan: `status` builds its plan with
420
441
  // `planChanges` directly rather than going through `push`.
421
442
  if (projection.phasesWithheld) lines.push(...deferredLines(projection.phasesWithheld))
443
+ lines.push(...phaseModeLines(projection.phaseMode, projection.status))
422
444
  if (!snapshot) lines.push(' push: never pushed — everything is pending')
423
445
  else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
424
446
  else {
@@ -474,6 +496,18 @@ function specSyncVerify(dir, config, specArg, flags, out) {
474
496
  }
475
497
 
476
498
  const identifier = specIdentifier(snapshotDir, config)
499
+ out.write(verifyLines(snapshotDir, config, stored, identifier).join('\n') + '\n')
500
+ return 0
501
+ }
502
+
503
+ /**
504
+ * The verify comparison itself, as reportable lines.
505
+ *
506
+ * Split out of `specSyncVerify` so `apply` runs the SAME check on the read-back
507
+ * it does itself, rather than a second implementation that could disagree about
508
+ * what counts as lost text.
509
+ */
510
+ function verifyLines(snapshotDir, config, stored, identifier) {
477
511
  const projection = projectionOf(snapshotDir, config)
478
512
  const checks = []
479
513
  if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
@@ -506,30 +540,681 @@ function specSyncVerify(dir, config, specArg, flags, out) {
506
540
  }
507
541
  if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
508
542
  else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
543
+ return lines
544
+ }
545
+
546
+ /**
547
+ * `spec-sync states [--json]` — which transport this repo will use, and on the
548
+ * API path the workspace's issue state NAMES.
549
+ *
550
+ * This exists to break a chicken-and-egg in `/spec-push`: the skill cannot know
551
+ * whether to do MCP work until it knows the transport, but `push` refuses to run
552
+ * without `--workspace-states`, which on the MCP path only an MCP call can
553
+ * supply. Asking the engine first makes the skill linear, and on the API path it
554
+ * removes the state fetch from the model's work entirely — the same reason
555
+ * `apply` exists.
556
+ *
557
+ * Read-only: fetches states, writes nothing, changes nothing.
558
+ */
559
+ async function specSyncStates(dir, config, flags, out) {
560
+ const key = resolveApiKey(config, flags.env || process.env)
561
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
562
+
563
+ if (transport === 'mcp') {
564
+ if (flags.json) {
565
+ out.write(JSON.stringify({ transport: 'mcp', reason: key.ok ? 'requested' : key.error, states: null }, null, 2) + '\n')
566
+ return 0
567
+ }
568
+ out.write(
569
+ [
570
+ 'spec-sync states: transport = mcp',
571
+ ` ${key.ok ? '--via mcp was requested' : key.error}`,
572
+ ' fetch the workspace states over MCP, as /spec-push describes',
573
+ ].join('\n') + '\n',
574
+ )
575
+ return 0
576
+ }
577
+ if (!key.ok) {
578
+ out.write(`spec-sync states: refusing — ${key.error}\n`)
579
+ return 1
580
+ }
581
+
582
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
583
+ const teamId = (config.linear && config.linear.teamId) || null
584
+ let names
585
+ try {
586
+ names = await fetchWorkspaceStates(adapter, teamId)
587
+ } catch (error) {
588
+ out.write(`spec-sync states: ${error.message}\n`)
589
+ return 1
590
+ }
591
+ if (flags.json) {
592
+ // The bare array `--workspace-states` takes, so this can be piped into it.
593
+ out.write(JSON.stringify(names, null, 2) + '\n')
594
+ return 0
595
+ }
596
+ out.write(`spec-sync states: transport = api\n ${names.join(', ')}\n`)
597
+ return 0
598
+ }
599
+
600
+ /**
601
+ * `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
602
+ *
603
+ * Apply a push plan to Linear **without any description passing through the
604
+ * agent**. `/spec-push` used to hand the plan to the model, which re-emitted
605
+ * every description as generated tokens — twice, once to write and once for the
606
+ * verify read-back — making throughput a function of decode speed rather than of
607
+ * Linear's API. This does the writes, the read-back, the stamping and the
608
+ * snapshot in one call.
609
+ *
610
+ * Two guarantees shape the code:
611
+ *
612
+ * - **Nothing is written until everything is checked.** A legacy plan, a
613
+ * missing key, or a `config.states` name the workspace lacks all fail before
614
+ * the first mutation. A half-applied plan is the one outcome worse than an
615
+ * unapplied one.
616
+ * - **Each id is stamped the moment its object exists**, not batched at the
617
+ * end. An interrupted run therefore leaves the objects it did create linked,
618
+ * so the next run's plan sees them as updates and mints no duplicates. That
619
+ * is the whole resumability story — there is no separate ledger to disagree
620
+ * with the spec files.
621
+ *
622
+ * On the MCP transport it writes nothing and prints the plan for the skill to
623
+ * apply, exactly as before.
624
+ */
625
+ /**
626
+ * `spec-sync projects [--json]` — the team's Linear Projects, for the picker.
627
+ *
628
+ * The picker is the one interactive step in linking a spec, and on the API path
629
+ * there is no MCP tool to list from — the whole point of that path is that the
630
+ * agent makes no Linear calls. So the engine offers the list, exactly as
631
+ * `spec-sync states` offers workspace states.
632
+ *
633
+ * Degrades rather than blocks: no key, or a Linear that will not answer, exits 0
634
+ * with an empty list and says why. A missing picker must never fail `/spec`.
635
+ */
636
+ async function specSyncProjects(dir, config, flags, out) {
637
+ const key = resolveApiKey(config, flags.env || process.env)
638
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
639
+ const teamId = (config.linear && config.linear.teamId) || null
640
+
641
+ const degrade = (reason) => {
642
+ if (flags.json) out.write(JSON.stringify({ transport, projects: null, reason }, null, 2) + '\n')
643
+ else out.write(`spec-sync projects: ${reason}\n`)
644
+ return 0
645
+ }
646
+ if (transport === 'mcp') {
647
+ return degrade(
648
+ `transport = mcp — ${key.ok ? '--via mcp was requested' : key.error}; list projects over MCP instead`,
649
+ )
650
+ }
651
+ if (!key.ok) return degrade(key.error)
652
+
653
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
654
+ let projects
655
+ try {
656
+ projects = await adapter.listProjects(teamId)
657
+ } catch (error) {
658
+ // The picker's contract is "degrade, never block" — a project list we cannot
659
+ // fetch means no picker, not a failed link.
660
+ return degrade(`could not list projects (${error.message}); continuing without the picker`)
661
+ }
662
+ const rows = projects.map((p) => ({ id: p.id, name: p.name })).filter((p) => p.id)
663
+ if (flags.json) {
664
+ out.write(JSON.stringify({ transport: 'api', projects: rows }, null, 2) + '\n')
665
+ return 0
666
+ }
667
+ out.write(
668
+ [`spec-sync projects: transport = api, ${rows.length} project(s)`, ...rows.map((p) => ` ${p.id} ${p.name}`)].join('\n') + '\n',
669
+ )
670
+ return 0
671
+ }
672
+
673
+ /**
674
+ * Apply one spec's plan. Returns what happened rather than printing it, so the
675
+ * single-spec command and the bulk loop report in their own voices while sharing
676
+ * one implementation of the part that actually matters.
677
+ *
678
+ * Every id is stamped the moment its object exists — see `specSyncApply`.
679
+ */
680
+ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, project, states }) {
681
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
682
+ const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
683
+ const lines = []
684
+ const result = { issue: null, subIssues: {} }
685
+ const stateId = (bucket) => (bucket ? stateIdFor(bucket, config, states) : null)
686
+
687
+ // Resolve every state id BEFORE the first write, so a bad config.states value
688
+ // cannot strand the spec mid-apply.
689
+ const wanted = new Set()
690
+ if (plan.issue && plan.issue.state) wanted.add(plan.issue.state)
691
+ for (const s of (plan.subIssues && plan.subIssues.create) || []) if (s.state) wanted.add(s.state)
692
+ for (const s of (plan.subIssues && plan.subIssues.update) || []) if (s.state) wanted.add(s.state)
693
+ for (const bucket of wanted) stateId(bucket)
694
+
695
+ // 1. The spec issue. No identifier yet → this push mints it.
696
+ let parentId = null
697
+ if (!identifier) {
698
+ const created = await adapter.createIssue(withoutNull({
699
+ title: readSnapshot(snapshotDir, config).title,
700
+ teamId,
701
+ projectId: project || (config.linear && config.linear.projectId) || null,
702
+ description: plan.issue && plan.issue.description,
703
+ stateId: stateId(plan.issue && plan.issue.state),
704
+ }))
705
+ if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
706
+ parentId = created.id
707
+ // Stamped NOW: an interrupt after this point must not mint a second issue.
708
+ writeFrontmatter(snapshotDir, config, { linear_identifier: created.identifier, linear_url: created.url })
709
+ result.issue = { id: created.id, identifier: created.identifier, url: created.url }
710
+ lines.push(` issue created: ${created.identifier}`)
711
+ } else {
712
+ const existing = await adapter.readIssue(identifier)
713
+ if (!existing || !existing.id) throw new Error(`no Linear issue found for ${identifier}`)
714
+ parentId = existing.id
715
+ result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
716
+ if (plan.issue) {
717
+ await adapter.updateIssue(existing.id, withoutNull({
718
+ description: plan.issue.description,
719
+ stateId: stateId(plan.issue.state),
720
+ }))
721
+ lines.push(` issue updated: ${identifier}`)
722
+ }
723
+ }
724
+
725
+ // 2. Sub-issue creates — each stamped as it lands, for the same reason.
726
+ for (const sub of (plan.subIssues && plan.subIssues.create) || []) {
727
+ const created = await adapter.createSubIssue(parentId, withoutNull({
728
+ title: sub.name,
729
+ teamId,
730
+ description: sub.goal,
731
+ stateId: stateId(sub.state),
732
+ }))
733
+ if (!created || !created.identifier) throw new Error(`Linear returned no issue for sub-issue ${sub.ref}`)
734
+ const file = resolvePhaseFile(snapshotDir, sub.ref)
735
+ if (!file) throw new Error(`no phase file for ref ${sub.ref}`)
736
+ stampSubIssueId(snapshotDir, file, created.identifier)
737
+ result.subIssues[sub.ref] = created.identifier
738
+ lines.push(` sub-issue created: ${sub.ref} → ${created.identifier}`)
739
+ }
740
+
741
+ // 3. Sub-issue updates — already linked, nothing to stamp.
742
+ for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
743
+ await adapter.updateIssue(sub.id, withoutNull({
744
+ title: sub.name,
745
+ description: sub.goal,
746
+ stateId: stateId(sub.state),
747
+ }))
748
+ result.subIssues[sub.ref || sub.id] = sub.id
749
+ lines.push(` sub-issue updated: ${sub.id}`)
750
+ }
751
+
752
+ // 4. Read back what Linear stored and run the SAME check `verify` runs.
753
+ const stored = { issue: undefined, subIssues: {} }
754
+ if (result.issue) {
755
+ const back = await adapter.readIssue(result.issue.id)
756
+ if (back && typeof back.description === 'string') stored.issue = back.description
757
+ }
758
+ for (const [ref, id] of Object.entries(result.subIssues)) {
759
+ const back = await adapter.readIssue(id)
760
+ if (back && typeof back.description === 'string') stored.subIssues[ref] = back.description
761
+ }
762
+ const verify = verifyLines(snapshotDir, config, stored, result.issue ? result.issue.identifier : identifier)
763
+ lines.push(...verify.map((l) => ` ${l}`))
764
+ const lost = verify.some((l) => l.includes('!!') || l.includes('??'))
765
+
766
+ // 5. Record the snapshot from the now-stamped files, so the next push is empty.
767
+ const file = recordPush({ dir, snapshotDir, identifier: specIdentifier(snapshotDir, config), config })
768
+ lines.push(` snapshot: ${path.relative(dir, file)}`)
769
+ return { result, lines, lost }
770
+ }
771
+
772
+ /**
773
+ * `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
774
+ * `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
775
+ *
776
+ * Apply a push plan to Linear **without any description passing through the
777
+ * agent**. `/spec-push` used to hand the plan to the model, which re-emitted
778
+ * every description as generated tokens — twice, once to write and once for the
779
+ * verify read-back — making throughput a function of decode speed rather than of
780
+ * Linear's API. This does the writes, the read-back, the stamping and the
781
+ * snapshot in one call.
782
+ *
783
+ * Two guarantees shape the code:
784
+ *
785
+ * - **Nothing is written until everything is checked.** A legacy plan, a
786
+ * missing key, or a `config.states` name the workspace lacks all fail before
787
+ * the first mutation. A half-applied plan is the one outcome worse than an
788
+ * unapplied one.
789
+ * - **Each id is stamped the moment its object exists**, not batched at the
790
+ * end. An interrupted run therefore leaves the objects it did create linked,
791
+ * so the next run's plan sees them as updates and mints no duplicates. That
792
+ * is the whole resumability story — there is no separate ledger to disagree
793
+ * with the spec files.
794
+ *
795
+ * `--all <bucket>` walks every spec in one lifecycle bucket, computing each plan
796
+ * in process (no plan files) and carrying on past a spec that fails. That is
797
+ * first-time adoption on an established repo: one command instead of a session.
798
+ *
799
+ * On the MCP transport it writes nothing and prints the plan for the skill to
800
+ * apply, exactly as before.
801
+ */
802
+ async function specSyncApply(dir, config, specArg, flags, out) {
803
+ const bulk = flags.all != null
804
+ if (bulk && !BUCKETS.includes(flags.all)) {
805
+ out.write(`spec-sync apply: --all ${flags.all} is not a bucket (${BUCKETS.join('|')})\n`)
806
+ return 1
807
+ }
808
+
809
+ let snapshotDir = null
810
+ let plan = null
811
+ if (!bulk) {
812
+ snapshotDir = resolveOrExit(specArg, dir, out)
813
+ if (!snapshotDir) return 1
814
+ if (!flags.plan) {
815
+ out.write(
816
+ 'spec-sync apply: refusing to run without --plan <file>.\n' +
817
+ ' Get one with: skitterspec spec-sync push <spec> --json > plan.json\n' +
818
+ ' Or apply a whole bucket at once with --all <bucket>.\n',
819
+ )
820
+ return 1
821
+ }
822
+ try {
823
+ plan = JSON.parse(fs.readFileSync(flags.plan, 'utf-8'))
824
+ } catch (error) {
825
+ out.write(`spec-sync apply: cannot read --plan ${flags.plan}: ${error.message}\n`)
826
+ return 1
827
+ }
828
+
829
+ // A pre-9.0 mirror reads as unlinked, so its plan is all-creates and applying
830
+ // it would abandon the live objects. The API path must not do that faster
831
+ // than a human can read about it.
832
+ if (plan.legacy) {
833
+ out.write(
834
+ ['spec-sync apply: refusing — this spec is linked under the pre-9.0 model.', ...legacyLines(plan.legacy)].join('\n') + '\n',
835
+ )
836
+ return 1
837
+ }
838
+ if (isEmptyPlan(plan)) {
839
+ out.write('spec-sync apply: nothing to apply — the mirror is up to date.\n')
840
+ return 0
841
+ }
842
+ }
843
+
844
+ const key = resolveApiKey(config, flags.env || process.env)
845
+ const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
846
+
847
+ if (transport === 'mcp') {
848
+ if (bulk) {
849
+ // Bulk over MCP is the very thing this command exists to avoid; pretending
850
+ // to support it would hand the model every description in the bucket.
851
+ out.write(
852
+ [
853
+ 'spec-sync apply: --all needs the api transport (no writes made here)',
854
+ ` ${key.ok ? '--via mcp was requested' : key.error}`,
855
+ ' push specs one at a time with /spec-push, or set a key and re-run.',
856
+ ].join('\n') + '\n',
857
+ )
858
+ return 1
859
+ }
860
+ out.write(
861
+ [
862
+ 'spec-sync apply: transport = mcp (no writes made here)',
863
+ key.ok ? ' --via mcp was requested' : ` ${key.error}`,
864
+ ' apply the plan over MCP as /spec-push describes, then run:',
865
+ ' skitterspec spec-sync stamp <spec> --issue … [--sub <ref>=… …]',
866
+ ' skitterspec spec-sync record <spec>',
867
+ ].join('\n') + '\n',
868
+ )
869
+ return 0
870
+ }
871
+ if (!key.ok) {
872
+ // --via api (or apply.transport: api) was explicit, so this is a failure
873
+ // rather than a quiet fallback — and it happens before any write.
874
+ out.write(`spec-sync apply: refusing — ${key.error}\n`)
875
+ return 1
876
+ }
877
+
878
+ const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
879
+ const teamId = (config.linear && config.linear.teamId) || null
880
+ let states
881
+ try {
882
+ states = await adapter.listIssueStates(teamId)
883
+ } catch (error) {
884
+ out.write(`spec-sync apply: ${error.message}\n`)
885
+ return 1
886
+ }
887
+ const shared = { dir, config, adapter, teamId, project: flags.project, states }
888
+
889
+ if (!bulk) {
890
+ const lines = ['spec-sync apply: transport = api']
891
+ let applied
892
+ try {
893
+ applied = await applyOneSpec({ ...shared, snapshotDir, plan })
894
+ } catch (error) {
895
+ // Whatever landed before the failure is already stamped, so re-running
896
+ // resumes rather than duplicating — say so instead of leaving it ambiguous.
897
+ out.write(
898
+ [...lines, ` !! ${error.message}`, ' ids stamped so far are saved — re-run to resume without duplicating'].join('\n') + '\n',
899
+ )
900
+ return 1
901
+ }
902
+ if (flags.json) {
903
+ out.write(JSON.stringify(applied.result, null, 2) + '\n')
904
+ return 0
905
+ }
906
+ out.write([...lines, ...applied.lines].join('\n') + '\n')
907
+ return 0
908
+ }
909
+
910
+ // --- bulk -------------------------------------------------------------------
911
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
912
+ const specs = listSpecs(dir, config).filter((s) => s.bucket === flags.all)
913
+ const lines = [`spec-sync apply --all ${flags.all}: transport = api, ${specs.length} spec(s)`]
914
+ const summary = { created: 0, updated: 0, upToDate: 0, failed: 0, altered: 0 }
915
+ const failures = []
916
+
917
+ const fail = (spec, why) => {
918
+ summary.failed++
919
+ failures.push(`${spec}: ${why}`)
920
+ lines.push(` x ${spec}: ${why}`)
921
+ }
922
+
923
+ for (const { spec } of specs) {
924
+ const specDir = resolveSnapshotDir(spec, dir)
925
+ if (!specDir) {
926
+ fail(spec, 'could not resolve its folder')
927
+ continue
928
+ }
929
+ let specPlan
930
+ try {
931
+ specPlan = push({ dir, snapshotDir: specDir, identifier: specIdentifier(specDir, config), config }).plan
932
+ } catch (error) {
933
+ fail(spec, error.message)
934
+ continue
935
+ }
936
+ // Never silently: applying this would abandon a live pre-9.0 mirror.
937
+ if (specPlan.legacy) {
938
+ fail(spec, `pre-9.0 mirror — would orphan ${specPlan.legacy.orphanCount} object(s); see MIGRATION.md`)
939
+ continue
940
+ }
941
+ if (isEmptyPlan(specPlan)) {
942
+ summary.upToDate++
943
+ lines.push(` . ${spec}: up to date`)
944
+ continue
945
+ }
946
+ const minted = !linkedIdentifier(path.join(specDir, overviewFile))
947
+ let applied
948
+ try {
949
+ applied = await applyOneSpec({ ...shared, snapshotDir: specDir, plan: specPlan })
950
+ } catch (error) {
951
+ fail(spec, error.message)
952
+ continue
953
+ }
954
+ if (minted) summary.created++
955
+ else summary.updated++
956
+ const id = applied.result.issue ? applied.result.issue.identifier : '?'
957
+ const subs = Object.keys(applied.result.subIssues).length
958
+ lines.push(` ok ${spec}: ${minted ? 'created' : 'updated'} ${id}${subs ? ` (+${subs} sub-issue(s))` : ''}`)
959
+ if (applied.lost) {
960
+ summary.altered++
961
+ lines.push(...applied.lines.filter((l) => l.includes('!!') || l.includes('??')))
962
+ }
963
+ }
964
+
965
+ lines.push(
966
+ '',
967
+ ` created ${summary.created} · updated ${summary.updated} · up to date ${summary.upToDate} · failed ${summary.failed}`,
968
+ )
969
+ if (summary.altered) {
970
+ lines.push(` ${summary.altered} spec(s) had text altered by Linear — the repo is unchanged and still correct`)
971
+ }
972
+ if (failures.length) {
973
+ lines.push('', ' failed:')
974
+ for (const f of failures) lines.push(` ${f}`)
975
+ lines.push(' re-run to retry them — everything already created is linked, so nothing duplicates')
976
+ }
977
+ if (flags.json) out.write(JSON.stringify({ summary, failures }, null, 2) + '\n')
978
+ else out.write(lines.join('\n') + '\n')
979
+ // Non-zero when anything failed, so a scripted backfill is checkable.
980
+ return summary.failed ? 1 : 0
981
+ }
982
+
983
+ // A comma-separated label flag (`--bug-labels bug,defect`) as the trimmed,
984
+ // non-empty list the config stores.
985
+ function labelList(value) {
986
+ return String(value || '')
987
+ .split(',')
988
+ .map((s) => s.trim())
989
+ .filter(Boolean)
990
+ }
991
+
992
+ /**
993
+ * `spec-sync init-config --team-id <id> [flags]`
994
+ *
995
+ * Turn values gathered from the workspace into a valid
996
+ * `specs/.core/linear.config.json`. The engine half of `/spec-linear-setup`:
997
+ * the skill discovers over MCP and interviews, this validates and writes, so a
998
+ * malformed config can never be the model's formatting. Same split as
999
+ * `/spec-push` gathering and `spec-sync apply` writing.
1000
+ *
1001
+ * Three things shape it:
1002
+ *
1003
+ * - **Only the keys the operator set are written.** The shipped defaults
1004
+ * already carry every other value; restating them buries the two or three
1005
+ * lines that are genuinely this repo's, and freezes today's defaults into a
1006
+ * file that then never picks up a change to them.
1007
+ * - **Nothing is written until the loader would accept it.** The draft is run
1008
+ * through `mergeConfig` — the very function `loadLinearConfig` uses — so an
1009
+ * enum this rejects is exactly an enum that would have thrown on first use.
1010
+ * - **State names are checked here, not at first push.** Linear silently
1011
+ * ignores an unknown issue state, so a workspace that renamed `Done` gets a
1012
+ * mirror that never moves. `--states` makes that a setup-time failure with
1013
+ * the replacement named.
1014
+ *
1015
+ * `--states` is optional, and deliberately so: `spec-sync states` cannot run
1016
+ * before a config exists (every other subcommand exits early on `present:false`),
1017
+ * so requiring it here would make the config unbootstrappable. The skill always
1018
+ * passes it from MCP discovery; a bare CLI run without it writes and says
1019
+ * loudly that the names are unverified.
1020
+ */
1021
+ function specSyncInitConfig(dir, flags, out) {
1022
+ const file = path.join(dir, CONFIG_FILE)
1023
+ const rel = CONFIG_FILE
1024
+ const existed = fs.existsSync(file)
1025
+ const fail = (lines, extra = {}) => {
1026
+ if (flags.json) {
1027
+ out.write(JSON.stringify({ ok: false, file: rel, error: lines[0], ...extra }, null, 2) + '\n')
1028
+ } else {
1029
+ out.write(lines.join('\n') + '\n')
1030
+ }
1031
+ return 1
1032
+ }
1033
+
1034
+ if (existed && !flags.force) {
1035
+ return fail([
1036
+ `spec-sync init-config: refusing — ${rel} already exists`,
1037
+ ' pass --force to replace it. Re-running setup on a configured repo is',
1038
+ ' meant to be a way to CHECK the setup, not a way to lose it.',
1039
+ ])
1040
+ }
1041
+ if (!flags.teamId) {
1042
+ return fail([
1043
+ 'spec-sync init-config: refusing — --team-id is required',
1044
+ ' it is the one value with no useful default: without it nothing knows',
1045
+ ' which Linear team a spec files into.',
1046
+ ])
1047
+ }
1048
+
1049
+ // Only what the operator actually set. Empty strings/arrays are treated as
1050
+ // "not set" — the defaults already say that, and more clearly.
1051
+ const draft = {}
1052
+ const linear = {}
1053
+ if (flags.teamId) linear.teamId = flags.teamId
1054
+ if (flags.teamKey) linear.teamKey = flags.teamKey
1055
+ if (flags.projectId) linear.projectId = flags.projectId
1056
+ draft.linear = linear
1057
+ const intake = {}
1058
+ if (flags.intakeLabel) intake.label = flags.intakeLabel
1059
+ if (flags.bugLabels.length) intake.bugLabels = flags.bugLabels
1060
+ if (flags.hotfixLabels.length) intake.hotfixLabels = flags.hotfixLabels
1061
+ if (Object.keys(intake).length) draft.intake = intake
1062
+ if (Object.keys(flags.stateNames).length) draft.states = { ...flags.stateNames }
1063
+
1064
+ for (const bucket of Object.keys(flags.stateNames)) {
1065
+ if (!LIFECYCLE_BUCKETS.includes(bucket)) {
1066
+ return fail([
1067
+ `spec-sync init-config: refusing — --state ${bucket}=… is not a lifecycle bucket`,
1068
+ ` expected one of ${LIFECYCLE_BUCKETS.join(', ')}`,
1069
+ ])
1070
+ }
1071
+ }
1072
+
1073
+ // The loader's own merge, so anything it would reject is rejected now rather
1074
+ // than on the first command that reads the file.
1075
+ let effective
1076
+ try {
1077
+ effective = mergeConfig(configDefaults(), draft)
1078
+ } catch (error) {
1079
+ return fail([`spec-sync init-config: refusing — ${error.message}`])
1080
+ }
1081
+
1082
+ // Workspace state names, when the caller discovered them.
1083
+ let workspace = null
1084
+ if (flags.statesFile) {
1085
+ if (!fs.existsSync(flags.statesFile)) {
1086
+ return fail([`spec-sync init-config: refusing — no such --states file: ${flags.statesFile}`])
1087
+ }
1088
+ let parsed
1089
+ try {
1090
+ parsed = JSON.parse(fs.readFileSync(flags.statesFile, 'utf-8'))
1091
+ } catch (error) {
1092
+ return fail([`spec-sync init-config: refusing — --states is not valid JSON: ${error.message}`])
1093
+ }
1094
+ if (!Array.isArray(parsed)) {
1095
+ return fail(['spec-sync init-config: refusing — --states must be a JSON array of state names'])
1096
+ }
1097
+ workspace = parsed.map((s) => String(s)).filter(Boolean)
1098
+
1099
+ const missing = validateStates(effective, workspace)
1100
+ if (missing.length) {
1101
+ const lines = ['spec-sync init-config: refusing — configured state name(s) not in the workspace', '']
1102
+ for (const { bucket, configured, suggestion } of stateSuggestions(effective, workspace)) {
1103
+ lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
1104
+ if (suggestion) lines.push(` pass --state ${bucket}="${suggestion}"`)
1105
+ }
1106
+ lines.push(
1107
+ '',
1108
+ ` available: ${workspace.join(', ') || '(the workspace reported none)'}`,
1109
+ ' Linear silently ignores an unknown issue state, so writing this would',
1110
+ ' have produced a mirror that never moves.',
1111
+ )
1112
+ return fail(lines, { missing })
1113
+ }
1114
+ }
1115
+
1116
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1117
+ fs.writeFileSync(file, JSON.stringify(draft, null, 2) + '\n', 'utf-8')
1118
+
1119
+ const checked = Object.keys(effective.states).length
1120
+ if (flags.json) {
1121
+ out.write(
1122
+ JSON.stringify(
1123
+ {
1124
+ ok: true,
1125
+ file: rel,
1126
+ replaced: existed,
1127
+ wrote: draft,
1128
+ validated: {
1129
+ teamId: flags.teamId,
1130
+ states: workspace ? { checked, against: workspace.length, missing: [] } : null,
1131
+ },
1132
+ },
1133
+ null,
1134
+ 2,
1135
+ ) + '\n',
1136
+ )
1137
+ return 0
1138
+ }
1139
+
1140
+ const lines = [`spec-sync init-config: wrote ${rel}`]
1141
+ lines.push(` team: ${flags.teamKey ? `${flags.teamKey} · ` : ''}${flags.teamId}`)
1142
+ lines.push(` project: ${flags.projectId || '(none — team only; the picker offers the rest)'}`)
1143
+ if (draft.intake) {
1144
+ const bits = []
1145
+ if (intake.label) bits.push(`inbox "${intake.label}"`)
1146
+ if (intake.bugLabels) bits.push(`bug: ${intake.bugLabels.join(', ')}`)
1147
+ if (intake.hotfixLabels) bits.push(`hotfix: ${intake.hotfixLabels.join(', ')}`)
1148
+ lines.push(` intake: ${bits.join(' · ')}`)
1149
+ }
1150
+ if (workspace) {
1151
+ lines.push(` states: ${checked} checked against ${workspace.length} workspace state(s) — all present`)
1152
+ } else {
1153
+ lines.push(` states: NOT validated — the defaults (${Object.values(effective.states).join(', ')}) are assumed`)
1154
+ lines.push(' pass --states <file> to check them; a renamed state pushes clean and moves nothing')
1155
+ }
1156
+ lines.push(` Wrote ${Object.keys(draft).length} section(s); everything else uses the shipped defaults.`)
509
1157
  out.write(lines.join('\n') + '\n')
510
1158
  return 0
511
1159
  }
512
1160
 
1161
+ // Drop null/undefined so a GraphQL input never carries a key it has no value
1162
+ // for — Linear rejects an explicit null where it expects a field to be absent.
1163
+ function withoutNull(obj) {
1164
+ const out = {}
1165
+ for (const [k, v] of Object.entries(obj)) if (v !== null && v !== undefined) out[k] = v
1166
+ return out
1167
+ }
1168
+
513
1169
  async function specSync(rest, io = {}) {
514
1170
  const out = io.out || process.stdout
515
1171
  const err = io.err || process.stderr
516
1172
  const [sub, ...args] = rest
517
1173
  let dir = io.cwd || process.cwd()
518
1174
  const positional = []
519
- const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null }
1175
+ 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,
1176
+ force: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
520
1177
  for (let i = 0; i < args.length; i++) {
521
1178
  if (args[i] === '--dir') dir = path.resolve(args[++i])
522
1179
  else if (args[i] === '--json') flags.json = true
523
1180
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
524
1181
  else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
1182
+ else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
1183
+ else if (args[i] === '--via') flags.via = args[++i]
1184
+ else if (args[i] === '--all') flags.all = args[++i]
1185
+ else if (args[i] === '--project') flags.project = args[++i]
525
1186
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
526
1187
  else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
527
1188
  else if (args[i] === '--issue') flags.issue = args[++i]
528
1189
  else if (args[i] === '--url') flags.url = args[++i]
529
1190
  else if (args[i] === '--sub') flags.subs.push(args[++i])
1191
+ else if (args[i] === '--force') flags.force = true
1192
+ else if (args[i] === '--team-id') flags.teamId = String(args[++i] || '').trim()
1193
+ else if (args[i] === '--team-key') flags.teamKey = String(args[++i] || '').trim()
1194
+ else if (args[i] === '--project-id') flags.projectId = String(args[++i] || '').trim()
1195
+ else if (args[i] === '--intake-label') flags.intakeLabel = String(args[++i] || '').trim()
1196
+ else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
1197
+ else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
1198
+ else if (args[i] === '--state') {
1199
+ // `--state complete=Shipped`, repeatable. Only the buckets a workspace
1200
+ // actually renamed get written; the rest keep the defaults.
1201
+ const [bucket, ...rest] = String(args[++i] || '').split('=')
1202
+ flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
1203
+ } else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
530
1204
  else positional.push(args[i])
531
1205
  }
532
1206
  dir = path.resolve(dir)
1207
+ // Injection seam, alongside cwd/out/err: `env` supplies the key lookup and
1208
+ // `adapter`/`fetch` stand in for the network, so `apply` is exercised end to
1209
+ // end offline. Production passes none of them and gets the real thing.
1210
+ flags.env = io.env || process.env
1211
+ if (io.adapter) flags.adapter = io.adapter
1212
+ if (io.fetch) flags.fetch = io.fetch
1213
+
1214
+ // Dispatched ahead of the load on purpose: this is the command you run when
1215
+ // there is no config, and `--force` must be able to replace one that is
1216
+ // malformed enough for the loader to throw on.
1217
+ if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
533
1218
 
534
1219
  const { config, present } = loadLinearConfig(dir)
535
1220
  if (!present) {
@@ -553,6 +1238,12 @@ async function specSync(rest, io = {}) {
553
1238
  return 0
554
1239
  case 'status':
555
1240
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
1241
+ case 'projects':
1242
+ return (await specSyncProjects(dir, config, flags, out)) || 0
1243
+ case 'states':
1244
+ return (await specSyncStates(dir, config, flags, out)) || 0
1245
+ case 'apply':
1246
+ return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
556
1247
  case 'verify':
557
1248
  return specSyncVerify(dir, config, positional[0], flags, out) || 0
558
1249
  case 'linked':
@@ -562,8 +1253,15 @@ async function specSync(rest, io = {}) {
562
1253
  out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
563
1254
  ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
564
1255
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
1256
+ ' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
1257
+ ' skitterspec spec-sync projects [--via api|mcp] [--json]\n' +
1258
+ ' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
1259
+ ' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
565
1260
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
566
- ' skitterspec spec-sync linked [--json]\n')
1261
+ ' skitterspec spec-sync linked [--json]\n' +
1262
+ ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1263
+ ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1264
+ ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
567
1265
  return 0
568
1266
  }
569
1267
  }