@skitterbyte/skitterspec-linear 12.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 +208 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +77 -30
- package/assets/core/linear.config.md +58 -0
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +198 -10
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +33 -5
- package/assets/skills/spec-bug/SKILL.md +172 -9
- package/assets/skills/spec-cancel/SKILL.md +98 -21
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +94 -25
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +172 -11
- package/assets/skills/spec-init/SKILL.md +34 -7
- package/assets/skills/spec-linear-setup/SKILL.md +55 -1
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +299 -6
- package/assets/skills/spec-push/SKILL.md +32 -8
- package/assets/skills/spec-review/SKILL.md +40 -5
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +386 -106
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +40 -4
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1513 -89
- package/src/env/building.js +143 -0
- package/src/env/config.js +42 -9
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +96 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +854 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +49 -3
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- package/LICENSE +0 -21
package/src/init.js
CHANGED
|
@@ -153,7 +153,7 @@ function assertComposedAssets() {
|
|
|
153
153
|
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
154
154
|
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
155
155
|
|
|
156
|
-
const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
156
|
+
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
157
157
|
|
|
158
158
|
function resetReport() {
|
|
159
159
|
for (const k of Object.keys(report)) report[k].length = 0
|
|
@@ -293,6 +293,25 @@ function writeFile(dir, target, content, { force }) {
|
|
|
293
293
|
if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
|
|
294
294
|
fs.unlinkSync(target)
|
|
295
295
|
}
|
|
296
|
+
// A LIVE symlink is the opposite case, and `--force` is what makes it
|
|
297
|
+
// dangerous: `writeFileSync` follows the link, so forcing would write composed
|
|
298
|
+
// content — seam markers resolved, provider text spliced in — straight through
|
|
299
|
+
// it and into whatever it points at. In a checkout that dogfoods its own
|
|
300
|
+
// assets that is `packages/*/assets`, i.e. the SOURCE the link exists to keep
|
|
301
|
+
// live. Refuse: the staleness `--force` was reached for is a smaller problem
|
|
302
|
+
// than corrupting the file it would overwrite.
|
|
303
|
+
//
|
|
304
|
+
// WHAT WOULD MAKE THIS LIE: a HARD link. It has no distinguishing lstat — it
|
|
305
|
+
// simply is the file — so it takes the same corrupting path and nothing here
|
|
306
|
+
// can see it. Out of scope deliberately, and said out loud rather than left to
|
|
307
|
+
// be discovered; nothing in this project's install creates one.
|
|
308
|
+
//
|
|
309
|
+
// It cannot fire in an ordinary consumer install, because nothing there is
|
|
310
|
+
// linked — `skitterspec update` writes copies by design.
|
|
311
|
+
if (force && link && link.isSymbolicLink() && fs.existsSync(target)) {
|
|
312
|
+
report.refused.push(rel(dir, target))
|
|
313
|
+
return
|
|
314
|
+
}
|
|
296
315
|
if (fs.existsSync(target)) {
|
|
297
316
|
if (!force) {
|
|
298
317
|
report.skipped.push(rel(dir, target))
|
|
@@ -499,6 +518,67 @@ function trustWorktreeRoot(dir) {
|
|
|
499
518
|
}
|
|
500
519
|
}
|
|
501
520
|
|
|
521
|
+
// Is the CLAUDE.md section this project has installed the one we ship?
|
|
522
|
+
//
|
|
523
|
+
// THREE answers, not two. `differs` deliberately does NOT mean "stale": the
|
|
524
|
+
// block is a COPY, so a difference is either an out-of-date copy or the user's
|
|
525
|
+
// own edit, and from here those read identically. Rule 4 of
|
|
526
|
+
// `.claude/rules/negative-checks.md` — route the unknown case to the harmless
|
|
527
|
+
// branch, which here means reporting a difference and naming the fix rather
|
|
528
|
+
// than accusing them of being behind.
|
|
529
|
+
//
|
|
530
|
+
// WHAT WOULD FOOL THIS: absent markers mean the section was never installed, OR
|
|
531
|
+
// that someone stripped it deliberately (`stripClaudeMdSection` exists and is
|
|
532
|
+
// reachable from `reset`). Neither is a fault, so both answer `not installed`
|
|
533
|
+
// and neither is reported as a problem.
|
|
534
|
+
function claudeMdSectionState(dir) {
|
|
535
|
+
const target = path.join(dir, 'CLAUDE.md')
|
|
536
|
+
if (!fs.existsSync(target)) return 'not installed'
|
|
537
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
538
|
+
if (!existing.includes(SPEC_MARKER_START) || !existing.includes(SPEC_MARKER_END)) {
|
|
539
|
+
return 'not installed'
|
|
540
|
+
}
|
|
541
|
+
const shipped = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
542
|
+
const start = existing.indexOf(SPEC_MARKER_START) + SPEC_MARKER_START.length
|
|
543
|
+
const installed = existing.slice(start, existing.indexOf(SPEC_MARKER_END)).trim()
|
|
544
|
+
return installed === shipped ? 'fresh' : 'differs'
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// `update --check`: say what `update` would change, write nothing, exit 0.
|
|
548
|
+
// It reports; `update` without the flag stays the only thing that touches a
|
|
549
|
+
// file. This exists because the section is a copy and a copy goes quietly out
|
|
550
|
+
// of date — this repo's own was a whole spec behind the template it ships,
|
|
551
|
+
// through a spec about that template, with every test green.
|
|
552
|
+
function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
|
|
553
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
554
|
+
const manifest = readManifest(dir)
|
|
555
|
+
const rows = []
|
|
556
|
+
// Mirror `resyncManagedFile`'s decision exactly rather than re-deriving it:
|
|
557
|
+
// missing → it would create; customized → it would KEEP yours and say so;
|
|
558
|
+
// pristine → it would write only when the shipped content actually differs.
|
|
559
|
+
for (const { relPath, abs, bundled } of managedTargets(dir)) {
|
|
560
|
+
const state = managedState(dir, relPath, manifest, bundled)
|
|
561
|
+
if (state === 'missing') rows.push([relPath, 'missing — would be created'])
|
|
562
|
+
else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
|
|
563
|
+
else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
|
|
564
|
+
}
|
|
565
|
+
const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
|
|
566
|
+
// A healthy area says NOTHING. A report that lists what is already fine is a
|
|
567
|
+
// report people learn to skim, and then the one line that mattered is missed.
|
|
568
|
+
if (section === 'differs') {
|
|
569
|
+
rows.push([
|
|
570
|
+
'CLAUDE.md (spec workflow section)',
|
|
571
|
+
'differs from the shipped one — `update` would replace it (it is a copy, so this is either your edit or an out-of-date one)',
|
|
572
|
+
])
|
|
573
|
+
}
|
|
574
|
+
if (!rows.length) log('skitterspec update --check: everything is up to date.')
|
|
575
|
+
else {
|
|
576
|
+
log('skitterspec update --check: `skitterspec update` would:')
|
|
577
|
+
for (const [name, why] of rows) log(` ${name} — ${why}`)
|
|
578
|
+
}
|
|
579
|
+
return { rows, section }
|
|
580
|
+
}
|
|
581
|
+
|
|
502
582
|
function installClaudeMd(dir, { mode }) {
|
|
503
583
|
const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
504
584
|
const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
|
|
@@ -681,6 +761,15 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
681
761
|
)
|
|
682
762
|
line('manifest repaired', report.healed)
|
|
683
763
|
line('unchanged', report.skipped)
|
|
764
|
+
if (report.refused.length) {
|
|
765
|
+
process.stdout.write('\nrefused (a symlink — writing would overwrite what it points at):\n')
|
|
766
|
+
for (const it of report.refused) process.stdout.write(` ${it}\n`)
|
|
767
|
+
process.stdout.write(
|
|
768
|
+
' These are links into the shipped assets. --force would follow them and\n' +
|
|
769
|
+
' write composed content into the source. Unlink one to take the copy\n' +
|
|
770
|
+
' (rm <path>, then re-run), or leave it linked and edit the asset.\n',
|
|
771
|
+
)
|
|
772
|
+
}
|
|
684
773
|
if (report.warnings.length) {
|
|
685
774
|
process.stdout.write('\nwarnings:\n')
|
|
686
775
|
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
@@ -764,6 +853,10 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
|
|
|
764
853
|
|
|
765
854
|
module.exports = {
|
|
766
855
|
init,
|
|
856
|
+
// A snapshot of the last run's report, for tests that need to assert on what a
|
|
857
|
+
// run DECIDED rather than only on what it left on disk. Copied, so a caller
|
|
858
|
+
// cannot mutate the live report between phases of a run.
|
|
859
|
+
lastReport: () => JSON.parse(JSON.stringify(report)),
|
|
767
860
|
SKILLS,
|
|
768
861
|
COMMANDS,
|
|
769
862
|
RULES,
|
|
@@ -774,6 +867,8 @@ module.exports = {
|
|
|
774
867
|
writeManifest,
|
|
775
868
|
managedTargets,
|
|
776
869
|
managedState,
|
|
870
|
+
claudeMdSectionState,
|
|
871
|
+
checkSync,
|
|
777
872
|
isExistingSetup,
|
|
778
873
|
resync,
|
|
779
874
|
reset,
|
package/src/vendor/linear/api.js
CHANGED
|
@@ -29,6 +29,10 @@ const ENDPOINT = 'https://api.linear.app/graphql'
|
|
|
29
29
|
const MAX_RETRIES = 5
|
|
30
30
|
const MAX_BACKOFF_MS = 60_000
|
|
31
31
|
|
|
32
|
+
// Linear's hard per-page ceiling on a connection. `listIssues` pages against
|
|
33
|
+
// this rather than trusting a caller's `first` to be under it.
|
|
34
|
+
const PAGE_SIZE = 250
|
|
35
|
+
|
|
32
36
|
const { storePath, readStore, resolveTeamKey } = require('./credentials.js')
|
|
33
37
|
|
|
34
38
|
/**
|
|
@@ -96,7 +100,22 @@ function resolveApiKey(config, env = process.env, deps = {}) {
|
|
|
96
100
|
|
|
97
101
|
// Fields we read back on every write. `identifier` and `url` are what the skill
|
|
98
102
|
// stamps into the spec; `description` is what `spec-sync verify` compares.
|
|
99
|
-
|
|
103
|
+
// `assignee` rides along so `spec-sync status` can report assignment drift from
|
|
104
|
+
// the same read-back the state drift already uses — one read, not two; it is
|
|
105
|
+
// also the "who holds this" column of the listing.
|
|
106
|
+
// `priority`, `sortOrder` and `parent` are here for `listIssues`: one query has
|
|
107
|
+
// to answer every column the listing prints (Linear's own backlog order) and
|
|
108
|
+
// the discriminator it filters on (a phase sub-issue carries `parent`, a spec
|
|
109
|
+
// issue does not). Requesting them on the read/create/update paths too costs
|
|
110
|
+
// nothing and keeps one field list.
|
|
111
|
+
const ISSUE_FIELDS =
|
|
112
|
+
'id identifier url title description priority sortOrder state { id name } assignee { id name } parent { id }'
|
|
113
|
+
|
|
114
|
+
// What we read back about a person. `name` is the handle Linear shows on an
|
|
115
|
+
// issue; `displayName` is the short @-handle; `active` distinguishes a current
|
|
116
|
+
// member from a deactivated one, which matters because a deactivated user still
|
|
117
|
+
// resolves by id and can still be assigned.
|
|
118
|
+
const USER_FIELDS = 'id name displayName email active'
|
|
100
119
|
|
|
101
120
|
/**
|
|
102
121
|
* A GraphQL caller bound to one key. Throws a clear Error on transport failure,
|
|
@@ -234,6 +253,90 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
|
|
|
234
253
|
if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
|
|
235
254
|
return (data && data.projects && data.projects.nodes) || []
|
|
236
255
|
},
|
|
256
|
+
// WHO THIS KEY BELONGS TO. A personal API key is issued to a person, so the
|
|
257
|
+
// workspace can answer "who am I" without anyone configuring it — which is
|
|
258
|
+
// why identity needs no repo config in the common case.
|
|
259
|
+
//
|
|
260
|
+
// The exception this cannot see: a SHARED or bot key, where the viewer is
|
|
261
|
+
// the bot and not the human at the keyboard. That is what
|
|
262
|
+
// `spec-sync whoami --set` exists to override, and why nothing treats this
|
|
263
|
+
// answer as unarguable.
|
|
264
|
+
async readViewer() {
|
|
265
|
+
const data = await call(`query { viewer { ${USER_FIELDS} } }`)
|
|
266
|
+
return (data && data.viewer) || null
|
|
267
|
+
},
|
|
268
|
+
// Find a person by name or email — the fallback when the viewer is not the
|
|
269
|
+
// right answer, and how `/spec-claim --to` resolves a teammate.
|
|
270
|
+
//
|
|
271
|
+
// Search AND paging, not a choice between them: a bare listing is the whole
|
|
272
|
+
// team (fine for five people, useless for five hundred), while search alone
|
|
273
|
+
// cannot answer "show me everyone". `query` omitted lists; `cursor` walks.
|
|
274
|
+
// `first` is capped at 250 to match what Linear will return in one page.
|
|
275
|
+
async searchUsers(query, { limit = 50, cursor = null } = {}) {
|
|
276
|
+
const term = typeof query === 'string' ? query.trim() : ''
|
|
277
|
+
// `or` over name and email: someone searching "jane" and someone pasting
|
|
278
|
+
// "jane@acme.com" are asking the same question.
|
|
279
|
+
const filter = term
|
|
280
|
+
? { or: [{ name: { containsIgnoreCase: term } }, { email: { containsIgnoreCase: term } }] }
|
|
281
|
+
: {}
|
|
282
|
+
const data = await call(
|
|
283
|
+
`query($filter: UserFilter, $first: Int, $after: String) {
|
|
284
|
+
users(filter: $filter, first: $first, after: $after) {
|
|
285
|
+
nodes { ${USER_FIELDS} }
|
|
286
|
+
pageInfo { hasNextPage endCursor }
|
|
287
|
+
} }`,
|
|
288
|
+
{ filter, first: Math.min(Math.max(1, limit), 250), after: cursor || null },
|
|
289
|
+
)
|
|
290
|
+
const users = (data && data.users) || {}
|
|
291
|
+
const page = users.pageInfo || {}
|
|
292
|
+
return {
|
|
293
|
+
users: users.nodes || [],
|
|
294
|
+
// Null rather than absent when the page is the last one, so a caller
|
|
295
|
+
// loops on a value rather than on the presence of a key.
|
|
296
|
+
nextCursor: page.hasNextPage ? page.endCursor || null : null,
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
// The listing's one read. Paging is done HERE rather than by the caller so
|
|
300
|
+
// `first` means what it says — Linear caps a page at 250, and a caller that
|
|
301
|
+
// asked for 400 and silently got 250 is exactly the "no silent caps" failure
|
|
302
|
+
// this feature exists to avoid. `first: null` means "everything", which is
|
|
303
|
+
// what lets the listing report a truthful `showing 5 of 23` instead of a
|
|
304
|
+
// total it only assumed. The returned `pageInfo` is the LAST page's, so
|
|
305
|
+
// `hasNextPage` still tells a capped caller that more exist.
|
|
306
|
+
//
|
|
307
|
+
// API-only, like `listIssueStates` — see the operation-contract test.
|
|
308
|
+
async listIssues({ teamId, stateIds, assigneeId, parentless, first = null, after = null, includeArchived = false } = {}) {
|
|
309
|
+
const filter = {}
|
|
310
|
+
if (teamId) filter.team = { id: { eq: teamId } }
|
|
311
|
+
if (stateIds && stateIds.length) filter.state = { id: { in: stateIds } }
|
|
312
|
+
if (assigneeId) filter.assignee = { id: { eq: assigneeId } }
|
|
313
|
+
// Linear's IssueFilter spells "has no parent" as a null check on the
|
|
314
|
+
// relation; there is no `isOrphan`-style boolean.
|
|
315
|
+
if (parentless) filter.parent = { null: true }
|
|
316
|
+
|
|
317
|
+
const query = `query($filter: IssueFilter, $first: Int, $after: String, $includeArchived: Boolean) {
|
|
318
|
+
issues(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
|
|
319
|
+
nodes { ${ISSUE_FIELDS} }
|
|
320
|
+
pageInfo { hasNextPage endCursor }
|
|
321
|
+
}
|
|
322
|
+
}`
|
|
323
|
+
|
|
324
|
+
const nodes = []
|
|
325
|
+
let cursor = after
|
|
326
|
+
let pageInfo = { hasNextPage: false, endCursor: null }
|
|
327
|
+
for (;;) {
|
|
328
|
+
const want = first === null ? PAGE_SIZE : Math.min(PAGE_SIZE, first - nodes.length)
|
|
329
|
+
if (want <= 0) break
|
|
330
|
+
const data = await call(query, { filter, first: want, after: cursor, includeArchived: !!includeArchived })
|
|
331
|
+
const conn = (data && data.issues) || {}
|
|
332
|
+
for (const node of conn.nodes || []) nodes.push(node)
|
|
333
|
+
pageInfo = conn.pageInfo || { hasNextPage: false, endCursor: null }
|
|
334
|
+
if (!pageInfo.hasNextPage) break
|
|
335
|
+
cursor = pageInfo.endCursor
|
|
336
|
+
if (first !== null && nodes.length >= first) break
|
|
337
|
+
}
|
|
338
|
+
return { nodes, pageInfo }
|
|
339
|
+
},
|
|
237
340
|
// The team's CURRENT key, which is what `retarget` compares stamped
|
|
238
341
|
// identifiers against. Read from Linear rather than `config.linear.teamKey`
|
|
239
342
|
// on purpose: the config key is itself one of the things that goes stale
|