@skitterbyte/skitterspec-linear 15.0.0 → 17.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 +218 -0
- package/README.md +34 -1
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/SETUP.md +10 -6
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +100 -23
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +49 -22
- package/assets/review/page.html +1044 -101
- package/assets/rules/spec-planning.md +35 -3
- package/assets/rules/spec-reports.md +131 -20
- package/assets/skills/spec/SKILL.md +161 -4
- package/assets/skills/spec-bug/SKILL.md +68 -30
- package/assets/skills/spec-cancel/SKILL.md +2 -2
- package/assets/skills/spec-claim/SKILL.md +12 -4
- package/assets/skills/spec-complete/SKILL.md +2 -2
- package/assets/skills/spec-diff/SKILL.md +131 -36
- package/assets/skills/spec-hotfix/SKILL.md +61 -25
- package/assets/skills/spec-linear-setup/SKILL.md +19 -11
- package/assets/skills/spec-next/SKILL.md +121 -58
- package/assets/skills/spec-push/SKILL.md +45 -0
- package/assets/skills/spec-review/SKILL.md +89 -2
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +26 -3
- package/assets/skills/spec-status/SKILL.md +20 -6
- package/assets/skills/spec-sync/SKILL.md +1 -0
- package/package.json +2 -2
- package/src/cli.js +913 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +542 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +60 -9
- package/src/vendor/linear/api.js +111 -2
- package/src/vendor/linear/cli-sync.js +661 -11
- package/src/vendor/linear/config.js +41 -13
- package/src/vendor/linear/doctor.js +6 -5
- package/src/vendor/sync-core/index.js +11 -3
- package/src/vendor/sync-core/src/compare.js +65 -0
- package/src/vendor/sync-core/src/normalize.js +26 -0
|
@@ -54,6 +54,8 @@ const {
|
|
|
54
54
|
isEmptyRetarget,
|
|
55
55
|
dirtyPaths,
|
|
56
56
|
phaseModeFor,
|
|
57
|
+
ownsField,
|
|
58
|
+
remoteDescriptionEdited,
|
|
57
59
|
} = require('../sync-core')
|
|
58
60
|
|
|
59
61
|
const {
|
|
@@ -477,6 +479,13 @@ function specSyncStamp(dir, config, specArg, flags, out) {
|
|
|
477
479
|
* the skills that call it) writes the field. A push that resolved identity for
|
|
478
480
|
* itself would quietly re-assign a spec to whoever happened to run it — which is
|
|
479
481
|
* how a teammate pushing someone else's branch would steal their work.
|
|
482
|
+
*
|
|
483
|
+
* **And it refuses where the write could not travel.** Two preconditions, for
|
|
484
|
+
* one reason: an unlinked spec has no issue, and a repo that does not own
|
|
485
|
+
* `assignee` has no field. In either case the stamp would sit in the file doing
|
|
486
|
+
* nothing while this command's success line promised `next: push it, so Linear
|
|
487
|
+
* agrees`. The failure mode being guarded is not a silent skip — it is a true-
|
|
488
|
+
* sounding sentence, which is worse, because nothing later contradicts it.
|
|
480
489
|
*/
|
|
481
490
|
function specSyncAssign(dir, config, specArg, flags, out) {
|
|
482
491
|
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
@@ -492,6 +501,23 @@ function specSyncAssign(dir, config, specArg, flags, out) {
|
|
|
492
501
|
// An unlinked spec has no issue to assign. Refusing beats stamping a field
|
|
493
502
|
// that would sit in the file doing nothing until someone noticed.
|
|
494
503
|
if (!identifier) problems.push(`${rel} is not linked to Linear — /spec-push it first`)
|
|
504
|
+
// AND THE SAME REASONING FOR THE FIELD ITSELF. `toFieldSet` drops a field the
|
|
505
|
+
// repo does not own, so a stamp written here would never reach the tracker —
|
|
506
|
+
// while this command's success line says `next: push it, so Linear agrees`.
|
|
507
|
+
// That is not a silent skip, it is an assertion that is untrue, and the cost
|
|
508
|
+
// is someone believing a spec is assigned for as long as it takes them to
|
|
509
|
+
// look. `/spec-claim` already refuses on this; the engine beneath it did not.
|
|
510
|
+
//
|
|
511
|
+
// Applies to `--release` too. Nothing it could clear was ever pushed, so
|
|
512
|
+
// releasing is inert here as well, and one command in this function acting on
|
|
513
|
+
// a field the repo declares it does not own is the inconsistency that would
|
|
514
|
+
// teach the next reader the check is optional.
|
|
515
|
+
if (!ownsField(config, 'assignee')) {
|
|
516
|
+
problems.push(
|
|
517
|
+
'this repo does not own the assignee field — set sync.fieldOwnership.assignee ' +
|
|
518
|
+
`to "push" in ${CONFIG_FILE}`,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
495
521
|
|
|
496
522
|
if (problems.length) {
|
|
497
523
|
out.write(
|
|
@@ -600,12 +626,45 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
600
626
|
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
601
627
|
}
|
|
602
628
|
lines.push(...assigneeLines(remote, projection, plan))
|
|
629
|
+
lines.push(...descriptionDriftLines(remote, snapshot, identifier))
|
|
603
630
|
}
|
|
604
631
|
|
|
605
632
|
out.write(lines.join('\n') + '\n')
|
|
606
633
|
return 0
|
|
607
634
|
}
|
|
608
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The description half of the drift report: has somebody edited the issue's
|
|
638
|
+
* description on Linear since the repo last pushed it?
|
|
639
|
+
*
|
|
640
|
+
* ITS OWN BRANCH, NOT THE `drift:` ONE ABOVE. Workflow-state and description
|
|
641
|
+
* are independent facts about the same issue — a PM can move the state, edit
|
|
642
|
+
* the prose, or both — and folding them together would let either hide behind
|
|
643
|
+
* the other.
|
|
644
|
+
*
|
|
645
|
+
* SILENT ON EVERYTHING EXCEPT A POSITIVE ANSWER, which is the whole discipline
|
|
646
|
+
* here. `remoteDescriptionEdited` returns `null` for every cannot-tell — a
|
|
647
|
+
* snapshot written before this feature existed (which is every snapshot, on the
|
|
648
|
+
* first run after an upgrade), a `--remote` file that carries no description,
|
|
649
|
+
* a description that is not a string — and none of those is evidence that
|
|
650
|
+
* anything happened. There is deliberately no "drift: none" counterpart either:
|
|
651
|
+
* the state line has one because it is always askable, and this one is not.
|
|
652
|
+
* (`.claude/rules/negative-checks.md`.)
|
|
653
|
+
*/
|
|
654
|
+
function descriptionDriftLines(remote, snapshot, identifier) {
|
|
655
|
+
if (remoteDescriptionEdited(snapshot, remote) !== true) return []
|
|
656
|
+
const lines = [
|
|
657
|
+
` drift: ${identifier}'s description was edited on Linear since the last push`,
|
|
658
|
+
' (repo wins on next push — read it before pushing)',
|
|
659
|
+
]
|
|
660
|
+
// The URL, so the reader can go and look. This reports only THAT it changed
|
|
661
|
+
// and never what it now says: the text is the reader's to read on Linear, and
|
|
662
|
+
// pulling it in here would put a whole description into whatever is running
|
|
663
|
+
// this — the same rule the rendered diff already follows.
|
|
664
|
+
if (remote && typeof remote.url === 'string' && remote.url) lines.push(` ${remote.url}`)
|
|
665
|
+
return lines
|
|
666
|
+
}
|
|
667
|
+
|
|
609
668
|
/**
|
|
610
669
|
* The assignee half of the drift report — nothing at all unless the repo opted
|
|
611
670
|
* `assignee` into `sync.fieldOwnership` (`projection.assignee` is then
|
|
@@ -1024,7 +1083,10 @@ function gatherState(dir, flags) {
|
|
|
1024
1083
|
// but has not yet reports as "not cached", with `whoami` as the fix, rather
|
|
1025
1084
|
// than as a fault: not having asked is not the same as having no answer.
|
|
1026
1085
|
if (config) {
|
|
1027
|
-
|
|
1086
|
+
// `ownsField`, not `'assignee' in …`: the key is present in every config
|
|
1087
|
+
// once the defaults carry it, so the membership test would read as opted-in
|
|
1088
|
+
// for a repo that declined with `"assignee": "none"`.
|
|
1089
|
+
const owned = ownsField(config, 'assignee')
|
|
1028
1090
|
state.identity = { owned }
|
|
1029
1091
|
if (owned) {
|
|
1030
1092
|
const store = readStore(storePath(flags.env || process.env))
|
|
@@ -1851,6 +1913,154 @@ function configuredVocabularyLines(config, names) {
|
|
|
1851
1913
|
* Degrades rather than blocks: no key, or a Linear that will not answer, exits 0
|
|
1852
1914
|
* with an empty list and says why. A missing picker must never fail `/spec`.
|
|
1853
1915
|
*/
|
|
1916
|
+
/**
|
|
1917
|
+
* `reattach` — point a spec back at the issue it already has.
|
|
1918
|
+
*
|
|
1919
|
+
* A spec whose `linear_identifier` is gone had no way back: the next push read
|
|
1920
|
+
* it as unlinked and minted a second issue over a perfectly good one. That is
|
|
1921
|
+
* the SKS-283/284 incident — a stray `sed '1s/…'` clobbered a stamped phase
|
|
1922
|
+
* file's opening `---`, and the duplicate had to be cancelled by hand.
|
|
1923
|
+
*
|
|
1924
|
+
* THREE ANSWERS, AND ONLY ONE ACTS. Exactly one unclaimed candidate is stamped;
|
|
1925
|
+
* none says so; more than one refuses and names them. Choosing between two is
|
|
1926
|
+
* the guess `claimPending` refuses to make, and this refuses it for the same
|
|
1927
|
+
* reason.
|
|
1928
|
+
*
|
|
1929
|
+
* WHAT WOULD FOOL A FUZZY VERSION: a title that merely reads similarly. A
|
|
1930
|
+
* near-match is not evidence (`.claude/rules/negative-checks.md` rule 1), and
|
|
1931
|
+
* acting on one would re-point a spec at somebody else's issue — so the match is
|
|
1932
|
+
* EXACT. The cost is that a **renamed** spec is not found, which `--to` answers
|
|
1933
|
+
* and which is said out loud rather than discovered.
|
|
1934
|
+
*
|
|
1935
|
+
* IT WRITES AN ID AND NOTHING ELSE. No title, no description, no state: this is
|
|
1936
|
+
* the one command whose shape makes a pull tempting, and the one-way rule holds
|
|
1937
|
+
* here exactly as everywhere. The next push overwrites the mirror.
|
|
1938
|
+
*/
|
|
1939
|
+
async function specSyncReattach(dir, config, specArg, flags, out) {
|
|
1940
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
1941
|
+
if (!snapshotDir) return 1
|
|
1942
|
+
|
|
1943
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
1944
|
+
const already = linkedIdentifier(path.join(snapshotDir, overviewFile))
|
|
1945
|
+
if (already) {
|
|
1946
|
+
// A linked spec is not broken, and re-pointing one is a different act with
|
|
1947
|
+
// different consequences. Refuse rather than quietly moving it.
|
|
1948
|
+
out.write(`spec-sync reattach: already linked to ${already} — nothing to reattach\n`)
|
|
1949
|
+
return 1
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
1953
|
+
if (!key.ok && !flags.adapter) {
|
|
1954
|
+
out.write(`spec-sync reattach: ${key.error}\n`)
|
|
1955
|
+
return 1
|
|
1956
|
+
}
|
|
1957
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
1958
|
+
const teamId = (config.linear && config.linear.teamId) || null
|
|
1959
|
+
|
|
1960
|
+
let chosen = null
|
|
1961
|
+
if (flags.to) {
|
|
1962
|
+
if (!ID_RE.test(flags.to)) {
|
|
1963
|
+
out.write(`spec-sync reattach: --to ${flags.to} is not an id like SKI-42\n`)
|
|
1964
|
+
return 1
|
|
1965
|
+
}
|
|
1966
|
+
// NAMED, so nothing is searched and nothing is matched. This is the escape
|
|
1967
|
+
// for a spec whose title has changed since it was linked.
|
|
1968
|
+
const found = await adapter.readIssue(flags.to)
|
|
1969
|
+
if (!found || !found.identifier) {
|
|
1970
|
+
out.write(`spec-sync reattach: no issue ${flags.to}\n`)
|
|
1971
|
+
return 1
|
|
1972
|
+
}
|
|
1973
|
+
chosen = found
|
|
1974
|
+
} else {
|
|
1975
|
+
const title = String(readSnapshot(snapshotDir, config).title || '').trim()
|
|
1976
|
+
if (!title) {
|
|
1977
|
+
out.write('spec-sync reattach: the spec has no title to match on — name one with --to <ISSUE-REF>\n')
|
|
1978
|
+
return 1
|
|
1979
|
+
}
|
|
1980
|
+
let candidates
|
|
1981
|
+
try {
|
|
1982
|
+
candidates = await adapter.searchIssues({ query: title, teamId })
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
out.write(`spec-sync reattach: ${error.message}\n`)
|
|
1985
|
+
return 1
|
|
1986
|
+
}
|
|
1987
|
+
// The candidate set is what NO spec already holds: an issue another spec is
|
|
1988
|
+
// linked to is not an orphan, it is somebody's live mirror.
|
|
1989
|
+
const claimed = new Set(listSpecs(dir, config).map((sp) => sp.identifier).filter(Boolean))
|
|
1990
|
+
const unclaimed = (candidates || [])
|
|
1991
|
+
.filter((i) => i && i.identifier && !claimed.has(i.identifier))
|
|
1992
|
+
.filter((i) => String(i.title || '').trim() === title)
|
|
1993
|
+
|
|
1994
|
+
if (unclaimed.length === 0) {
|
|
1995
|
+
out.write(
|
|
1996
|
+
`spec-sync reattach: no unclaimed issue titled "${title}" — name one with --to <ISSUE-REF>\n`,
|
|
1997
|
+
)
|
|
1998
|
+
return 1
|
|
1999
|
+
}
|
|
2000
|
+
if (unclaimed.length > 1) {
|
|
2001
|
+
out.write(
|
|
2002
|
+
`spec-sync reattach: ${unclaimed.length} unclaimed issues match — name one with --to <ISSUE-REF>:\n` +
|
|
2003
|
+
unclaimed.map((i) => ` ${i.identifier} ${i.title}\n`).join(''),
|
|
2004
|
+
)
|
|
2005
|
+
return 1
|
|
2006
|
+
}
|
|
2007
|
+
chosen = unclaimed[0]
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
const lines = [`spec-sync reattach: ${chosen.identifier}`]
|
|
2011
|
+
writeFrontmatter(snapshotDir, config, {
|
|
2012
|
+
linear_identifier: chosen.identifier,
|
|
2013
|
+
linear_url: chosen.url || null,
|
|
2014
|
+
})
|
|
2015
|
+
|
|
2016
|
+
// THE PHASES TOO, or it says it did not. A spec issue with unlinked phases is
|
|
2017
|
+
// half a link: the next push mints a sub-issue per phase beside the ones
|
|
2018
|
+
// already there — the same duplicate, one level down.
|
|
2019
|
+
let children = []
|
|
2020
|
+
try {
|
|
2021
|
+
children = (await adapter.listSubIssues(chosen.id || chosen.identifier)) || []
|
|
2022
|
+
} catch {
|
|
2023
|
+
// A parent whose children cannot be listed is not a parent with none.
|
|
2024
|
+
children = null
|
|
2025
|
+
}
|
|
2026
|
+
if (children === null) {
|
|
2027
|
+
lines.push(' phases not checked — could not list the issue\'s children')
|
|
2028
|
+
} else {
|
|
2029
|
+
// The projection already knows every phase's file and the title it pushes
|
|
2030
|
+
// as the sub-issue's — so the match here is against exactly what a push
|
|
2031
|
+
// would have created, rather than a second reading of the same headings.
|
|
2032
|
+
for (const ph of projectionOf(snapshotDir, config).subIssues || []) {
|
|
2033
|
+
// Already linked — leave it. Reattach fills gaps; it does not re-point
|
|
2034
|
+
// phases that are fine.
|
|
2035
|
+
if (ph.id) continue
|
|
2036
|
+
const ref = ph.ref
|
|
2037
|
+
// `resolvePhaseFile`, exactly as `apply` does it, so a ref resolves to the
|
|
2038
|
+
// same file on both paths rather than by a second spelling of the rule.
|
|
2039
|
+
const file = resolvePhaseFile(snapshotDir, ref)
|
|
2040
|
+
if (!file) continue
|
|
2041
|
+
const phaseTitle = String(ph.name || '').trim()
|
|
2042
|
+
const hits = children.filter((c) => String(c.title || '').trim() === phaseTitle)
|
|
2043
|
+
if (hits.length === 1) {
|
|
2044
|
+
stampSubIssueId(snapshotDir, file, hits[0].identifier)
|
|
2045
|
+
lines.push(` phase ${ref} → ${hits[0].identifier}`)
|
|
2046
|
+
} else if (hits.length > 1) {
|
|
2047
|
+
// Refused individually, never for the whole run: the spec issue is
|
|
2048
|
+
// linked and one ambiguous phase should not undo that.
|
|
2049
|
+
lines.push(` phase ${ref}: ${hits.length} children match "${phaseTitle}" — left unlinked`)
|
|
2050
|
+
} else {
|
|
2051
|
+
lines.push(` phase ${ref}: no child matches "${phaseTitle}" — left unlinked`)
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
if (flags.json) {
|
|
2057
|
+
out.write(JSON.stringify({ spec: path.basename(snapshotDir), identifier: chosen.identifier }, null, 2) + '\n')
|
|
2058
|
+
} else {
|
|
2059
|
+
out.write(lines.join('\n') + '\n')
|
|
2060
|
+
}
|
|
2061
|
+
return 0
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1854
2064
|
async function specSyncProjects(dir, config, flags, out) {
|
|
1855
2065
|
const key = resolveApiKey(config, flags.env || process.env)
|
|
1856
2066
|
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
@@ -2340,11 +2550,86 @@ async function specSyncList(dir, config, flags, out) {
|
|
|
2340
2550
|
*
|
|
2341
2551
|
* Every id is stamped the moment its object exists — see `specSyncApply`.
|
|
2342
2552
|
*/
|
|
2343
|
-
|
|
2553
|
+
/**
|
|
2554
|
+
* A refusal, as the lines a reader acts on. Pure.
|
|
2555
|
+
*
|
|
2556
|
+
* Two questions, and the old single line answered neither: **what was refused**
|
|
2557
|
+
* and **can waiting help**. `Linear API error: usage limit exceeded` reads like
|
|
2558
|
+
* a throttle, so an hour went into checking a rate limit that was untouched —
|
|
2559
|
+
* while Linear's own explanation, naming the free-plan issue cap and how to
|
|
2560
|
+
* clear it, sat in `extensions.userPresentableMessage`.
|
|
2561
|
+
*
|
|
2562
|
+
* WHAT WOULD FOOL A LOOSER VERSION: treating "no verdict" as retryable. An error
|
|
2563
|
+
* that said nothing about itself — every MCP-path error, every older Linear
|
|
2564
|
+
* response — gets exactly the line it always got and no claim in either
|
|
2565
|
+
* direction (`.claude/rules/negative-checks.md` rule 4).
|
|
2566
|
+
*/
|
|
2567
|
+
function describeRefusal(error) {
|
|
2568
|
+
const message = (error && error.message) || String(error)
|
|
2569
|
+
const presentable = error && error.userPresentableMessage
|
|
2570
|
+
const code = error && error.code
|
|
2571
|
+
const metric = error && error.meta && error.meta.usageMetric
|
|
2572
|
+
const retryable = error && error.retryable
|
|
2573
|
+
|
|
2574
|
+
// Nothing to add. Say what was always said, and stop.
|
|
2575
|
+
if (!presentable && retryable !== false) return [message]
|
|
2576
|
+
|
|
2577
|
+
const lines = []
|
|
2578
|
+
lines.push(
|
|
2579
|
+
retryable === false
|
|
2580
|
+
? 'Linear refused this write — not a rate limit, so waiting will not help.'
|
|
2581
|
+
: message,
|
|
2582
|
+
)
|
|
2583
|
+
// The presentable message usually repeats the short one; printing both reads
|
|
2584
|
+
// as two problems, so the short one is dropped where the long one says it.
|
|
2585
|
+
if (presentable) lines.push(` ${presentable}`)
|
|
2586
|
+
else if (retryable === false) lines.push(` ${message}`)
|
|
2587
|
+
const tail = [code, metric].filter(Boolean)
|
|
2588
|
+
if (tail.length) lines.push(` (${tail.join(' · ')})`)
|
|
2589
|
+
return lines
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/**
|
|
2593
|
+
* Apply a plan, and say what landed even when it throws.
|
|
2594
|
+
*
|
|
2595
|
+
* A thin wrapper, because the caller has to answer a question the inner
|
|
2596
|
+
* function's `result` knows and a thrown error does not carry: **was anything
|
|
2597
|
+
* written?** That decides whether re-running is a resume or a fresh start, and
|
|
2598
|
+
* the failure line claimed `ids stamped so far are saved` on both paths —
|
|
2599
|
+
* including the one where nothing had been created.
|
|
2600
|
+
*
|
|
2601
|
+
* Counted from what was actually stamped, never inferred from which call
|
|
2602
|
+
* failed: the second reading goes wrong the moment the order changes.
|
|
2603
|
+
*/
|
|
2604
|
+
async function applyOneSpec(args) {
|
|
2605
|
+
const progress = {}
|
|
2606
|
+
try {
|
|
2607
|
+
return await applyOneSpecInner({ ...args, progress })
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
const r = progress.result
|
|
2610
|
+
if (error && typeof error === 'object' && error.stamped === undefined) {
|
|
2611
|
+
error.stamped = r ? (r.issue ? 1 : 0) + Object.keys(r.subIssues || {}).length : 0
|
|
2612
|
+
// THE ONE CASE A RE-RUN MAKES WORSE. `created` is what Linear holds;
|
|
2613
|
+
// `stamped` is what the repo recorded. They part company when a create
|
|
2614
|
+
// lands and its stamp cannot be written — and then the next plan still
|
|
2615
|
+
// reads the spec as unlinked, so re-running mints a SECOND issue beside a
|
|
2616
|
+
// perfectly good one. The identifiers travel out so the caller can name
|
|
2617
|
+
// them and send the reader to `reattach` instead.
|
|
2618
|
+
error.created = progress.created || []
|
|
2619
|
+
error.orphans = error.created.slice(error.stamped)
|
|
2620
|
+
}
|
|
2621
|
+
throw error
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
async function applyOneSpecInner({ dir, config, snapshotDir, plan, adapter, teamId, project, states, progress, forceNew = false }) {
|
|
2344
2626
|
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
2345
2627
|
const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
|
|
2346
2628
|
const lines = []
|
|
2347
2629
|
const result = { issue: null, subIssues: {} }
|
|
2630
|
+
// Handed out immediately, so a throw from anywhere below still reports what
|
|
2631
|
+
// had been stamped by the time it happened.
|
|
2632
|
+
if (progress) progress.result = result
|
|
2348
2633
|
const stateId = (bucket) => (bucket ? stateIdFor(bucket, config, states) : null)
|
|
2349
2634
|
|
|
2350
2635
|
// Resolve every state id BEFORE the first write, so a bad config.states value
|
|
@@ -2358,6 +2643,43 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
2358
2643
|
// 1. The spec issue. No identifier yet → this push mints it.
|
|
2359
2644
|
let parentId = null
|
|
2360
2645
|
if (!identifier) {
|
|
2646
|
+
// REFUSE TO MINT A TWIN. This is the guard SKS-284 needed: the push was
|
|
2647
|
+
// creating because the spec read as unlinked, while SKS-283 sat unclaimed
|
|
2648
|
+
// with exactly this title. An EXACT match held by no spec is a positive
|
|
2649
|
+
// signal that the issue already exists.
|
|
2650
|
+
//
|
|
2651
|
+
// WHAT WOULD FOOL A FUZZY VERSION: a title that merely reads similarly.
|
|
2652
|
+
// Being wrong here BLOCKS a legitimate push, so a near-match must never
|
|
2653
|
+
// count (`.claude/rules/negative-checks.md` rule 1) — and the cost, a
|
|
2654
|
+
// renamed spec that is not recognised, is answered by `reattach --to`.
|
|
2655
|
+
//
|
|
2656
|
+
// Only on a MINT. An update already has its issue, and checking one would
|
|
2657
|
+
// find its own mirror.
|
|
2658
|
+
if (!forceNew && adapter.searchIssues) {
|
|
2659
|
+
const wanted = String(plan.title || readSnapshot(snapshotDir, config).title || '').trim()
|
|
2660
|
+
if (wanted) {
|
|
2661
|
+
let found = []
|
|
2662
|
+
try {
|
|
2663
|
+
found = (await adapter.searchIssues({ query: wanted, teamId })) || []
|
|
2664
|
+
} catch {
|
|
2665
|
+
// A search we could not run is not evidence of an orphan. Carry on and
|
|
2666
|
+
// mint — the cannot-tell case goes to the harmless branch, which here
|
|
2667
|
+
// is the one that does the work the caller asked for (rule 4).
|
|
2668
|
+
found = []
|
|
2669
|
+
}
|
|
2670
|
+
const claimed = new Set(listSpecs(dir, config).map((sp) => sp.identifier).filter(Boolean))
|
|
2671
|
+
const twin = found.find(
|
|
2672
|
+
(i) => i && i.identifier && !claimed.has(i.identifier) && String(i.title || '').trim() === wanted,
|
|
2673
|
+
)
|
|
2674
|
+
if (twin) {
|
|
2675
|
+
throw new Error(
|
|
2676
|
+
`${twin.identifier} is already titled "${wanted}" and no spec claims it — ` +
|
|
2677
|
+
`adopt it with \`spec-sync reattach ${path.basename(snapshotDir)} --to ${twin.identifier}\`, ` +
|
|
2678
|
+
'or pass --force-new to create a second',
|
|
2679
|
+
)
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2361
2683
|
const created = await adapter.createIssue(withoutNull({
|
|
2362
2684
|
title: readSnapshot(snapshotDir, config).title,
|
|
2363
2685
|
teamId,
|
|
@@ -2370,6 +2692,10 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
2370
2692
|
}))
|
|
2371
2693
|
if (!created || !created.identifier) throw new Error('Linear returned no issue for the spec create')
|
|
2372
2694
|
parentId = created.id
|
|
2695
|
+
// NOTED BEFORE IT IS STAMPED. The stamp can fail — a read-only tree, a
|
|
2696
|
+
// permission error — and then this identifier exists in Linear with nothing
|
|
2697
|
+
// in the repo pointing at it. Recorded here so the failure can name it.
|
|
2698
|
+
if (progress) (progress.created = progress.created || []).push(created.identifier)
|
|
2373
2699
|
// Stamped NOW: an interrupt after this point must not mint a second issue.
|
|
2374
2700
|
writeFrontmatter(snapshotDir, config, { linear_identifier: created.identifier, linear_url: created.url })
|
|
2375
2701
|
result.issue = { id: created.id, identifier: created.identifier, url: created.url }
|
|
@@ -2379,6 +2705,22 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
2379
2705
|
if (!existing || !existing.id) throw new Error(`no Linear issue found for ${identifier}`)
|
|
2380
2706
|
parentId = existing.id
|
|
2381
2707
|
result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
|
|
2708
|
+
|
|
2709
|
+
// SOMEONE ELSE EDITED THE DESCRIPTION SINCE WE LAST PUSHED, and this write
|
|
2710
|
+
// is about to replace it. Said here because this is the one moment the
|
|
2711
|
+
// engine has both halves in hand for free — the read-back it already does,
|
|
2712
|
+
// and the snapshot on disk — so no extra round trip buys the warning.
|
|
2713
|
+
//
|
|
2714
|
+
// A WARNING AND NEVER A REFUSAL. One-way sync means the repo wins, and that
|
|
2715
|
+
// is not in question; what was wrong was winning silently. Turning this
|
|
2716
|
+
// into a gate would stop a legitimate push over a typo fix, and the whole
|
|
2717
|
+
// point is that only a person can judge which it was. The exit code is
|
|
2718
|
+
// untouched, deliberately.
|
|
2719
|
+
if (remoteDescriptionEdited(readBase(dir, identifier, config), existing) === true) {
|
|
2720
|
+
lines.push(
|
|
2721
|
+
` !! ${identifier}'s description was edited on Linear since the last push — this replaces it`,
|
|
2722
|
+
)
|
|
2723
|
+
}
|
|
2382
2724
|
if (plan.issue) {
|
|
2383
2725
|
const updates = withoutNull({
|
|
2384
2726
|
description: plan.issue.description,
|
|
@@ -2405,6 +2747,7 @@ async function applyOneSpec({ dir, config, snapshotDir, plan, adapter, teamId, p
|
|
|
2405
2747
|
stateId: stateId(sub.state),
|
|
2406
2748
|
}))
|
|
2407
2749
|
if (!created || !created.identifier) throw new Error(`Linear returned no issue for sub-issue ${sub.ref}`)
|
|
2750
|
+
if (progress) (progress.created = progress.created || []).push(created.identifier)
|
|
2408
2751
|
const file = resolvePhaseFile(snapshotDir, sub.ref)
|
|
2409
2752
|
if (!file) throw new Error(`no phase file for ref ${sub.ref}`)
|
|
2410
2753
|
stampSubIssueId(snapshotDir, file, created.identifier)
|
|
@@ -2757,7 +3100,7 @@ async function specSyncApply(dir, config, specArg, flags, out) {
|
|
|
2757
3100
|
out.write(`spec-sync apply: ${error.message}\n`)
|
|
2758
3101
|
return 1
|
|
2759
3102
|
}
|
|
2760
|
-
const shared = { dir, config, adapter, teamId, project: flags.project, states }
|
|
3103
|
+
const shared = { dir, config, adapter, teamId, project: flags.project, states, forceNew: flags.forceNew }
|
|
2761
3104
|
|
|
2762
3105
|
if (!bulk) {
|
|
2763
3106
|
const lines = ['spec-sync apply: transport = api']
|
|
@@ -2767,8 +3110,25 @@ async function specSyncApply(dir, config, specArg, flags, out) {
|
|
|
2767
3110
|
} catch (error) {
|
|
2768
3111
|
// Whatever landed before the failure is already stamped, so re-running
|
|
2769
3112
|
// resumes rather than duplicating — say so instead of leaving it ambiguous.
|
|
3113
|
+
const said = describeRefusal(error)
|
|
2770
3114
|
out.write(
|
|
2771
|
-
[
|
|
3115
|
+
[
|
|
3116
|
+
...lines,
|
|
3117
|
+
` !! ${said[0]}`,
|
|
3118
|
+
...said.slice(1).map((l) => ` ${l}`),
|
|
3119
|
+
// THREE ENDINGS, and the third is the one a re-run makes worse.
|
|
3120
|
+
// Something exists in Linear that the repo does not record, so the
|
|
3121
|
+
// next plan still reads this spec as unlinked and re-running mints a
|
|
3122
|
+
// second issue beside it. Name what exists and send the reader to
|
|
3123
|
+
// `reattach` instead.
|
|
3124
|
+
error && error.orphans && error.orphans.length
|
|
3125
|
+
? ` ${error.orphans.join(', ')} ${error.orphans.length === 1 ? 'exists' : 'exist'} in Linear but could not be ` +
|
|
3126
|
+
'recorded — reattach rather than re-running, or a second will be minted:\n' +
|
|
3127
|
+
` skitterspec spec-sync reattach ${plan && plan.spec ? plan.spec : '<spec>'} --to ${error.orphans[0]}`
|
|
3128
|
+
: error && error.stamped
|
|
3129
|
+
? ' ids stamped so far are saved — re-run to resume without duplicating'
|
|
3130
|
+
: ' nothing was created — re-run once the cause is resolved',
|
|
3131
|
+
].join('\n') + '\n',
|
|
2772
3132
|
)
|
|
2773
3133
|
return 1
|
|
2774
3134
|
}
|
|
@@ -2937,12 +3297,17 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
2937
3297
|
// shape — a blank key or a duplicate fails there, in one place, rather than
|
|
2938
3298
|
// being re-checked here and drifting from the loader.
|
|
2939
3299
|
if (flags.stages.length) draft.release = { stages: flags.stages }
|
|
2940
|
-
// Assignment
|
|
2941
|
-
//
|
|
3300
|
+
// Assignment ships ON, so the flag is the OPT-OUT and it writes `none`. The
|
|
3301
|
+
// previous `--assign` is gone rather than kept as a no-op: a flag whose meaning
|
|
3302
|
+
// the default flip inverted, still accepted and doing nothing, is the
|
|
3303
|
+
// record-and-do-nothing shape this project is against — and an unknown flag
|
|
3304
|
+
// already fails loudly, which is a better answer than silence.
|
|
3305
|
+
//
|
|
3306
|
+
// Only `assignee` is written: the loader merges this map PER KEY onto the
|
|
2942
3307
|
// defaults, so restating the other three would freeze today's defaults into
|
|
2943
3308
|
// the file and quietly opt the repo out of any later change to them — the very
|
|
2944
3309
|
// thing "only the keys that differ" exists to avoid.
|
|
2945
|
-
if (flags.
|
|
3310
|
+
if (flags.noAssign) draft.sync = { fieldOwnership: { assignee: 'none' } }
|
|
2946
3311
|
|
|
2947
3312
|
for (const bucket of Object.keys(flags.stateNames)) {
|
|
2948
3313
|
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
@@ -3246,6 +3611,279 @@ function repoConfigKeyCommand(dir) {
|
|
|
3246
3611
|
}
|
|
3247
3612
|
}
|
|
3248
3613
|
|
|
3614
|
+
// --- preserve: the reporter's original, kept before the spec replaces it ------
|
|
3615
|
+
|
|
3616
|
+
/**
|
|
3617
|
+
* The marker that makes preserving idempotent.
|
|
3618
|
+
*
|
|
3619
|
+
* An HTML comment, so it does not render in Linear's comment body — the reader
|
|
3620
|
+
* sees a clean quote, and the machine sees a string it can match on. It is
|
|
3621
|
+
* matched with `includes`, never parsed, so a Linear that reformats the
|
|
3622
|
+
* surrounding markdown cannot detach it from the comment it identifies.
|
|
3623
|
+
*
|
|
3624
|
+
* WHAT WOULD FOOL THIS: a Linear that strips HTML comments on save. Then every
|
|
3625
|
+
* run reads as "not yet preserved" and posts again. That is why
|
|
3626
|
+
* `originalPreserved` ALSO matches the visible lead-in — two independent
|
|
3627
|
+
* signals, either of which is enough, so losing one degrades to a duplicate
|
|
3628
|
+
* check rather than to duplicate comments.
|
|
3629
|
+
*/
|
|
3630
|
+
const PRESERVE_MARKER = '<!-- skitterspec:original-report -->'
|
|
3631
|
+
|
|
3632
|
+
// The visible half of the marker — a heading a human reads, and the fallback
|
|
3633
|
+
// the idempotence check falls back to if the HTML comment does not survive.
|
|
3634
|
+
const PRESERVE_HEADING = '**Original report**'
|
|
3635
|
+
|
|
3636
|
+
/**
|
|
3637
|
+
* Compose the preserving comment. Pure: no I/O, no clock, no randomness — the
|
|
3638
|
+
* caller supplies everything, which is what makes this testable without a
|
|
3639
|
+
* network and deterministic under `--json`.
|
|
3640
|
+
*
|
|
3641
|
+
* The description is quoted VERBATIM and never reflowed. Canonicalising it here
|
|
3642
|
+
* would defeat the point: this comment exists precisely because the generated
|
|
3643
|
+
* description is a canonicalised projection, and the thing worth keeping is
|
|
3644
|
+
* what the reporter actually typed.
|
|
3645
|
+
*/
|
|
3646
|
+
function preserveComment(description, specName) {
|
|
3647
|
+
return [
|
|
3648
|
+
`${PRESERVE_HEADING} — preserved before this issue became a spec.`,
|
|
3649
|
+
'',
|
|
3650
|
+
`This issue's description is now a generated mirror of \`${specName}\`, ` +
|
|
3651
|
+
'pushed from the repo. Edit the spec, not this issue. This is what was filed:',
|
|
3652
|
+
'',
|
|
3653
|
+
'---',
|
|
3654
|
+
'',
|
|
3655
|
+
String(description),
|
|
3656
|
+
'',
|
|
3657
|
+
PRESERVE_MARKER,
|
|
3658
|
+
].join('\n')
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* Has this issue already been preserved?
|
|
3663
|
+
*
|
|
3664
|
+
* Two signals, either sufficient: the hidden marker, and the visible lead-in.
|
|
3665
|
+
* A POSITIVE match is what stops a second post, so the cost of being wrong runs
|
|
3666
|
+
* one way only — a missed match posts a duplicate comment, which is untidy; a
|
|
3667
|
+
* spurious match would silently skip preserving the one thing this exists for.
|
|
3668
|
+
* Both signals are therefore specific enough that ordinary prose cannot trip
|
|
3669
|
+
* them, and neither is an absence (`.claude/rules/negative-checks.md` rule 1).
|
|
3670
|
+
*/
|
|
3671
|
+
function originalPreserved(comments) {
|
|
3672
|
+
if (!Array.isArray(comments)) return false
|
|
3673
|
+
return comments.some((c) => {
|
|
3674
|
+
const body = c && typeof c.body === 'string' ? c.body : ''
|
|
3675
|
+
return body.includes(PRESERVE_MARKER) || body.startsWith(PRESERVE_HEADING)
|
|
3676
|
+
})
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/**
|
|
3680
|
+
* `spec-sync preserve <spec> [--text <file>] [--json]` — post the issue's
|
|
3681
|
+
* current description as a comment, before a push replaces it with the spec.
|
|
3682
|
+
*
|
|
3683
|
+
* ORDERING IS THE CORRECTNESS CONDITION. Run before the linking push, this
|
|
3684
|
+
* captures the reporter's words. Run after it, it would capture the generated
|
|
3685
|
+
* spec and report success — so the snapshot check below warns about exactly
|
|
3686
|
+
* that, and the adoption skills order the call explicitly.
|
|
3687
|
+
*
|
|
3688
|
+
* EVERY CANNOT-TELL EXITS 0 AND SAYS NOTHING BEYOND WHY. Preserving is a
|
|
3689
|
+
* best-effort courtesy on top of adoption: an unresolvable issue, a description
|
|
3690
|
+
* that is absent, a project that declined — none of those is a reason to fail
|
|
3691
|
+
* the skill that called this, and none is evidence that anything went wrong.
|
|
3692
|
+
*/
|
|
3693
|
+
async function specSyncPreserve(dir, config, specArg, flags, out) {
|
|
3694
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
3695
|
+
if (!snapshotDir) return 1
|
|
3696
|
+
const specName = path.basename(snapshotDir)
|
|
3697
|
+
const say = (result) => {
|
|
3698
|
+
if (flags.json) out.write(JSON.stringify(result, null, 2) + '\n')
|
|
3699
|
+
// NO LINES MEANS NO OUTPUT, not a blank one. The opt-out is the case this
|
|
3700
|
+
// is for: a project that declined must see nothing, and a bare newline is
|
|
3701
|
+
// still a trace of a feature it turned off.
|
|
3702
|
+
else if (result.lines.length) out.write(result.lines.join('\n') + '\n')
|
|
3703
|
+
return 0
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// The opt-out, checked first: a project that declined must see no trace of
|
|
3707
|
+
// the feature, including a line about why it did nothing.
|
|
3708
|
+
if (!(config.intake && config.intake.preserveOriginal)) {
|
|
3709
|
+
return say({ spec: specName, preserved: false, reason: 'declined', lines: [] })
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
3713
|
+
const identifier = linkedIdentifier(path.join(snapshotDir, overviewFile))
|
|
3714
|
+
if (!identifier) {
|
|
3715
|
+
return say({
|
|
3716
|
+
spec: specName,
|
|
3717
|
+
preserved: false,
|
|
3718
|
+
reason: 'unlinked',
|
|
3719
|
+
lines: [`spec-sync preserve: ${specName} is not linked to an issue — nothing to preserve`],
|
|
3720
|
+
})
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// ALREADY PUSHED, so the description on the issue is this spec's own mirror
|
|
3724
|
+
// rather than anyone's original. A warning and not a refusal: a snapshot can
|
|
3725
|
+
// be absent for reasons that have nothing to do with ordering (never
|
|
3726
|
+
// committed, a fresh worktree), so this reads as "you may be late", never as
|
|
3727
|
+
// "you are wrong".
|
|
3728
|
+
const late = readBase(dir, identifier, config)
|
|
3729
|
+
? [
|
|
3730
|
+
` note: ${identifier} has been pushed before, so its description is already`,
|
|
3731
|
+
' the generated mirror — preserve runs before the linking push',
|
|
3732
|
+
]
|
|
3733
|
+
: []
|
|
3734
|
+
|
|
3735
|
+
// `--text <file>` is the MCP path's supply line: the engine cannot call an
|
|
3736
|
+
// MCP tool, so the skill reads the issue and hands the description over in a
|
|
3737
|
+
// file, exactly as `--workspace-states` and `--stored` already do.
|
|
3738
|
+
let description = null
|
|
3739
|
+
if (flags.text) {
|
|
3740
|
+
try {
|
|
3741
|
+
description = fs.readFileSync(flags.text, 'utf-8')
|
|
3742
|
+
} catch (error) {
|
|
3743
|
+
out.write(`spec-sync preserve: cannot read --text ${flags.text}: ${error.message}\n`)
|
|
3744
|
+
return 1
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
3749
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
3750
|
+
|
|
3751
|
+
if (transport === 'mcp' && description == null) {
|
|
3752
|
+
return say({
|
|
3753
|
+
spec: specName,
|
|
3754
|
+
identifier,
|
|
3755
|
+
preserved: false,
|
|
3756
|
+
reason: 'mcp',
|
|
3757
|
+
marker: PRESERVE_MARKER,
|
|
3758
|
+
lines: [
|
|
3759
|
+
'spec-sync preserve: transport = mcp (no writes made here)',
|
|
3760
|
+
key.ok ? ' --via mcp was requested' : ` ${key.error}`,
|
|
3761
|
+
...late,
|
|
3762
|
+
` read ${identifier}'s description, check its comments for the marker`,
|
|
3763
|
+
` ${PRESERVE_MARKER}`,
|
|
3764
|
+
' and if it is absent, post this comment with the Linear comment tool:',
|
|
3765
|
+
'',
|
|
3766
|
+
...preserveComment('<the description you just read>', specName).split('\n').map((l) => ` ${l}`),
|
|
3767
|
+
],
|
|
3768
|
+
})
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
if (transport === 'mcp') {
|
|
3772
|
+
// A description was supplied, so the body can be composed here even though
|
|
3773
|
+
// the post itself belongs to the skill.
|
|
3774
|
+
return say({
|
|
3775
|
+
spec: specName,
|
|
3776
|
+
identifier,
|
|
3777
|
+
preserved: false,
|
|
3778
|
+
reason: 'mcp',
|
|
3779
|
+
marker: PRESERVE_MARKER,
|
|
3780
|
+
body: preserveComment(description, specName),
|
|
3781
|
+
lines: [
|
|
3782
|
+
'spec-sync preserve: transport = mcp (no writes made here)',
|
|
3783
|
+
...late,
|
|
3784
|
+
` check ${identifier}'s comments for ${PRESERVE_MARKER}, and if absent post:`,
|
|
3785
|
+
'',
|
|
3786
|
+
...preserveComment(description, specName).split('\n').map((l) => ` ${l}`),
|
|
3787
|
+
],
|
|
3788
|
+
})
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
if (!key.ok) {
|
|
3792
|
+
return say({
|
|
3793
|
+
spec: specName,
|
|
3794
|
+
identifier,
|
|
3795
|
+
preserved: false,
|
|
3796
|
+
reason: 'no-key',
|
|
3797
|
+
lines: [`spec-sync preserve: skipped — ${key.error}`],
|
|
3798
|
+
})
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
3802
|
+
let issue
|
|
3803
|
+
try {
|
|
3804
|
+
issue = await adapter.readIssue(identifier)
|
|
3805
|
+
} catch (error) {
|
|
3806
|
+
// Unreachable is not evidence that nothing needs preserving — it is no
|
|
3807
|
+
// evidence at all, so it exits 0 and the caller carries on (rule 4).
|
|
3808
|
+
return say({
|
|
3809
|
+
spec: specName,
|
|
3810
|
+
identifier,
|
|
3811
|
+
preserved: false,
|
|
3812
|
+
reason: 'unreachable',
|
|
3813
|
+
lines: [`spec-sync preserve: could not read ${identifier} — ${error.message}`],
|
|
3814
|
+
})
|
|
3815
|
+
}
|
|
3816
|
+
if (!issue || !issue.id) {
|
|
3817
|
+
return say({
|
|
3818
|
+
spec: specName,
|
|
3819
|
+
identifier,
|
|
3820
|
+
preserved: false,
|
|
3821
|
+
reason: 'not-found',
|
|
3822
|
+
lines: [`spec-sync preserve: no Linear issue found for ${identifier}`],
|
|
3823
|
+
})
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
// A description that is not a string was never read, and one that is empty
|
|
3827
|
+
// has nothing in it worth keeping. Both are silent no-ops rather than an
|
|
3828
|
+
// empty comment claiming to preserve something.
|
|
3829
|
+
const text = description != null ? description : issue.description
|
|
3830
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
3831
|
+
return say({
|
|
3832
|
+
spec: specName,
|
|
3833
|
+
identifier,
|
|
3834
|
+
preserved: false,
|
|
3835
|
+
reason: 'no-description',
|
|
3836
|
+
lines: [`spec-sync preserve: ${identifier} has no description to preserve`],
|
|
3837
|
+
})
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
let comments
|
|
3841
|
+
try {
|
|
3842
|
+
comments = await adapter.listComments(issue.id)
|
|
3843
|
+
} catch (error) {
|
|
3844
|
+
// THE CANNOT-TELL BRANCH THAT MATTERS. Without the listing there is no way
|
|
3845
|
+
// to know whether this already ran, and the two readings cost differently:
|
|
3846
|
+
// skipping loses the original, posting again leaves a duplicate. Duplicate
|
|
3847
|
+
// is the harmless one, so a failed read does not stop the write.
|
|
3848
|
+
comments = null
|
|
3849
|
+
late.push(` note: could not list ${identifier}'s comments (${error.message}) — posting anyway`)
|
|
3850
|
+
}
|
|
3851
|
+
if (comments && originalPreserved(comments)) {
|
|
3852
|
+
return say({
|
|
3853
|
+
spec: specName,
|
|
3854
|
+
identifier,
|
|
3855
|
+
preserved: false,
|
|
3856
|
+
reason: 'already',
|
|
3857
|
+
lines: [`spec-sync preserve: ${identifier} already carries the original report — nothing posted`],
|
|
3858
|
+
})
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
const body = preserveComment(text, specName)
|
|
3862
|
+
try {
|
|
3863
|
+
await adapter.createComment(issue.id, body)
|
|
3864
|
+
} catch (error) {
|
|
3865
|
+
return say({
|
|
3866
|
+
spec: specName,
|
|
3867
|
+
identifier,
|
|
3868
|
+
preserved: false,
|
|
3869
|
+
reason: 'refused',
|
|
3870
|
+
lines: [`spec-sync preserve: Linear refused the comment on ${identifier} — ${error.message}`],
|
|
3871
|
+
})
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
return say({
|
|
3875
|
+
spec: specName,
|
|
3876
|
+
identifier,
|
|
3877
|
+
preserved: true,
|
|
3878
|
+
reason: 'posted',
|
|
3879
|
+
lines: [
|
|
3880
|
+
`spec-sync preserve: ${identifier} — original report preserved as a comment`,
|
|
3881
|
+
...late,
|
|
3882
|
+
` ${text.length} characters kept verbatim; the push may now replace the description`,
|
|
3883
|
+
],
|
|
3884
|
+
})
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3249
3887
|
// Read stdin to completion (for `--stdin`).
|
|
3250
3888
|
function readAllStdin(input) {
|
|
3251
3889
|
return new Promise((resolve, reject) => {
|
|
@@ -3299,7 +3937,7 @@ async function specSync(rest, io = {}) {
|
|
|
3299
3937
|
// after the loop.
|
|
3300
3938
|
const unknownFlags = []
|
|
3301
3939
|
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,
|
|
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,
|
|
3940
|
+
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, noAssign: false, mine: false, by: null, inProgress: false, text: null }
|
|
3303
3941
|
for (let i = 0; i < args.length; i++) {
|
|
3304
3942
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
3305
3943
|
else if (args[i] === '--json') flags.json = true
|
|
@@ -3308,6 +3946,9 @@ async function specSync(rest, io = {}) {
|
|
|
3308
3946
|
else if (args[i] === '--apply') flags.apply = true
|
|
3309
3947
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
3310
3948
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
3949
|
+
// `preserve --text <file>`: the description to keep, when the caller read it
|
|
3950
|
+
// over MCP and the engine cannot.
|
|
3951
|
+
else if (args[i] === '--text') flags.text = path.resolve(args[++i])
|
|
3311
3952
|
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
3312
3953
|
else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
|
|
3313
3954
|
else if (args[i] === '--via') flags.via = args[++i]
|
|
@@ -3328,6 +3969,9 @@ async function specSync(rest, io = {}) {
|
|
|
3328
3969
|
else if (args[i] === '--url') flags.url = args[++i]
|
|
3329
3970
|
else if (args[i] === '--sub') flags.subs.push(args[++i])
|
|
3330
3971
|
else if (args[i] === '--force') flags.force = true
|
|
3972
|
+
// Distinct from `--force`, deliberately. That one overrides other guards;
|
|
3973
|
+
// this one says "the duplicate title is real, create a second issue".
|
|
3974
|
+
else if (args[i] === '--force-new') flags.forceNew = true
|
|
3331
3975
|
else if (args[i] === '--yes') flags.yes = true
|
|
3332
3976
|
else if (args[i] === '--check-remote') flags.remoteCheck = true
|
|
3333
3977
|
else if (args[i] === '--stdin') flags.stdin = true
|
|
@@ -3339,7 +3983,7 @@ async function specSync(rest, io = {}) {
|
|
|
3339
3983
|
else if (args[i] === '--unset') flags.unset = true
|
|
3340
3984
|
else if (args[i] === '--to') flags.to = String(args[++i] || '').trim()
|
|
3341
3985
|
else if (args[i] === '--release') flags.release = true
|
|
3342
|
-
else if (args[i] === '--assign') flags.
|
|
3986
|
+
else if (args[i] === '--no-assign') flags.noAssign = true
|
|
3343
3987
|
else if (args[i] === '--limit') flags.limit = Number(args[++i]) || 0
|
|
3344
3988
|
else if (args[i] === '--cursor') flags.cursor = String(args[++i] || '').trim()
|
|
3345
3989
|
else if (args[i] === '--command') flags.command = String(args[++i] || '').trim()
|
|
@@ -3449,6 +4093,8 @@ async function specSync(rest, io = {}) {
|
|
|
3449
4093
|
'It computes the create/update plan and writes nothing; `spec-sync apply` applies it.\n',
|
|
3450
4094
|
)
|
|
3451
4095
|
return 1
|
|
4096
|
+
case 'reattach':
|
|
4097
|
+
return specSyncReattach(dir, config, positional[0], flags, out)
|
|
3452
4098
|
case 'stamp':
|
|
3453
4099
|
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
3454
4100
|
case 'record':
|
|
@@ -3479,6 +4125,8 @@ async function specSync(rest, io = {}) {
|
|
|
3479
4125
|
return (await specSyncApply(dir, config, positional[0], flags, out)) || 0
|
|
3480
4126
|
case 'verify':
|
|
3481
4127
|
return specSyncVerify(dir, config, positional[0], flags, out) || 0
|
|
4128
|
+
case 'preserve':
|
|
4129
|
+
return (await specSyncPreserve(dir, config, positional[0], flags, out)) || 0
|
|
3482
4130
|
case 'linked':
|
|
3483
4131
|
specSyncLinked(dir, config, flags, out)
|
|
3484
4132
|
return 0
|
|
@@ -3497,19 +4145,21 @@ async function specSync(rest, io = {}) {
|
|
|
3497
4145
|
' skitterspec spec-sync apply <spec> --plan <file> [--via api|mcp] [--project id] [--json]\n' +
|
|
3498
4146
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
3499
4147
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
4148
|
+
' skitterspec spec-sync preserve <spec> [--text <file>] [--via api|mcp] [--json]\n' +
|
|
3500
4149
|
' skitterspec spec-sync list [--state <name> …|--all|--in-progress] [--next N] [--mine|--by <user>] [--limit N] [--archived] [--json]\n' +
|
|
3501
4150
|
' skitterspec spec-sync linked [--json]\n' +
|
|
3502
4151
|
' skitterspec spec-sync ref [<spec>] [--json]\n' +
|
|
3503
4152
|
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
3504
4153
|
' skitterspec spec-sync stage <key> [<range>] [--apply] [--json]\n' +
|
|
4154
|
+
' skitterspec spec-sync reattach <spec> [--to <ISSUE-REF>] [--json]\n' +
|
|
3505
4155
|
' skitterspec spec-sync retarget [--yes]\n' +
|
|
3506
4156
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
3507
4157
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
3508
4158
|
' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
|
|
3509
|
-
' [--assign]\n' +
|
|
4159
|
+
' [--no-assign]\n' +
|
|
3510
4160
|
' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
|
|
3511
4161
|
return 0
|
|
3512
4162
|
}
|
|
3513
4163
|
}
|
|
3514
4164
|
|
|
3515
|
-
module.exports = { specSync, listSpecs, promptHidden }
|
|
4165
|
+
module.exports = { specSync, listSpecs, promptHidden, describeRefusal }
|