@skitterbyte/skitterspec-linear 12.0.0 → 14.0.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/MIGRATION.md +296 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +7 -3
- package/assets/core/env.config.md +90 -30
- package/assets/core/linear.config.md +58 -0
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +1787 -0
- package/assets/rules/spec-planning.md +250 -10
- package/assets/rules/spec-reports.md +321 -0
- package/assets/skills/spec/SKILL.md +33 -5
- package/assets/skills/spec-bug/SKILL.md +172 -9
- package/assets/skills/spec-cancel/SKILL.md +98 -21
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +94 -25
- package/assets/skills/spec-diff/SKILL.md +678 -0
- package/assets/skills/spec-hotfix/SKILL.md +172 -11
- package/assets/skills/spec-init/SKILL.md +56 -7
- package/assets/skills/spec-linear-setup/SKILL.md +55 -1
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +419 -7
- package/assets/skills/spec-push/SKILL.md +32 -8
- package/assets/skills/spec-review/SKILL.md +40 -5
- package/assets/skills/spec-reviewed/SKILL.md +258 -0
- package/assets/skills/spec-start/SKILL.md +386 -106
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +40 -4
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1808 -89
- package/src/env/building.js +143 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +58 -9
- package/src/env/hooks.js +117 -0
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1536 -0
- package/src/env/serve.js +573 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +150 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +854 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +49 -3
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- package/LICENSE +0 -21
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* provider-neutral engine (`@skitterbyte/skitterspec-sync-core`):
|
|
10
10
|
*
|
|
11
11
|
* spec-sync normalize <spec> print the local projection (JSON)
|
|
12
|
-
* spec-sync
|
|
12
|
+
* spec-sync plan <spec> print the create/update PLAN the skill applies
|
|
13
13
|
* (requires --workspace-states; see stateCheckFailure)
|
|
14
14
|
* spec-sync stamp <spec> write returned ids back into the spec files
|
|
15
15
|
* spec-sync record <spec> write the last-pushed snapshot (after apply)
|
|
@@ -44,6 +44,7 @@ const {
|
|
|
44
44
|
stateSuggestions,
|
|
45
45
|
lintPhases,
|
|
46
46
|
writeFrontmatter,
|
|
47
|
+
deleteFrontmatter,
|
|
47
48
|
stampSubIssueId,
|
|
48
49
|
listPhaseFiles,
|
|
49
50
|
compareStored,
|
|
@@ -52,6 +53,7 @@ const {
|
|
|
52
53
|
deriveRecordedKey,
|
|
53
54
|
isEmptyRetarget,
|
|
54
55
|
dirtyPaths,
|
|
56
|
+
phaseModeFor,
|
|
55
57
|
} = require('../sync-core')
|
|
56
58
|
|
|
57
59
|
const {
|
|
@@ -69,11 +71,16 @@ const { runChecks } = require('./doctor.js')
|
|
|
69
71
|
const {
|
|
70
72
|
storePath,
|
|
71
73
|
storeMode,
|
|
74
|
+
readStore,
|
|
75
|
+
userForTeam,
|
|
72
76
|
fingerprint,
|
|
73
77
|
writeKey,
|
|
78
|
+
writeUser,
|
|
74
79
|
writeKeyCommand,
|
|
75
80
|
removeKey,
|
|
81
|
+
removeUser,
|
|
76
82
|
} = require('./credentials.js')
|
|
83
|
+
const { resolveIdentity, displayNameOf } = require('./identity.js')
|
|
77
84
|
|
|
78
85
|
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
79
86
|
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
@@ -212,7 +219,7 @@ function specSyncNormalize(dir, config, specArg, out, err) {
|
|
|
212
219
|
out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
// `spec-sync
|
|
222
|
+
// `spec-sync plan <spec> [--json]` — print the create/update PLAN diffed against
|
|
216
223
|
// the last-pushed snapshot. Machine-readable by default; the /spec-push skill
|
|
217
224
|
// applies it over MCP then calls `record`.
|
|
218
225
|
function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
@@ -231,7 +238,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
231
238
|
return 0
|
|
232
239
|
}
|
|
233
240
|
const p = r.plan
|
|
234
|
-
const lines = [`spec-sync
|
|
241
|
+
const lines = [`spec-sync plan: ${identifier}`, ...warningLines(snapshotDir, config)]
|
|
235
242
|
if (p.legacy) lines.push(...legacyLines(p.legacy))
|
|
236
243
|
if (p.phasesDeferred) lines.push(...deferredLines(p.phasesDeferred))
|
|
237
244
|
lines.push(...phaseModeLines(p.phaseMode, r.projection.status))
|
|
@@ -333,7 +340,7 @@ function stateCheckFailure(config, flags) {
|
|
|
333
340
|
if (flags.skipStateCheck) return null
|
|
334
341
|
if (!flags.workspaceStates) {
|
|
335
342
|
return [
|
|
336
|
-
'spec-sync
|
|
343
|
+
'spec-sync plan: refusing — the configured issue states have not been validated',
|
|
337
344
|
' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
|
|
338
345
|
' workflow-state names, which /spec-push fetches over MCP), or',
|
|
339
346
|
' --skip-state-check to push anyway.',
|
|
@@ -342,20 +349,20 @@ function stateCheckFailure(config, flags) {
|
|
|
342
349
|
]
|
|
343
350
|
}
|
|
344
351
|
if (!fs.existsSync(flags.workspaceStates)) {
|
|
345
|
-
return [`spec-sync
|
|
352
|
+
return [`spec-sync plan: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
|
|
346
353
|
}
|
|
347
354
|
let names
|
|
348
355
|
try {
|
|
349
356
|
names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
|
|
350
357
|
} catch (error) {
|
|
351
|
-
return [`spec-sync
|
|
358
|
+
return [`spec-sync plan: refusing — --workspace-states is not valid JSON: ${error.message}`]
|
|
352
359
|
}
|
|
353
360
|
const list = Array.isArray(names) ? names : []
|
|
354
361
|
const missing = validateStates(config, list)
|
|
355
362
|
if (missing.length) {
|
|
356
363
|
// Say what IS available, and what to use instead. "Done is not a state" sends
|
|
357
364
|
// you to the Linear UI to go and look; naming the replacement does not.
|
|
358
|
-
const lines = ['spec-sync
|
|
365
|
+
const lines = ['spec-sync plan: refusing — configured state name(s) not in the workspace', '']
|
|
359
366
|
for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
|
|
360
367
|
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
361
368
|
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
@@ -454,6 +461,77 @@ function specSyncStamp(dir, config, specArg, flags, out) {
|
|
|
454
461
|
return 0
|
|
455
462
|
}
|
|
456
463
|
|
|
464
|
+
/**
|
|
465
|
+
* `spec-sync assign <spec> [--to <id> [--name <n>]] [--release] [--json]`
|
|
466
|
+
*
|
|
467
|
+
* Record — or release — who owns a spec, by stamping `linear_assignee_id` /
|
|
468
|
+
* `linear_assignee_name` into the overview frontmatter. The counterpart to
|
|
469
|
+
* `spec-sync stamp`, which does the same job for issue ids.
|
|
470
|
+
*
|
|
471
|
+
* **It writes the repo and nothing else.** Pushing is the caller's next step,
|
|
472
|
+
* deliberately: the repo is the source of truth, so ownership changes here and
|
|
473
|
+
* the mirror catches up like any other edit. Splitting it that way also means a
|
|
474
|
+
* failed push leaves the claim recorded rather than lost.
|
|
475
|
+
*
|
|
476
|
+
* **Stamping is explicit, and never inferred by a push.** Only this verb (and
|
|
477
|
+
* the skills that call it) writes the field. A push that resolved identity for
|
|
478
|
+
* itself would quietly re-assign a spec to whoever happened to run it — which is
|
|
479
|
+
* how a teammate pushing someone else's branch would steal their work.
|
|
480
|
+
*/
|
|
481
|
+
function specSyncAssign(dir, config, specArg, flags, out) {
|
|
482
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
483
|
+
if (!snapshotDir) return 1
|
|
484
|
+
|
|
485
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
486
|
+
const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
|
|
487
|
+
const rel = path.relative(dir, snapshotDir)
|
|
488
|
+
|
|
489
|
+
const problems = []
|
|
490
|
+
if (flags.release && flags.to) problems.push('--release and --to are opposites — pass one')
|
|
491
|
+
if (!flags.release && !flags.to) problems.push('nothing to do — pass --to <user-id> or --release')
|
|
492
|
+
// An unlinked spec has no issue to assign. Refusing beats stamping a field
|
|
493
|
+
// that would sit in the file doing nothing until someone noticed.
|
|
494
|
+
if (!identifier) problems.push(`${rel} is not linked to Linear — /spec-push it first`)
|
|
495
|
+
|
|
496
|
+
if (problems.length) {
|
|
497
|
+
out.write(
|
|
498
|
+
['spec-sync assign: refusing to write — nothing was changed', ...problems.map((p) => ` ${p}`)].join('\n') + '\n',
|
|
499
|
+
)
|
|
500
|
+
return 1
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (flags.release) {
|
|
504
|
+
const removed = deleteFrontmatter(snapshotDir, config, ['linear_assignee_id', 'linear_assignee_name'])
|
|
505
|
+
if (flags.json) {
|
|
506
|
+
out.write(JSON.stringify({ spec: rel, issue: identifier, released: removed.length > 0 }, null, 2) + '\n')
|
|
507
|
+
return 0
|
|
508
|
+
}
|
|
509
|
+
out.write(
|
|
510
|
+
(removed.length
|
|
511
|
+
? `spec-sync assign: ${rel} released (${identifier})\n`
|
|
512
|
+
: `spec-sync assign: ${rel} records no assignee — nothing to release\n`) +
|
|
513
|
+
' next: push it, so Linear is unassigned too\n',
|
|
514
|
+
)
|
|
515
|
+
return 0
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
writeFrontmatter(snapshotDir, config, {
|
|
519
|
+
linear_assignee_id: flags.to,
|
|
520
|
+
// Display only — the projection never sends it. It exists so a reader (and
|
|
521
|
+
// `/spec-claim`) can name the person without a Linear round trip.
|
|
522
|
+
linear_assignee_name: flags.name || null,
|
|
523
|
+
})
|
|
524
|
+
if (flags.json) {
|
|
525
|
+
out.write(
|
|
526
|
+
JSON.stringify({ spec: rel, issue: identifier, id: flags.to, name: flags.name || null }, null, 2) + '\n',
|
|
527
|
+
)
|
|
528
|
+
return 0
|
|
529
|
+
}
|
|
530
|
+
const who = flags.name ? `${flags.name} (${flags.to})` : flags.to
|
|
531
|
+
out.write(`spec-sync assign: ${rel} assigned to ${who} (${identifier})\n next: push it, so Linear agrees\n`)
|
|
532
|
+
return 0
|
|
533
|
+
}
|
|
534
|
+
|
|
457
535
|
// `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
|
|
458
536
|
// files. The skill calls this AFTER applying the plan and stamping new ids.
|
|
459
537
|
function specSyncRecord(dir, config, specArg, out) {
|
|
@@ -521,12 +599,54 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
521
599
|
} else {
|
|
522
600
|
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
523
601
|
}
|
|
602
|
+
lines.push(...assigneeLines(remote, projection, plan))
|
|
524
603
|
}
|
|
525
604
|
|
|
526
605
|
out.write(lines.join('\n') + '\n')
|
|
527
606
|
return 0
|
|
528
607
|
}
|
|
529
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The assignee half of the drift report — nothing at all unless the repo opted
|
|
611
|
+
* `assignee` into `sync.fieldOwnership` (`projection.assignee` is then
|
|
612
|
+
* `undefined`, and a project that opted out must see no trace of the feature).
|
|
613
|
+
*
|
|
614
|
+
* The line says what will ACTUALLY happen, which is not the same as whether the
|
|
615
|
+
* two sides differ. A spec with no recorded assignee never overwrites Linear's,
|
|
616
|
+
* so printing "repo wins on next push" against a PM's assignment would be both
|
|
617
|
+
* an accusation and a lie — the same trap the `stage` branch above documents.
|
|
618
|
+
* The plan is the only honest witness to that, so it is what gets asked.
|
|
619
|
+
*/
|
|
620
|
+
function assigneeLines(remote, projection, plan) {
|
|
621
|
+
if (!projection || projection.assignee === undefined) return []
|
|
622
|
+
const remoteUser = remote && remote.assignee ? remote.assignee : null
|
|
623
|
+
const remoteName = remoteUser ? remoteUser.name || remoteUser.id : null
|
|
624
|
+
const local = projection.assignee || null
|
|
625
|
+
const willPush = !!(plan && plan.issue && 'assignee' in plan.issue)
|
|
626
|
+
|
|
627
|
+
if (!remoteUser && !local) return [' assignee: none — the spec records nobody, and neither does Linear']
|
|
628
|
+
if (remoteUser && local && remoteUser.id === local) return [` assignee: ${remoteName} — matches the spec`]
|
|
629
|
+
|
|
630
|
+
// The two sides differ. WHICH LINE IS HONEST depends on the plan, not on the
|
|
631
|
+
// disagreement — there are three ways to differ and only one is drift.
|
|
632
|
+
if (willPush) {
|
|
633
|
+
const to = local || 'nobody'
|
|
634
|
+
return [` drift: Linear assignee is ${remoteName || 'nobody'} but the spec records ${to} (repo wins on next push)`]
|
|
635
|
+
}
|
|
636
|
+
if (!local) {
|
|
637
|
+
// Decision 6, reported: the spec never recorded anyone, so nothing is sent
|
|
638
|
+
// and this assignment is safe where it is.
|
|
639
|
+
return [` assignee: Linear has ${remoteName}; the spec records nobody and will not overwrite it`]
|
|
640
|
+
}
|
|
641
|
+
// Recorded, already pushed, and changed in Linear since. The snapshot says we
|
|
642
|
+
// are in sync, so nothing re-asserts it — and claiming "repo wins" here would
|
|
643
|
+
// promise a correction that will never come.
|
|
644
|
+
return [
|
|
645
|
+
` assignee: Linear shows ${remoteName || 'nobody'} but the spec records ${local} — ` +
|
|
646
|
+
'already pushed, so it will not be re-sent',
|
|
647
|
+
]
|
|
648
|
+
}
|
|
649
|
+
|
|
530
650
|
/**
|
|
531
651
|
* `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
|
|
532
652
|
* against what we sent, and report any lost text.
|
|
@@ -899,6 +1019,21 @@ function gatherState(dir, flags) {
|
|
|
899
1019
|
: { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
|
|
900
1020
|
}
|
|
901
1021
|
|
|
1022
|
+
// Identity, read from the CACHE ONLY — deliberately no network call, because
|
|
1023
|
+
// doctor is offline until `--check-remote`. A key that could derive an identity
|
|
1024
|
+
// but has not yet reports as "not cached", with `whoami` as the fix, rather
|
|
1025
|
+
// than as a fault: not having asked is not the same as having no answer.
|
|
1026
|
+
if (config) {
|
|
1027
|
+
const owned = !!(config.sync && config.sync.fieldOwnership && 'assignee' in config.sync.fieldOwnership)
|
|
1028
|
+
state.identity = { owned }
|
|
1029
|
+
if (owned) {
|
|
1030
|
+
const store = readStore(storePath(flags.env || process.env))
|
|
1031
|
+
const cached = store.ok ? userForTeam(store.store, state.tracker.teamId) : null
|
|
1032
|
+
if (cached) Object.assign(state.identity, { ok: true, id: cached.id, name: cached.name, source: 'store' })
|
|
1033
|
+
else state.identity.error = 'no identity cached yet — run `spec-sync whoami` to resolve it'
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
902
1037
|
state._config = config
|
|
903
1038
|
// Declared here, checked only if `--check-remote` fetches the state TYPES the
|
|
904
1039
|
// shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
|
|
@@ -1753,6 +1888,451 @@ async function specSyncProjects(dir, config, flags, out) {
|
|
|
1753
1888
|
return 0
|
|
1754
1889
|
}
|
|
1755
1890
|
|
|
1891
|
+
// Linear's PRIORITY is an enum, not a magnitude: 1=Urgent, 2=High, 3=Medium,
|
|
1892
|
+
// 4=Low — and 0 means "nobody set one", not "lowest". Sorting on the raw number
|
|
1893
|
+
// would float every unprioritised issue to the top, which is exactly backwards,
|
|
1894
|
+
// so 0 is ranked last explicitly.
|
|
1895
|
+
const priorityRank = (issue) => {
|
|
1896
|
+
const p = Number(issue && issue.priority)
|
|
1897
|
+
return !Number.isFinite(p) || p === 0 ? Number.POSITIVE_INFINITY : p
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const UNPRIORITISED = (issues) => issues.length > 0 && issues.every((i) => priorityRank(i) === Infinity)
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Linear's own Backlog order, reproduced — which is the only reason to ask
|
|
1904
|
+
* Linear this rather than `ls specs/backlog/`.
|
|
1905
|
+
*
|
|
1906
|
+
* `sortOrder` is a float and LOWER sorts higher: it is the position Linear
|
|
1907
|
+
* stores when someone drags a card, never a score. Created-date is deliberately
|
|
1908
|
+
* not consulted — created order is the thing this ordering exists to replace.
|
|
1909
|
+
*
|
|
1910
|
+
* The identifier tie-break is what makes the listing reproducible: without it
|
|
1911
|
+
* two issues a person never dragged apart share a `sortOrder`, and the rows
|
|
1912
|
+
* reorder between runs on nothing but the order Linear happened to return.
|
|
1913
|
+
*/
|
|
1914
|
+
function backlogOrder(a, b) {
|
|
1915
|
+
const pa = priorityRank(a)
|
|
1916
|
+
const pb = priorityRank(b)
|
|
1917
|
+
if (pa !== pb) return pa - pb
|
|
1918
|
+
const sa = Number(a && a.sortOrder)
|
|
1919
|
+
const sb = Number(b && b.sortOrder)
|
|
1920
|
+
const na = Number.isFinite(sa) ? sa : 0
|
|
1921
|
+
const nb = Number.isFinite(sb) ? sb : 0
|
|
1922
|
+
if (na !== nb) return na - nb
|
|
1923
|
+
return String((a && a.identifier) || '').localeCompare(
|
|
1924
|
+
String((b && b.identifier) || ''),
|
|
1925
|
+
'en',
|
|
1926
|
+
{ numeric: true },
|
|
1927
|
+
)
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// What a row says when Linear holds an issue this branch has no spec file for.
|
|
1931
|
+
const UNLINKED = '— (not linked here)'
|
|
1932
|
+
|
|
1933
|
+
/** `2/5 — Wire the toggle`, or nothing at all when no phase is in progress. */
|
|
1934
|
+
function phaseLabel(phase) {
|
|
1935
|
+
if (!phase) return ''
|
|
1936
|
+
const base = `${phase.n}/${phase.total}${phase.title ? ` — ${phase.title}` : ''}`
|
|
1937
|
+
return phase.alsoInProgress > 0 ? `${base} (+${phase.alsoInProgress} more in progress)` : base
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const sameState = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Phase order among a spec issue's children — the order `/spec-push` minted them
|
|
1944
|
+
* in, which is phase-file order. That is the **identifier**, numerically, so
|
|
1945
|
+
* `SKS-9` stays ahead of `SKS-10`.
|
|
1946
|
+
*
|
|
1947
|
+
* `sortOrder` is deliberately NOT consulted, though it is fetched for `--next`.
|
|
1948
|
+
* It is the position in Linear's Backlog view, and for sub-issues it tracks
|
|
1949
|
+
* nothing about the plan: this repo's own SKS-115 carries -105486, -108489,
|
|
1950
|
+
* -109484, -5066, -10982 across phases 1-5, which sorts phase 4 last and would
|
|
1951
|
+
* have printed a spec on phase 4 as "5/5". Nor is `backlogOrder` reused — a
|
|
1952
|
+
* priority set on one phase would renumber the spec, and "phase 3 of 5" is a
|
|
1953
|
+
* position in the plan, never a ranking.
|
|
1954
|
+
*
|
|
1955
|
+
* KNOWN BLIND SPOT: a phase inserted between two existing ones mints a HIGHER
|
|
1956
|
+
* identifier than its position, so it numbers as if appended. Phase files are
|
|
1957
|
+
* appended in practice, and the alternative — reading the local phase files —
|
|
1958
|
+
* cannot number a spec that is not on this branch, which is the case the
|
|
1959
|
+
* listing exists for.
|
|
1960
|
+
*/
|
|
1961
|
+
function phaseOrder(a, b) {
|
|
1962
|
+
return String((a && a.identifier) || '').localeCompare(
|
|
1963
|
+
String((b && b.identifier) || ''),
|
|
1964
|
+
'en',
|
|
1965
|
+
{ numeric: true },
|
|
1966
|
+
)
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* `spec-sync list [--state <name> …|--all] [--next N] [--limit N] [--archived]
|
|
1971
|
+
* [--json]`
|
|
1972
|
+
* — every spec issue Linear holds, with the local spec folder that owns it.
|
|
1973
|
+
*
|
|
1974
|
+
* Linear answers what is live and who holds it; the repo answers what each
|
|
1975
|
+
* issue is CALLED on disk, because `/spec-start` moves a spec to
|
|
1976
|
+
* `specs/in-progress/` on that spec's own branch — so `ls specs/` on the base
|
|
1977
|
+
* branch is wrong about exactly the specs you most want to see.
|
|
1978
|
+
*/
|
|
1979
|
+
async function specSyncList(dir, config, flags, out) {
|
|
1980
|
+
// `--next N` is the top of the BACKLOG in Linear's own order, so it fixes the
|
|
1981
|
+
// scope itself. Combined with --state/--all one of them would have to lose,
|
|
1982
|
+
// silently — the failure this feature exists to avoid. Refuse instead, before
|
|
1983
|
+
// any transport work, so the answer does not depend on whether a key is set.
|
|
1984
|
+
const next = Number.isFinite(flags.next) && flags.next > 0 ? flags.next : null
|
|
1985
|
+
|
|
1986
|
+
// Exactly one scope selector. Each of these FIXES the set of states, so any
|
|
1987
|
+
// two of them means one wins silently — and a scope the user did not get is
|
|
1988
|
+
// the same failure as a cap they were not told about (decision 4). Checked
|
|
1989
|
+
// before any transport work, so the answer never depends on whether a key is
|
|
1990
|
+
// set. `--state` is repeatable and counts once: several names are one scope.
|
|
1991
|
+
const scopeFlags = [
|
|
1992
|
+
next !== null && '--next',
|
|
1993
|
+
flags.all === true && '--all',
|
|
1994
|
+
flags.stateArgs.length > 0 && '--state',
|
|
1995
|
+
flags.inProgress === true && '--in-progress',
|
|
1996
|
+
].filter(Boolean)
|
|
1997
|
+
if (scopeFlags.length > 1) {
|
|
1998
|
+
out.write(
|
|
1999
|
+
[
|
|
2000
|
+
`spec-sync list: ${scopeFlags.join(' and ')} each pick the scope, so they cannot be combined`,
|
|
2001
|
+
' use one of them — they are alternatives, not filters that stack.',
|
|
2002
|
+
].join('\n') + '\n',
|
|
2003
|
+
)
|
|
2004
|
+
return 1
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// `--mine` and `--by` are the same question asked two ways. Combining them
|
|
2008
|
+
// has no reading, and picking one would answer a question nobody asked.
|
|
2009
|
+
if (flags.mine && flags.by) {
|
|
2010
|
+
out.write(
|
|
2011
|
+
[
|
|
2012
|
+
'spec-sync list: --mine and --by both filter by assignee, so they cannot be combined',
|
|
2013
|
+
' use --mine for your own, or --by <user> for someone else.',
|
|
2014
|
+
].join('\n') + '\n',
|
|
2015
|
+
)
|
|
2016
|
+
return 1
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
2020
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
2021
|
+
const teamId = (config.linear && config.linear.teamId) || null
|
|
2022
|
+
|
|
2023
|
+
const degrade = (reason) => {
|
|
2024
|
+
if (flags.json) out.write(JSON.stringify({ transport, specs: null, reason }, null, 2) + '\n')
|
|
2025
|
+
else out.write(`spec-sync list: ${reason}\n`)
|
|
2026
|
+
return 0
|
|
2027
|
+
}
|
|
2028
|
+
if (transport === 'mcp') {
|
|
2029
|
+
return degrade(
|
|
2030
|
+
`transport = mcp — ${key.ok ? '--via mcp was requested' : key.error}; list issues over MCP instead`,
|
|
2031
|
+
)
|
|
2032
|
+
}
|
|
2033
|
+
if (!key.ok) return degrade(key.error)
|
|
2034
|
+
|
|
2035
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
2036
|
+
|
|
2037
|
+
// The scope. `--all` drops the state filter; `--state` names states directly;
|
|
2038
|
+
// the default reads the two live buckets out of `config.states` rather than
|
|
2039
|
+
// restating them, so a workspace that renamed "Backlog" needs no second edit.
|
|
2040
|
+
const wantedNames = next !== null
|
|
2041
|
+
? [config.states.backlog]
|
|
2042
|
+
: flags.inProgress === true
|
|
2043
|
+
? [config.states['in-progress']]
|
|
2044
|
+
: flags.all === true
|
|
2045
|
+
? null
|
|
2046
|
+
: flags.stateArgs.length
|
|
2047
|
+
? flags.stateArgs
|
|
2048
|
+
: [config.states.backlog, config.states['in-progress']]
|
|
2049
|
+
|
|
2050
|
+
let stateIds = null
|
|
2051
|
+
if (wantedNames) {
|
|
2052
|
+
let workspaceStates
|
|
2053
|
+
try {
|
|
2054
|
+
workspaceStates = await adapter.listIssueStates(teamId)
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
return degrade(`could not read the workspace's issue states (${error.message})`)
|
|
2057
|
+
}
|
|
2058
|
+
// A POSITIVE signal, not an absence: this is the workspace's FULL state
|
|
2059
|
+
// list, freshly read, so a name missing from it is genuinely missing rather
|
|
2060
|
+
// than merely outside a narrower query. Linear silently ignores an unknown
|
|
2061
|
+
// state, so an unchecked name would return an empty listing that looks like
|
|
2062
|
+
// "no specs" — see .claude/rules/negative-checks.md.
|
|
2063
|
+
const byName = new Map(
|
|
2064
|
+
workspaceStates.filter((s) => s && s.name).map((s) => [String(s.name).toLowerCase(), s]),
|
|
2065
|
+
)
|
|
2066
|
+
const missing = wantedNames.filter((n) => !byName.has(String(n).toLowerCase()))
|
|
2067
|
+
if (missing.length) {
|
|
2068
|
+
out.write(
|
|
2069
|
+
[
|
|
2070
|
+
`spec-sync list: unknown state ${missing.map((n) => `"${n}"`).join(', ')}`,
|
|
2071
|
+
` the workspace has: ${workspaceStates.map((s) => s.name).join(', ')}`,
|
|
2072
|
+
' fix specs/.core/linear.config.json → states, or pass --state with one of those.',
|
|
2073
|
+
].join('\n') + '\n',
|
|
2074
|
+
)
|
|
2075
|
+
return 1
|
|
2076
|
+
}
|
|
2077
|
+
stateIds = wantedNames.map((n) => byName.get(String(n).toLowerCase()).id)
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// --- who holds it (decision 9) --------------------------------------------
|
|
2081
|
+
//
|
|
2082
|
+
// Both filters reuse `feat-linear-assignment`'s identity rather than keeping a
|
|
2083
|
+
// second copy: `--mine` through `resolveIdentity`, `--by` through the same
|
|
2084
|
+
// user search `spec-sync users` runs.
|
|
2085
|
+
let assignee = null
|
|
2086
|
+
if (flags.mine) {
|
|
2087
|
+
const who = await resolveIdentity(config, flags.env || process.env, { adapter })
|
|
2088
|
+
if (!who.ok) {
|
|
2089
|
+
// Exit 0, list NOTHING, and never prompt. An unresolvable identity is an
|
|
2090
|
+
// ordinary state — a shared or bot key, an offline machine, a repo
|
|
2091
|
+
// mid-setup — not a fault. The one thing that must not happen is dropping
|
|
2092
|
+
// the filter and printing the whole team's work under a `--mine` heading.
|
|
2093
|
+
const line = `spec-sync list: --mine needs to know who you are, and it does not (${who.reason})`
|
|
2094
|
+
if (flags.json) {
|
|
2095
|
+
out.write(JSON.stringify({ transport: 'api', scope: null, specs: null, reason: line }, null, 2) + '\n')
|
|
2096
|
+
} else {
|
|
2097
|
+
out.write(
|
|
2098
|
+
[line, ' run `spec-sync whoami --set <id>` to say so, or use --by <user>.'].join('\n') + '\n',
|
|
2099
|
+
)
|
|
2100
|
+
}
|
|
2101
|
+
return 0
|
|
2102
|
+
}
|
|
2103
|
+
assignee = { id: who.id, name: who.name }
|
|
2104
|
+
} else if (flags.by) {
|
|
2105
|
+
// Resolved through Linear, never a hand-typed id — a wrong uuid filters to
|
|
2106
|
+
// nothing and looks exactly like "that person has no specs".
|
|
2107
|
+
let found
|
|
2108
|
+
try {
|
|
2109
|
+
found = await adapter.searchUsers(String(flags.by), { limit: 50 })
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
return degrade(`could not search users (${error.message}); Linear is unreachable`)
|
|
2112
|
+
}
|
|
2113
|
+
const users = (found && found.users) || []
|
|
2114
|
+
// Unlike --mine above, these two exit NON-ZERO: the argument is wrong and
|
|
2115
|
+
// only the caller can fix it, which is the same shape as an unknown --state.
|
|
2116
|
+
// Never fall back to the whole team — the heading would promise one person.
|
|
2117
|
+
if (!users.length) {
|
|
2118
|
+
out.write(
|
|
2119
|
+
[
|
|
2120
|
+
`spec-sync list: no Linear user matches ${JSON.stringify(String(flags.by))}`,
|
|
2121
|
+
' `spec-sync users <term>` lists who the workspace has.',
|
|
2122
|
+
].join('\n') + '\n',
|
|
2123
|
+
)
|
|
2124
|
+
return 1
|
|
2125
|
+
}
|
|
2126
|
+
if (users.length > 1) {
|
|
2127
|
+
const lines = [`spec-sync list: ${users.length} users match ${JSON.stringify(String(flags.by))}`]
|
|
2128
|
+
for (const u of users.slice(0, 10)) {
|
|
2129
|
+
lines.push(` ${displayNameOf(u) || '(unnamed)'}${u.email ? ` ${u.email}` : ''}`)
|
|
2130
|
+
}
|
|
2131
|
+
if (users.length > 10) lines.push(` … and ${users.length - 10} more`)
|
|
2132
|
+
lines.push(' narrow it — an email address matches exactly one person.')
|
|
2133
|
+
out.write(lines.join('\n') + '\n')
|
|
2134
|
+
return 1
|
|
2135
|
+
}
|
|
2136
|
+
assignee = { id: users[0].id, name: displayNameOf(users[0]) }
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
let page
|
|
2140
|
+
try {
|
|
2141
|
+
page = await adapter.listIssues({
|
|
2142
|
+
teamId,
|
|
2143
|
+
stateIds,
|
|
2144
|
+
assigneeId: assignee ? assignee.id : null,
|
|
2145
|
+
parentless: true,
|
|
2146
|
+
first: null,
|
|
2147
|
+
includeArchived: !!flags.archived,
|
|
2148
|
+
})
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
return degrade(`could not list issues (${error.message}); Linear is unreachable`)
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Filtered again here, not merely in the query: `parent { id }` is the
|
|
2154
|
+
// structural discriminator between a spec issue and a phase sub-issue
|
|
2155
|
+
// (decision 2), and it is cheap enough to assert on the data we actually got
|
|
2156
|
+
// rather than trust that the filter was applied.
|
|
2157
|
+
const issues = (page.nodes || []).filter((n) => n && !(n.parent && n.parent.id))
|
|
2158
|
+
|
|
2159
|
+
// Ordered BEFORE projection, so the comparator reads Linear's own fields
|
|
2160
|
+
// (`priority`, `sortOrder`) rather than a row shape that never carried them.
|
|
2161
|
+
// Only `--next` reorders: the plain listing keeps Linear's return order, which
|
|
2162
|
+
// is what every existing caller already reads.
|
|
2163
|
+
const ordered = next === null ? issues : issues.slice().sort(backlogOrder)
|
|
2164
|
+
|
|
2165
|
+
const byIdentifier = new Map(
|
|
2166
|
+
listSpecs(dir, config)
|
|
2167
|
+
.filter((s) => s.identifier)
|
|
2168
|
+
.map((s) => [s.identifier, s]),
|
|
2169
|
+
)
|
|
2170
|
+
// Identifier → Linear uuid, for the phase lookup below. Kept beside the rows
|
|
2171
|
+
// rather than on them, so the --json shape stays exactly what it prints.
|
|
2172
|
+
const uuidOf = new Map(ordered.map((i) => [i.identifier, i.id]))
|
|
2173
|
+
|
|
2174
|
+
const rows = ordered.map((issue) => {
|
|
2175
|
+
const local = byIdentifier.get(issue.identifier) || null
|
|
2176
|
+
return {
|
|
2177
|
+
identifier: issue.identifier || '',
|
|
2178
|
+
title: issue.title || '',
|
|
2179
|
+
state: (issue.state && issue.state.name) || '',
|
|
2180
|
+
// An issue with no local match is REPORTED, not dropped. A teammate's
|
|
2181
|
+
// unlanded spec, or one authored inside another spec's worktree, is not
|
|
2182
|
+
// on this branch at all — which is the very gap the listing exists to
|
|
2183
|
+
// close, so hiding it would defeat the command.
|
|
2184
|
+
spec: local ? local.spec : null,
|
|
2185
|
+
bucket: local ? local.bucket : null,
|
|
2186
|
+
assignee: (issue.assignee && issue.assignee.name) || null,
|
|
2187
|
+
// Filled in below for in-progress rows only; `null` on every row that was
|
|
2188
|
+
// never looked up, so the shape does not vary by state.
|
|
2189
|
+
phase: null,
|
|
2190
|
+
url: issue.url || null,
|
|
2191
|
+
}
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
const total = rows.length
|
|
2195
|
+
// `--next N` IS a cap, and announces itself as one (decision 4) — the count
|
|
2196
|
+
// line below reports it the same way `--limit` is reported.
|
|
2197
|
+
const limit = next !== null
|
|
2198
|
+
? next
|
|
2199
|
+
: Number.isFinite(flags.limit) && flags.limit > 0
|
|
2200
|
+
? flags.limit
|
|
2201
|
+
: null
|
|
2202
|
+
const shown = limit === null ? rows : rows.slice(0, limit)
|
|
2203
|
+
const capFlag = next !== null ? '--next' : '--limit'
|
|
2204
|
+
// The heading has to name the filter as well as the states: `showing 2 of 2`
|
|
2205
|
+
// under a bare "live" heading reads as the whole team's work when it is one
|
|
2206
|
+
// person's.
|
|
2207
|
+
const held = assignee ? `, assigned to ${assignee.name || assignee.id}` : ''
|
|
2208
|
+
const scope =
|
|
2209
|
+
(next !== null
|
|
2210
|
+
? `next ${next}`
|
|
2211
|
+
: flags.inProgress === true
|
|
2212
|
+
? `in progress (${wantedNames.join(', ')})`
|
|
2213
|
+
: flags.all === true
|
|
2214
|
+
? 'all states'
|
|
2215
|
+
: `${flags.stateArgs.length ? 'states' : 'live'} (${wantedNames.join(', ')})`) + held
|
|
2216
|
+
// Said whenever it is true, not treated as an edge case: a workspace where
|
|
2217
|
+
// nobody has set a priority is the common one, and there the order is a
|
|
2218
|
+
// person's drag-order rather than a ranking. Reading it as a ranking is the
|
|
2219
|
+
// mistake this line exists to prevent.
|
|
2220
|
+
const unprioritised = next !== null && UNPRIORITISED(ordered)
|
|
2221
|
+
|
|
2222
|
+
// --- the current phase, on in-progress rows only (decision 8) --------------
|
|
2223
|
+
//
|
|
2224
|
+
// One extra Linear call per in-progress row, and NONE for any other — a
|
|
2225
|
+
// backlog listing costs exactly what it did before. Run over `shown`, not
|
|
2226
|
+
// `rows`, so a capped listing does not pay for phases it will not print.
|
|
2227
|
+
const inProgressState = config.states['in-progress']
|
|
2228
|
+
const phaseFailures = []
|
|
2229
|
+
for (const row of shown) {
|
|
2230
|
+
if (!sameState(row.state, inProgressState)) continue
|
|
2231
|
+
// An `inline`-mode spec keeps its phases in the issue DESCRIPTION, so there
|
|
2232
|
+
// are no children to read. Asking anyway would report "no phase in progress"
|
|
2233
|
+
// for a spec that is mid-build — an absence that means nothing. The bucket
|
|
2234
|
+
// is the local one where we have it; where we do not, Linear's own state is
|
|
2235
|
+
// in-progress by construction, which is the bucket that mode resolves for.
|
|
2236
|
+
if (phaseModeFor(row.bucket || 'in-progress', config) === 'inline') continue
|
|
2237
|
+
|
|
2238
|
+
const uuid = uuidOf.get(row.identifier)
|
|
2239
|
+
if (!uuid) continue
|
|
2240
|
+
let children
|
|
2241
|
+
try {
|
|
2242
|
+
children = await adapter.listSubIssues(uuid)
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
// Bias the unknown case to inaction: a failed lookup leaves the row
|
|
2245
|
+
// without a phase and says so once, rather than failing the whole
|
|
2246
|
+
// listing or printing a phase it never read.
|
|
2247
|
+
phaseFailures.push(row.identifier)
|
|
2248
|
+
continue
|
|
2249
|
+
}
|
|
2250
|
+
const phases = (children || []).slice().sort(phaseOrder)
|
|
2251
|
+
const live = phases.filter((c) => c && sameState(c.state && c.state.name, inProgressState))
|
|
2252
|
+
if (!live.length) continue
|
|
2253
|
+
row.phase = {
|
|
2254
|
+
n: phases.indexOf(live[0]) + 1,
|
|
2255
|
+
total: phases.length,
|
|
2256
|
+
title: (live[0].title || '').trim(),
|
|
2257
|
+
// More than one phase in progress is a real shape, not an error — two
|
|
2258
|
+
// people can work a spec at once. Report the count rather than picking
|
|
2259
|
+
// one silently.
|
|
2260
|
+
alsoInProgress: live.length - 1,
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
if (flags.json) {
|
|
2265
|
+
out.write(
|
|
2266
|
+
JSON.stringify(
|
|
2267
|
+
{
|
|
2268
|
+
transport: 'api',
|
|
2269
|
+
scope,
|
|
2270
|
+
archived: !!flags.archived,
|
|
2271
|
+
showing: shown.length,
|
|
2272
|
+
total,
|
|
2273
|
+
...(next !== null ? { unprioritised } : {}),
|
|
2274
|
+
assignedTo: assignee,
|
|
2275
|
+
specs: shown,
|
|
2276
|
+
},
|
|
2277
|
+
null,
|
|
2278
|
+
2,
|
|
2279
|
+
) + '\n',
|
|
2280
|
+
)
|
|
2281
|
+
return 0
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
const lines = [
|
|
2285
|
+
`spec-sync list: transport = api, ${scope} — showing ${shown.length} of ${total}` +
|
|
2286
|
+
(next !== null ? ' in backlog' : ''),
|
|
2287
|
+
]
|
|
2288
|
+
if (unprioritised) {
|
|
2289
|
+
lines.push(" every candidate is unprioritised — this is Linear's manual Backlog order, not a ranking")
|
|
2290
|
+
}
|
|
2291
|
+
// Say what was NOT shown, every time (decisions 4 and 5). A listing that
|
|
2292
|
+
// implies a completeness it never verified is this command's failure mode.
|
|
2293
|
+
lines.push(
|
|
2294
|
+
flags.archived
|
|
2295
|
+
? ' archived issues included'
|
|
2296
|
+
: ' archived issues excluded (--archived to include)',
|
|
2297
|
+
)
|
|
2298
|
+
if (limit !== null && total > shown.length) {
|
|
2299
|
+
lines.push(` ${total - shown.length} more not shown — ${capFlag} ${total} for all of them`)
|
|
2300
|
+
}
|
|
2301
|
+
if (page.pageInfo && page.pageInfo.hasNextPage) {
|
|
2302
|
+
lines.push(' ! Linear reported further pages it did not return — this listing may be short')
|
|
2303
|
+
}
|
|
2304
|
+
if (phaseFailures.length) {
|
|
2305
|
+
lines.push(
|
|
2306
|
+
` ! could not read the phases of ${phaseFailures.join(', ')} — those rows show no phase`,
|
|
2307
|
+
)
|
|
2308
|
+
}
|
|
2309
|
+
lines.push('')
|
|
2310
|
+
if (!shown.length) {
|
|
2311
|
+
lines.push(' no spec issues in scope')
|
|
2312
|
+
} else {
|
|
2313
|
+
const idW = Math.max(...shown.map((r) => r.identifier.length))
|
|
2314
|
+
const stW = Math.max(...shown.map((r) => r.state.length))
|
|
2315
|
+
const pad = ' '.repeat(idW + stW + 4)
|
|
2316
|
+
const specW = Math.max(...shown.map((r) => (r.spec || UNLINKED).length))
|
|
2317
|
+
for (const row of shown) {
|
|
2318
|
+
// Who holds it, and where the work is — the two things the repo could not
|
|
2319
|
+
// have told them. Appended rather than columnised further: both are empty
|
|
2320
|
+
// on most rows, and padding for a column that is usually blank buys
|
|
2321
|
+
// nothing but width.
|
|
2322
|
+
const held = [row.assignee, phaseLabel(row.phase)].filter(Boolean).join(' · ')
|
|
2323
|
+
lines.push(
|
|
2324
|
+
` ${row.identifier.padEnd(idW)} ${row.state.padEnd(stW)} ` +
|
|
2325
|
+
(held ? `${(row.spec || UNLINKED).padEnd(specW)} ${held}` : row.spec || UNLINKED),
|
|
2326
|
+
)
|
|
2327
|
+
if (row.title) lines.push(` ${pad}${row.title}`)
|
|
2328
|
+
}
|
|
2329
|
+
lines.push('')
|
|
2330
|
+
lines.push(' start one with: /spec-start <name>')
|
|
2331
|
+
}
|
|
2332
|
+
out.write(lines.join('\n') + '\n')
|
|
2333
|
+
return 0
|
|
2334
|
+
}
|
|
2335
|
+
|
|
1756
2336
|
/**
|
|
1757
2337
|
* Apply one spec's plan. Returns what happened rather than printing it, so the
|
|
1758
2338
|
* single-spec command and the bulk loop report in their own voices while sharing
|
|
@@ -1784,6 +2364,9 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1784
2364
|
projectId: project || (config.linear && config.linear.projectId) || null,
|
|
1785
2365
|
description: plan.issue && plan.issue.description,
|
|
1786
2366
|
stateId: stateId(plan.issue && plan.issue.state),
|
|
2367
|
+
// Assert only. An issue being minted has no assignee to retract, so
|
|
2368
|
+
// `withoutNull` dropping a null here is exactly right.
|
|
2369
|
+
assigneeId: (plan.issue && plan.issue.assignee) || null,
|
|
1787
2370
|
}))
|
|
1788
2371
|
if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
|
|
1789
2372
|
parentId = created.id
|
|
@@ -1797,10 +2380,18 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1797
2380
|
parentId = existing.id
|
|
1798
2381
|
result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
|
|
1799
2382
|
if (plan.issue) {
|
|
1800
|
-
|
|
2383
|
+
const updates = withoutNull({
|
|
1801
2384
|
description: plan.issue.description,
|
|
1802
2385
|
stateId: stateId(plan.issue.state),
|
|
1803
|
-
})
|
|
2386
|
+
})
|
|
2387
|
+
// APPLIED AFTER `withoutNull`, and that ordering is the whole point: null
|
|
2388
|
+
// is MEANINGFUL for this field — it is how Linear unassigns an issue — so
|
|
2389
|
+
// passing it through the stripper would drop exactly the clear that
|
|
2390
|
+
// finishing a spec depends on, and the mirror would keep a finished spec
|
|
2391
|
+
// assigned forever. The plan only carries the key when there is something
|
|
2392
|
+
// to say, so `in` is the test, not truthiness.
|
|
2393
|
+
if ('assignee' in plan.issue) updates.assigneeId = plan.issue.assignee ?? null
|
|
2394
|
+
await adapter.updateIssue(existing.id, updates)
|
|
1804
2395
|
lines.push(` issue updated: ${identifier}`)
|
|
1805
2396
|
}
|
|
1806
2397
|
}
|
|
@@ -1861,6 +2452,196 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1861
2452
|
return { result, lines, lost }
|
|
1862
2453
|
}
|
|
1863
2454
|
|
|
2455
|
+
/**
|
|
2456
|
+
* `spec-sync whoami [--json] [--set <id> [--name <n>]] [--unset]`
|
|
2457
|
+
*
|
|
2458
|
+
* Report — and cache — who the operator is in Linear, which is the fact
|
|
2459
|
+
* assignment rests on. See `identity.js` for why it is derived rather than
|
|
2460
|
+
* configured.
|
|
2461
|
+
*
|
|
2462
|
+
* **Exit code 0 even when the answer is "unknown".** This deliberately differs
|
|
2463
|
+
* from `credentials status`, which exits 1 with no key: that command is a
|
|
2464
|
+
* READINESS check, and a missing key means the thing you asked about cannot
|
|
2465
|
+
* work. This one answers a question, and "nobody is identified here" is a valid
|
|
2466
|
+
* answer — a shared key, an offline machine, a repo mid-setup. Exiting non-zero
|
|
2467
|
+
* would make every caller treat an ordinary state as a fault, which is how an
|
|
2468
|
+
* advisory check turns into one that accuses.
|
|
2469
|
+
*
|
|
2470
|
+
* A successful DERIVE is cached; a read from the cache is not re-written. The
|
|
2471
|
+
* caching lives here rather than in `resolveIdentity` so that asking the
|
|
2472
|
+
* question never has a side effect — see that module's header.
|
|
2473
|
+
*/
|
|
2474
|
+
async function specSyncWhoami(dir, config, flags, out) {
|
|
2475
|
+
const env = flags.env || process.env
|
|
2476
|
+
const file = storePath(env)
|
|
2477
|
+
const teamId = (config.linear && config.linear.teamId) || ''
|
|
2478
|
+
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
2479
|
+
const label = teamKey ? `${teamId} (${teamKey})` : teamId
|
|
2480
|
+
|
|
2481
|
+
// The store is keyed by team, so without one there is nowhere to read from or
|
|
2482
|
+
// write to. That IS a broken config rather than an ordinary unknown, so unlike
|
|
2483
|
+
// the rest of this command it exits non-zero.
|
|
2484
|
+
if (!teamId) {
|
|
2485
|
+
out.write(
|
|
2486
|
+
'spec-sync whoami: no linear.teamId in specs/.core/linear.config.json.\n' +
|
|
2487
|
+
' Identity is stored per team — run `spec-sync init-config` first.\n',
|
|
2488
|
+
)
|
|
2489
|
+
return 1
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
if (flags.unset) {
|
|
2493
|
+
const r = removeUser(file, teamId, {})
|
|
2494
|
+
if (!r.ok) {
|
|
2495
|
+
out.write(`spec-sync whoami: ${r.reason}\n`)
|
|
2496
|
+
return 1
|
|
2497
|
+
}
|
|
2498
|
+
out.write(
|
|
2499
|
+
r.removed
|
|
2500
|
+
? `spec-sync whoami: forgot the identity for ${label} (the API key is untouched)\n`
|
|
2501
|
+
: `spec-sync whoami: no identity stored for ${label} — nothing to forget\n`,
|
|
2502
|
+
)
|
|
2503
|
+
return 0
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
if (flags.set) {
|
|
2507
|
+
const r = writeUser(file, teamId, { id: flags.set, name: flags.name }, {})
|
|
2508
|
+
if (!r.ok) {
|
|
2509
|
+
out.write(`spec-sync whoami: ${r.reason}\n`)
|
|
2510
|
+
return 1
|
|
2511
|
+
}
|
|
2512
|
+
if (flags.json) {
|
|
2513
|
+
out.write(
|
|
2514
|
+
JSON.stringify({ id: flags.set, name: flags.name || null, source: 'set', store: r.path }, null, 2) + '\n',
|
|
2515
|
+
)
|
|
2516
|
+
return 0
|
|
2517
|
+
}
|
|
2518
|
+
const shown = flags.name ? `${flags.name} (${flags.set})` : flags.set
|
|
2519
|
+
out.write(`spec-sync whoami: identity for ${label} set to ${shown} in ${r.path} (600)\n`)
|
|
2520
|
+
return 0
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const identity = await resolveIdentity(config, env, { adapter: flags.adapter, fetch: flags.fetch })
|
|
2524
|
+
|
|
2525
|
+
if (identity.ok && identity.source === 'viewer') {
|
|
2526
|
+
// Cache what the network just told us, so the next `/spec-start` costs no
|
|
2527
|
+
// call. A failed write is not fatal: we HAVE the answer, and re-deriving it
|
|
2528
|
+
// next time is a slower success, not a wrong one.
|
|
2529
|
+
const cached = writeUser(file, teamId, { id: identity.id, name: identity.name }, {})
|
|
2530
|
+
if (!cached.ok) identity.cacheWarning = cached.reason
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
if (flags.json) {
|
|
2534
|
+
out.write(
|
|
2535
|
+
JSON.stringify(
|
|
2536
|
+
{
|
|
2537
|
+
team: label,
|
|
2538
|
+
store: file,
|
|
2539
|
+
ok: identity.ok,
|
|
2540
|
+
id: identity.id || null,
|
|
2541
|
+
name: identity.name || null,
|
|
2542
|
+
source: identity.source,
|
|
2543
|
+
reason: identity.ok ? null : identity.reason || null,
|
|
2544
|
+
},
|
|
2545
|
+
null,
|
|
2546
|
+
2,
|
|
2547
|
+
) + '\n',
|
|
2548
|
+
)
|
|
2549
|
+
return 0
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
if (!identity.ok) {
|
|
2553
|
+
out.write(
|
|
2554
|
+
[
|
|
2555
|
+
'spec-sync whoami: unknown — no identity for this workspace',
|
|
2556
|
+
` ${identity.reason}`,
|
|
2557
|
+
' Set one by hand:',
|
|
2558
|
+
' skitterspec spec-sync whoami --set <linear-user-id> --name "<your name>"',
|
|
2559
|
+
' Find the id with: skitterspec spec-sync users <name-or-email>',
|
|
2560
|
+
].join('\n') + '\n',
|
|
2561
|
+
)
|
|
2562
|
+
return 0
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
const where = identity.source === 'store' ? `cached in ${file}` : 'derived from the API key'
|
|
2566
|
+
const lines = [
|
|
2567
|
+
`spec-sync whoami: ${identity.name || '(unnamed)'} <${identity.id}>`,
|
|
2568
|
+
` team: ${label}`,
|
|
2569
|
+
` source: ${where}`,
|
|
2570
|
+
]
|
|
2571
|
+
if (identity.cacheWarning) lines.push(` note: not cached — ${identity.cacheWarning}`)
|
|
2572
|
+
out.write(lines.join('\n') + '\n')
|
|
2573
|
+
return 0
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* `spec-sync users [<query>] [--json] [--limit N] [--cursor C]`
|
|
2578
|
+
*
|
|
2579
|
+
* Find a person by name or email. Both halves matter: search is what makes this
|
|
2580
|
+
* usable in a workspace of hundreds, and the bare listing is what answers "show
|
|
2581
|
+
* me everyone" in one of five. Linear's user query does both, so this is one
|
|
2582
|
+
* command rather than a `users` and a `search-users`.
|
|
2583
|
+
*
|
|
2584
|
+
* The engine answers on the API path and steps aside on the MCP path — the same
|
|
2585
|
+
* contract `states` and `projects` follow.
|
|
2586
|
+
*/
|
|
2587
|
+
async function specSyncUsers(dir, config, query, flags, out) {
|
|
2588
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
2589
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
2590
|
+
|
|
2591
|
+
if (transport === 'mcp') {
|
|
2592
|
+
if (flags.json) {
|
|
2593
|
+
out.write(
|
|
2594
|
+
JSON.stringify({ transport: 'mcp', reason: key.ok ? 'requested' : key.error, users: null }, null, 2) + '\n',
|
|
2595
|
+
)
|
|
2596
|
+
return 0
|
|
2597
|
+
}
|
|
2598
|
+
out.write(
|
|
2599
|
+
[
|
|
2600
|
+
'spec-sync users: transport = mcp',
|
|
2601
|
+
` ${key.ok ? '--via mcp was requested' : key.error}`,
|
|
2602
|
+
' search the workspace over MCP with the user-list tool',
|
|
2603
|
+
].join('\n') + '\n',
|
|
2604
|
+
)
|
|
2605
|
+
return 0
|
|
2606
|
+
}
|
|
2607
|
+
if (!key.ok) {
|
|
2608
|
+
out.write(`spec-sync users: refusing — ${key.error}\n`)
|
|
2609
|
+
return 1
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
2613
|
+
let page
|
|
2614
|
+
try {
|
|
2615
|
+
page = await adapter.searchUsers(query || '', { limit: flags.limit || 50, cursor: flags.cursor || null })
|
|
2616
|
+
} catch (error) {
|
|
2617
|
+
out.write(`spec-sync users: ${error.message}\n`)
|
|
2618
|
+
return 1
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const users = (page && page.users) || []
|
|
2622
|
+
if (flags.json) {
|
|
2623
|
+
out.write(
|
|
2624
|
+
JSON.stringify({ transport: 'api', users, nextCursor: (page && page.nextCursor) || null }, null, 2) + '\n',
|
|
2625
|
+
)
|
|
2626
|
+
return 0
|
|
2627
|
+
}
|
|
2628
|
+
if (!users.length) {
|
|
2629
|
+
out.write(`spec-sync users: no match for ${JSON.stringify(query || '')}\n`)
|
|
2630
|
+
return 0
|
|
2631
|
+
}
|
|
2632
|
+
const lines = [`spec-sync users: ${users.length} match(es)`]
|
|
2633
|
+
for (const u of users) {
|
|
2634
|
+
// A deactivated user still resolves by id and can still be assigned, so the
|
|
2635
|
+
// marker is the difference between a valid pick and a silently dead one.
|
|
2636
|
+
const inactive = u && u.active === false ? ' [deactivated]' : ''
|
|
2637
|
+
const email = u && u.email ? ` ${u.email}` : ''
|
|
2638
|
+
lines.push(` ${displayNameOf(u) || '(unnamed)'} <${u && u.id}>${email}${inactive}`)
|
|
2639
|
+
}
|
|
2640
|
+
if (page && page.nextCursor) lines.push(` … more — re-run with --cursor ${page.nextCursor}`)
|
|
2641
|
+
out.write(lines.join('\n') + '\n')
|
|
2642
|
+
return 0
|
|
2643
|
+
}
|
|
2644
|
+
|
|
1864
2645
|
/**
|
|
1865
2646
|
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
1866
2647
|
* `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
|
|
@@ -1906,7 +2687,7 @@ async function specSyncApply(dir, config, specArg, flags, out) {
|
|
|
1906
2687
|
if (!flags.plan) {
|
|
1907
2688
|
out.write(
|
|
1908
2689
|
'spec-sync apply: refusing to run without --plan <file>.\n' +
|
|
1909
|
-
' Get one with: skitterspec spec-sync
|
|
2690
|
+
' Get one with: skitterspec spec-sync plan <spec> --json > plan.json\n' +
|
|
1910
2691
|
' Or apply a whole bucket at once with --all <bucket>.\n',
|
|
1911
2692
|
)
|
|
1912
2693
|
return 1
|
|
@@ -2156,6 +2937,12 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
2156
2937
|
// shape — a blank key or a duplicate fails there, in one place, rather than
|
|
2157
2938
|
// being re-checked here and drifting from the loader.
|
|
2158
2939
|
if (flags.stages.length) draft.release = { stages: flags.stages }
|
|
2940
|
+
// Assignment is opt-in and rides `fieldOwnership` rather than a key of its
|
|
2941
|
+
// own. Only `assignee` is written: the loader merges this map PER KEY onto the
|
|
2942
|
+
// defaults, so restating the other three would freeze today's defaults into
|
|
2943
|
+
// the file and quietly opt the repo out of any later change to them — the very
|
|
2944
|
+
// thing "only the keys that differ" exists to avoid.
|
|
2945
|
+
if (flags.assign) draft.sync = { fieldOwnership: { assignee: 'push' } }
|
|
2159
2946
|
|
|
2160
2947
|
for (const bucket of Object.keys(flags.stateNames)) {
|
|
2161
2948
|
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
@@ -2512,7 +3299,7 @@ async function specSync(rest, io = {}) {
|
|
|
2512
3299
|
// after the loop.
|
|
2513
3300
|
const unknownFlags = []
|
|
2514
3301
|
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,
|
|
2515
|
-
mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null, stages: [] }
|
|
3302
|
+
mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, stateArgs: [], statesFile: null, stages: [], limit: null, next: null, archived: false, assign: false, mine: false, by: null, inProgress: false }
|
|
2516
3303
|
for (let i = 0; i < args.length; i++) {
|
|
2517
3304
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
2518
3305
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -2524,7 +3311,16 @@ async function specSync(rest, io = {}) {
|
|
|
2524
3311
|
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
2525
3312
|
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
2526
3313
|
else if (args[i] === '--via') flags.via = args[++i]
|
|
2527
|
-
|
|
3314
|
+
// `apply --all <bucket>` takes a value; `list --all` is a bare boolean. Read
|
|
3315
|
+
// off the subcommand rather than guessing from the next token — `list --all
|
|
3316
|
+
// --json` would otherwise swallow `--json` as the bucket and silently drop it.
|
|
3317
|
+
else if (args[i] === '--all') flags.all = sub === 'list' ? true : args[++i]
|
|
3318
|
+
else if (args[i] === '--archived') flags.archived = true
|
|
3319
|
+
else if (args[i] === '--limit') flags.limit = Number(args[++i])
|
|
3320
|
+
else if (args[i] === '--next') flags.next = Number(args[++i])
|
|
3321
|
+
else if (args[i] === '--mine') flags.mine = true
|
|
3322
|
+
else if (args[i] === '--by') flags.by = args[++i]
|
|
3323
|
+
else if (args[i] === '--in-progress') flags.inProgress = true
|
|
2528
3324
|
else if (args[i] === '--project') flags.project = args[++i]
|
|
2529
3325
|
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
2530
3326
|
else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
|
|
@@ -2535,6 +3331,17 @@ async function specSync(rest, io = {}) {
|
|
|
2535
3331
|
else if (args[i] === '--yes') flags.yes = true
|
|
2536
3332
|
else if (args[i] === '--check-remote') flags.remoteCheck = true
|
|
2537
3333
|
else if (args[i] === '--stdin') flags.stdin = true
|
|
3334
|
+
// `whoami --set <id> --name <n>`. Unlike `--key`, a user id and a name are
|
|
3335
|
+
// NOT secrets — they appear on every issue in Linear — so passing them as
|
|
3336
|
+
// arguments is safe and there is no hidden-prompt equivalent to reach for.
|
|
3337
|
+
else if (args[i] === '--set') flags.set = String(args[++i] || '').trim()
|
|
3338
|
+
else if (args[i] === '--name') flags.name = String(args[++i] || '').trim()
|
|
3339
|
+
else if (args[i] === '--unset') flags.unset = true
|
|
3340
|
+
else if (args[i] === '--to') flags.to = String(args[++i] || '').trim()
|
|
3341
|
+
else if (args[i] === '--release') flags.release = true
|
|
3342
|
+
else if (args[i] === '--assign') flags.assign = true
|
|
3343
|
+
else if (args[i] === '--limit') flags.limit = Number(args[++i]) || 0
|
|
3344
|
+
else if (args[i] === '--cursor') flags.cursor = String(args[++i] || '').trim()
|
|
2538
3345
|
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
2539
3346
|
else if (args[i] === '--key') {
|
|
2540
3347
|
// Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
|
|
@@ -2551,10 +3358,15 @@ async function specSync(rest, io = {}) {
|
|
|
2551
3358
|
else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
|
|
2552
3359
|
else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
|
|
2553
3360
|
else if (args[i] === '--state') {
|
|
2554
|
-
//
|
|
2555
|
-
//
|
|
2556
|
-
|
|
3361
|
+
// Two readings of one flag, kept apart rather than overloaded:
|
|
3362
|
+
// `init-config --state complete=Shipped` is a bucket=name PAIR, while
|
|
3363
|
+
// `list --state "In Progress"` is a bare Linear state NAME. Both are
|
|
3364
|
+
// recorded — `stateNames` for the former, the raw value in `stateArgs`
|
|
3365
|
+
// for the latter — so neither command has to infer which it was given.
|
|
3366
|
+
const raw = String(args[++i] || '').trim()
|
|
3367
|
+
const [bucket, ...rest] = raw.split('=')
|
|
2557
3368
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
3369
|
+
if (raw) flags.stateArgs.push(raw)
|
|
2558
3370
|
} else if (args[i] === '--stage') {
|
|
2559
3371
|
// `--stage test="On Test"`, repeatable. Order matters and is the order
|
|
2560
3372
|
// given, so the ladder is written exactly as the operator listed it.
|
|
@@ -2623,8 +3435,20 @@ async function specSync(rest, io = {}) {
|
|
|
2623
3435
|
switch (sub) {
|
|
2624
3436
|
case 'normalize':
|
|
2625
3437
|
return specSyncNormalize(dir, config, positional[0], out, err) || 0
|
|
2626
|
-
case '
|
|
3438
|
+
case 'plan':
|
|
2627
3439
|
return specSyncPush(dir, config, positional[0], flags, out, err) || 0
|
|
3440
|
+
// RETIRED NAME, deliberately recognised rather than left to fall through to
|
|
3441
|
+
// the usage block. `push` named the one subcommand that writes nothing — it
|
|
3442
|
+
// computes a plan with no network access, while `apply` does the writing —
|
|
3443
|
+
// and it collided with both the /spec-push skill and `git push`. A silent
|
|
3444
|
+
// alias would keep the old name alive forever; a generic "unknown
|
|
3445
|
+
// subcommand" would throw away the one hint that makes the break cheap.
|
|
3446
|
+
case 'push':
|
|
3447
|
+
err.write(
|
|
3448
|
+
'spec-sync push was renamed to spec-sync plan.\n' +
|
|
3449
|
+
'It computes the create/update plan and writes nothing; `spec-sync apply` applies it.\n',
|
|
3450
|
+
)
|
|
3451
|
+
return 1
|
|
2628
3452
|
case 'stamp':
|
|
2629
3453
|
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
2630
3454
|
case 'record':
|
|
@@ -2633,8 +3457,16 @@ async function specSync(rest, io = {}) {
|
|
|
2633
3457
|
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
2634
3458
|
case 'projects':
|
|
2635
3459
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
3460
|
+
case 'list':
|
|
3461
|
+
return (await specSyncList(dir, config, flags, out)) || 0
|
|
2636
3462
|
case 'states':
|
|
2637
3463
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
3464
|
+
case 'whoami':
|
|
3465
|
+
return (await specSyncWhoami(dir, config, flags, out)) || 0
|
|
3466
|
+
case 'assign':
|
|
3467
|
+
return specSyncAssign(dir, config, positional[0], flags, out)
|
|
3468
|
+
case 'users':
|
|
3469
|
+
return (await specSyncUsers(dir, config, positional[0], flags, out)) || 0
|
|
2638
3470
|
case 'released':
|
|
2639
3471
|
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2640
3472
|
case 'stage':
|
|
@@ -2655,13 +3487,17 @@ async function specSync(rest, io = {}) {
|
|
|
2655
3487
|
default:
|
|
2656
3488
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
2657
3489
|
' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
|
|
2658
|
-
' skitterspec spec-sync
|
|
3490
|
+
' skitterspec spec-sync plan <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
2659
3491
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
2660
3492
|
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
2661
3493
|
' skitterspec spec-sync projects [--via api|mcp] [--json]\n' +
|
|
3494
|
+
' skitterspec spec-sync whoami [--set <id> [--name N]] [--unset] [--json]\n' +
|
|
3495
|
+
' skitterspec spec-sync users [<name-or-email>] [--limit N] [--cursor C] [--json]\n' +
|
|
3496
|
+
' skitterspec spec-sync assign <spec> --to <id> [--name N] | --release [--json]\n' +
|
|
2662
3497
|
' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
|
|
2663
3498
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
2664
3499
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
3500
|
+
' skitterspec spec-sync list [--state <name> …|--all|--in-progress] [--next N] [--mine|--by <user>] [--limit N] [--archived] [--json]\n' +
|
|
2665
3501
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2666
3502
|
' skitterspec spec-sync ref [<spec>] [--json]\n' +
|
|
2667
3503
|
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
@@ -2670,6 +3506,7 @@ async function specSync(rest, io = {}) {
|
|
|
2670
3506
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
2671
3507
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
2672
3508
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
3509
|
+
' [--assign]\n' +
|
|
2673
3510
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
2674
3511
|
return 0
|
|
2675
3512
|
}
|