@skitterbyte/skitterspec-linear 11.0.0 → 13.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 +260 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +48 -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 +9 -3
- package/assets/core/env.config.md +102 -30
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +67 -8
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +224 -15
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +64 -13
- package/assets/skills/spec-bug/SKILL.md +193 -27
- package/assets/skills/spec-cancel/SKILL.md +99 -8
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +123 -22
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +202 -22
- package/assets/skills/spec-init/SKILL.md +49 -9
- package/assets/skills/spec-linear-setup/SKILL.md +86 -7
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +300 -7
- package/assets/skills/spec-push/SKILL.md +45 -22
- package/assets/skills/spec-review/SKILL.md +59 -11
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +426 -66
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +47 -11
- package/assets/skills/spec-to-main/SKILL.md +42 -20
- package/package.json +11 -7
- package/src/cli.js +1710 -80
- package/src/env/building.js +143 -0
- package/src/env/classify.js +91 -0
- package/src/env/config.js +57 -9
- package/src/env/provision.js +192 -19
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +296 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/gating.js +155 -0
- package/src/init.js +124 -2
- package/src/prompts.js +10 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +874 -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 +74 -5
- 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))
|
|
@@ -242,6 +249,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
242
249
|
if (p.subIssues.update.length) lines.push(` sub-issues update: ${p.subIssues.update.map((s) => s.id).join(', ')}`)
|
|
243
250
|
lines.push(' (run with --json for the full plan the skill applies)')
|
|
244
251
|
}
|
|
252
|
+
lines.push(...unstampedLines(p))
|
|
245
253
|
out.write(lines.join('\n') + '\n')
|
|
246
254
|
return 0
|
|
247
255
|
}
|
|
@@ -249,6 +257,24 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
249
257
|
// `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
|
|
250
258
|
// plan or a status report is printed, because the alternative reading of a spec
|
|
251
259
|
// with no sub-issues is that its phase files failed to parse.
|
|
260
|
+
// Phases whose stamp is missing while the snapshot still remembers an id nobody
|
|
261
|
+
// claims. Reported OUTSIDE the empty-plan branch on purpose: a plan carrying
|
|
262
|
+
// nothing but these is "empty" for applying, and saying `up to date` about it
|
|
263
|
+
// would hide the one thing the operator has to act on.
|
|
264
|
+
function unstampedLines(plan) {
|
|
265
|
+
const u = (plan && plan.unstamped) || []
|
|
266
|
+
if (!u.length) return []
|
|
267
|
+
const lines = [
|
|
268
|
+
` unstamped: ${u.length} phase(s) have no linear_issue_id, and ${
|
|
269
|
+
u[0].candidates.length
|
|
270
|
+
} sub-issue(s) from the last push are unclaimed —`,
|
|
271
|
+
' not creating, since a lost stamp and a new phase look identical from here.',
|
|
272
|
+
]
|
|
273
|
+
for (const item of u) lines.push(` ${item.ref} — could be: ${item.candidates.join(', ')}`)
|
|
274
|
+
lines.push(' re-stamp the phase file (linear_issue_id) and push again.')
|
|
275
|
+
return lines
|
|
276
|
+
}
|
|
277
|
+
|
|
252
278
|
function deferredLines(n) {
|
|
253
279
|
return [
|
|
254
280
|
` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
|
|
@@ -314,7 +340,7 @@ function stateCheckFailure(config, flags) {
|
|
|
314
340
|
if (flags.skipStateCheck) return null
|
|
315
341
|
if (!flags.workspaceStates) {
|
|
316
342
|
return [
|
|
317
|
-
'spec-sync
|
|
343
|
+
'spec-sync plan: refusing — the configured issue states have not been validated',
|
|
318
344
|
' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
|
|
319
345
|
' workflow-state names, which /spec-push fetches over MCP), or',
|
|
320
346
|
' --skip-state-check to push anyway.',
|
|
@@ -323,20 +349,20 @@ function stateCheckFailure(config, flags) {
|
|
|
323
349
|
]
|
|
324
350
|
}
|
|
325
351
|
if (!fs.existsSync(flags.workspaceStates)) {
|
|
326
|
-
return [`spec-sync
|
|
352
|
+
return [`spec-sync plan: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
|
|
327
353
|
}
|
|
328
354
|
let names
|
|
329
355
|
try {
|
|
330
356
|
names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
|
|
331
357
|
} catch (error) {
|
|
332
|
-
return [`spec-sync
|
|
358
|
+
return [`spec-sync plan: refusing — --workspace-states is not valid JSON: ${error.message}`]
|
|
333
359
|
}
|
|
334
360
|
const list = Array.isArray(names) ? names : []
|
|
335
361
|
const missing = validateStates(config, list)
|
|
336
362
|
if (missing.length) {
|
|
337
363
|
// Say what IS available, and what to use instead. "Done is not a state" sends
|
|
338
364
|
// you to the Linear UI to go and look; naming the replacement does not.
|
|
339
|
-
const lines = ['spec-sync
|
|
365
|
+
const lines = ['spec-sync plan: refusing — configured state name(s) not in the workspace', '']
|
|
340
366
|
for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
|
|
341
367
|
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
342
368
|
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
@@ -435,6 +461,77 @@ function specSyncStamp(dir, config, specArg, flags, out) {
|
|
|
435
461
|
return 0
|
|
436
462
|
}
|
|
437
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
|
+
|
|
438
535
|
// `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
|
|
439
536
|
// files. The skill calls this AFTER applying the plan and stamping new ids.
|
|
440
537
|
function specSyncRecord(dir, config, specArg, out) {
|
|
@@ -483,6 +580,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
483
580
|
const u = plan.subIssues.update.length
|
|
484
581
|
lines.push(` push: pending — ${n} to create, ${u} to update${plan.issue ? ', issue changed' : ''}`)
|
|
485
582
|
}
|
|
583
|
+
lines.push(...unstampedLines(plan))
|
|
486
584
|
|
|
487
585
|
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
488
586
|
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
@@ -501,12 +599,54 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
501
599
|
} else {
|
|
502
600
|
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
503
601
|
}
|
|
602
|
+
lines.push(...assigneeLines(remote, projection, plan))
|
|
504
603
|
}
|
|
505
604
|
|
|
506
605
|
out.write(lines.join('\n') + '\n')
|
|
507
606
|
return 0
|
|
508
607
|
}
|
|
509
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
|
+
|
|
510
650
|
/**
|
|
511
651
|
* `spec-sync verify <spec> --stored <file>` — compare what the tracker STORED
|
|
512
652
|
* against what we sent, and report any lost text.
|
|
@@ -879,6 +1019,21 @@ function gatherState(dir, flags) {
|
|
|
879
1019
|
: { ok: false, error: `no key for ${state.tracker.teamKey || state.tracker.teamId}${why ? ` — ${why}` : ''}` }
|
|
880
1020
|
}
|
|
881
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
|
+
|
|
882
1037
|
state._config = config
|
|
883
1038
|
// Declared here, checked only if `--check-remote` fetches the state TYPES the
|
|
884
1039
|
// shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
|
|
@@ -1733,6 +1888,451 @@ async function specSyncProjects(dir, config, flags, out) {
|
|
|
1733
1888
|
return 0
|
|
1734
1889
|
}
|
|
1735
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
|
+
|
|
1736
2336
|
/**
|
|
1737
2337
|
* Apply one spec's plan. Returns what happened rather than printing it, so the
|
|
1738
2338
|
* single-spec command and the bulk loop report in their own voices while sharing
|
|
@@ -1764,6 +2364,9 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1764
2364
|
projectId: project || (config.linear && config.linear.projectId) || null,
|
|
1765
2365
|
description: plan.issue && plan.issue.description,
|
|
1766
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,
|
|
1767
2370
|
}))
|
|
1768
2371
|
if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
|
|
1769
2372
|
parentId = created.id
|
|
@@ -1777,10 +2380,18 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1777
2380
|
parentId = existing.id
|
|
1778
2381
|
result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
|
|
1779
2382
|
if (plan.issue) {
|
|
1780
|
-
|
|
2383
|
+
const updates = withoutNull({
|
|
1781
2384
|
description: plan.issue.description,
|
|
1782
2385
|
stateId: stateId(plan.issue.state),
|
|
1783
|
-
})
|
|
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)
|
|
1784
2395
|
lines.push(` issue updated: ${identifier}`)
|
|
1785
2396
|
}
|
|
1786
2397
|
}
|
|
@@ -1841,6 +2452,196 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
1841
2452
|
return { result, lines, lost }
|
|
1842
2453
|
}
|
|
1843
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
|
+
|
|
1844
2645
|
/**
|
|
1845
2646
|
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
1846
2647
|
* `spec-sync apply --all <bucket> [--via api|mcp] [--json]`
|
|
@@ -1886,7 +2687,7 @@ async function specSyncApply(dir, config, specArg, flags, out) {
|
|
|
1886
2687
|
if (!flags.plan) {
|
|
1887
2688
|
out.write(
|
|
1888
2689
|
'spec-sync apply: refusing to run without --plan <file>.\n' +
|
|
1889
|
-
' Get one with: skitterspec spec-sync
|
|
2690
|
+
' Get one with: skitterspec spec-sync plan <spec> --json > plan.json\n' +
|
|
1890
2691
|
' Or apply a whole bucket at once with --all <bucket>.\n',
|
|
1891
2692
|
)
|
|
1892
2693
|
return 1
|
|
@@ -2136,6 +2937,12 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
2136
2937
|
// shape — a blank key or a duplicate fails there, in one place, rather than
|
|
2137
2938
|
// being re-checked here and drifting from the loader.
|
|
2138
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' } }
|
|
2139
2946
|
|
|
2140
2947
|
for (const bucket of Object.keys(flags.stateNames)) {
|
|
2141
2948
|
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
@@ -2492,7 +3299,7 @@ async function specSync(rest, io = {}) {
|
|
|
2492
3299
|
// after the loop.
|
|
2493
3300
|
const unknownFlags = []
|
|
2494
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,
|
|
2495
|
-
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 }
|
|
2496
3303
|
for (let i = 0; i < args.length; i++) {
|
|
2497
3304
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
2498
3305
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -2504,7 +3311,16 @@ async function specSync(rest, io = {}) {
|
|
|
2504
3311
|
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
2505
3312
|
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
2506
3313
|
else if (args[i] === '--via') flags.via = args[++i]
|
|
2507
|
-
|
|
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
|
|
2508
3324
|
else if (args[i] === '--project') flags.project = args[++i]
|
|
2509
3325
|
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
2510
3326
|
else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
|
|
@@ -2515,6 +3331,17 @@ async function specSync(rest, io = {}) {
|
|
|
2515
3331
|
else if (args[i] === '--yes') flags.yes = true
|
|
2516
3332
|
else if (args[i] === '--check-remote') flags.remoteCheck = true
|
|
2517
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()
|
|
2518
3345
|
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
2519
3346
|
else if (args[i] === '--key') {
|
|
2520
3347
|
// Consumed and DELIBERATELY DISCARDED. A secret in argv is visible in
|
|
@@ -2531,10 +3358,15 @@ async function specSync(rest, io = {}) {
|
|
|
2531
3358
|
else if (args[i] === '--bug-labels') flags.bugLabels = labelList(args[++i])
|
|
2532
3359
|
else if (args[i] === '--hotfix-labels') flags.hotfixLabels = labelList(args[++i])
|
|
2533
3360
|
else if (args[i] === '--state') {
|
|
2534
|
-
//
|
|
2535
|
-
//
|
|
2536
|
-
|
|
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('=')
|
|
2537
3368
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
3369
|
+
if (raw) flags.stateArgs.push(raw)
|
|
2538
3370
|
} else if (args[i] === '--stage') {
|
|
2539
3371
|
// `--stage test="On Test"`, repeatable. Order matters and is the order
|
|
2540
3372
|
// given, so the ladder is written exactly as the operator listed it.
|
|
@@ -2603,8 +3435,20 @@ async function specSync(rest, io = {}) {
|
|
|
2603
3435
|
switch (sub) {
|
|
2604
3436
|
case 'normalize':
|
|
2605
3437
|
return specSyncNormalize(dir, config, positional[0], out, err) || 0
|
|
2606
|
-
case '
|
|
3438
|
+
case 'plan':
|
|
2607
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
|
|
2608
3452
|
case 'stamp':
|
|
2609
3453
|
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
2610
3454
|
case 'record':
|
|
@@ -2613,8 +3457,16 @@ async function specSync(rest, io = {}) {
|
|
|
2613
3457
|
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
2614
3458
|
case 'projects':
|
|
2615
3459
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
3460
|
+
case 'list':
|
|
3461
|
+
return (await specSyncList(dir, config, flags, out)) || 0
|
|
2616
3462
|
case 'states':
|
|
2617
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
|
|
2618
3470
|
case 'released':
|
|
2619
3471
|
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2620
3472
|
case 'stage':
|
|
@@ -2635,13 +3487,17 @@ async function specSync(rest, io = {}) {
|
|
|
2635
3487
|
default:
|
|
2636
3488
|
out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
|
|
2637
3489
|
' skitterspec spec-sync credentials <status|set|unset> [--stdin] [--json]\n' +
|
|
2638
|
-
' skitterspec spec-sync
|
|
3490
|
+
' skitterspec spec-sync plan <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
|
|
2639
3491
|
' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
|
|
2640
3492
|
' skitterspec spec-sync states [--via api|mcp] [--json]\n' +
|
|
2641
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' +
|
|
2642
3497
|
' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
|
|
2643
3498
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
2644
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' +
|
|
2645
3501
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2646
3502
|
' skitterspec spec-sync ref [<spec>] [--json]\n' +
|
|
2647
3503
|
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
@@ -2650,6 +3506,7 @@ async function specSync(rest, io = {}) {
|
|
|
2650
3506
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
2651
3507
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
2652
3508
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
3509
|
+
' [--assign]\n' +
|
|
2653
3510
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
2654
3511
|
return 0
|
|
2655
3512
|
}
|