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