@skitterbyte/skitterspec-linear 11.0.0 → 12.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 +52 -0
- package/assets/claude-md-section.md +10 -0
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +36 -11
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +9 -8
- package/assets/rules/spec-planning.md +31 -10
- package/assets/skills/spec/SKILL.md +31 -8
- package/assets/skills/spec-bug/SKILL.md +21 -18
- package/assets/skills/spec-cancel/SKILL.md +16 -2
- package/assets/skills/spec-complete/SKILL.md +47 -15
- package/assets/skills/spec-hotfix/SKILL.md +30 -11
- package/assets/skills/spec-init/SKILL.md +15 -2
- package/assets/skills/spec-linear-setup/SKILL.md +31 -6
- package/assets/skills/spec-next/SKILL.md +2 -2
- package/assets/skills/spec-push/SKILL.md +13 -14
- package/assets/skills/spec-review/SKILL.md +19 -6
- package/assets/skills/spec-start/SKILL.md +133 -53
- package/assets/skills/spec-sync/SKILL.md +7 -7
- package/assets/skills/spec-to-main/SKILL.md +14 -14
- package/package.json +1 -1
- package/src/cli.js +209 -3
- package/src/env/classify.js +91 -0
- package/src/env/config.js +15 -0
- package/src/env/provision.js +145 -11
- package/src/env/resolve.js +1 -0
- package/src/gating.js +155 -0
- package/src/init.js +28 -1
- package/src/prompts.js +10 -1
- package/src/vendor/linear/cli-sync.js +20 -0
- package/src/vendor/sync-core/src/compare.js +25 -2
package/src/gating.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release gating — the loader, the header reader, and the check.
|
|
5
|
+
*
|
|
6
|
+
* The feature records ONE decision per spec: does this ship behind a feature
|
|
7
|
+
* flag, or land live? Skitterspec never learns how a project does flags; it asks
|
|
8
|
+
* the question, cites the project's own documentation, and reads back what was
|
|
9
|
+
* written. Strictly opt-in: with `specs/.core/gating.config.json` absent, every
|
|
10
|
+
* function here reports "not configured" and nothing else changes.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `src/env/config.js` (frozen defaults, merge known keys only, never
|
|
13
|
+
* throws on absence) so the two opt-in configs behave alike.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
|
|
19
|
+
const CONFIG_FILE = path.join('specs', '.core', 'gating.config.json')
|
|
20
|
+
|
|
21
|
+
// Only these two buckets are ever read.
|
|
22
|
+
//
|
|
23
|
+
// A SPEC WRITTEN BEFORE GATING WAS ADOPTED HAS NO HEADER AND IS NOT BROKEN. That
|
|
24
|
+
// is the blind spot this check would otherwise walk into: "no Gating: line" is
|
|
25
|
+
// evidence of an unanswered question only for a spec that could have been asked,
|
|
26
|
+
// and every finished or abandoned spec predates the question by definition. They
|
|
27
|
+
// are excluded STRUCTURALLY rather than by a filter someone must remember — a
|
|
28
|
+
// completed spec is not in range, so no future edit can make it fire. Pre-existing
|
|
29
|
+
// specs still in flight ARE reported, deliberately: they are live work, the
|
|
30
|
+
// question genuinely still applies, and the report never blocks anything.
|
|
31
|
+
const ACTIVE_BUCKETS = ['backlog', 'in-progress']
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
34
|
+
guidance: '',
|
|
35
|
+
default: 'none: <reason>',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load `specs/.core/gating.config.json`. Returns `{ config, present }`;
|
|
40
|
+
* `present:false` means the project has not adopted gating, which is read as
|
|
41
|
+
* "this project does not use feature flags" — never as an error.
|
|
42
|
+
*/
|
|
43
|
+
function loadGatingConfig(dir = process.cwd()) {
|
|
44
|
+
const base = { ...DEFAULT_CONFIG }
|
|
45
|
+
let raw
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return { config: base, present: false }
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
let parsed
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
57
|
+
}
|
|
58
|
+
if (parsed && typeof parsed === 'object') {
|
|
59
|
+
if (typeof parsed.guidance === 'string') base.guidance = parsed.guidance.trim()
|
|
60
|
+
if (typeof parsed.default === 'string' && parsed.default.trim()) {
|
|
61
|
+
base.default = parsed.default.trim()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { config: base, present: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a spec's `> **Gating:** …` blockquote field from `00-overview.md`.
|
|
69
|
+
*
|
|
70
|
+
* Returns `{ raw, kind }` with four kinds, because there are four states and
|
|
71
|
+
* collapsing them is what made the omission invisible in the first place:
|
|
72
|
+
*
|
|
73
|
+
* flag a flag name — ships behind it
|
|
74
|
+
* none `none: <reason>` — deliberately not flagged, and why
|
|
75
|
+
* invalid present but says nothing: empty, or a bare `none` with no reason
|
|
76
|
+
* missing no field at all
|
|
77
|
+
*
|
|
78
|
+
* `invalid` and `missing` are kept apart on purpose. A bare `none` is someone
|
|
79
|
+
* answering without deciding; a missing line is nobody having been asked. They
|
|
80
|
+
* want different words.
|
|
81
|
+
*/
|
|
82
|
+
function readGatingField(specPath) {
|
|
83
|
+
const overview = path.join(specPath, '00-overview.md')
|
|
84
|
+
let raw
|
|
85
|
+
try {
|
|
86
|
+
raw = fs.readFileSync(overview, 'utf-8')
|
|
87
|
+
} catch {
|
|
88
|
+
return { raw: null, kind: 'missing' }
|
|
89
|
+
}
|
|
90
|
+
const m = /^>\s*\*\*Gating:\*\*\s*(.*)$/m.exec(raw)
|
|
91
|
+
if (!m) return { raw: null, kind: 'missing' }
|
|
92
|
+
const value = m[1].trim().replace(/^["'`]|["'`]$/g, '')
|
|
93
|
+
if (!value) return { raw: value, kind: 'invalid' }
|
|
94
|
+
const bare = /^none\b/i.test(value)
|
|
95
|
+
if (bare) {
|
|
96
|
+
// `none` alone, or `none:` with nothing after it, is a shrug rather than a
|
|
97
|
+
// decision — the reason half is the whole point of recording it.
|
|
98
|
+
const reason = value.replace(/^none\b:?/i, '').trim()
|
|
99
|
+
return { raw: value, kind: reason ? 'none' : 'invalid' }
|
|
100
|
+
}
|
|
101
|
+
return { raw: value, kind: 'flag' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Active specs on disk, as `{ folder, bucket, path }`. A bucket that does not
|
|
105
|
+
// exist is simply empty — git does not store empty directories, so a missing
|
|
106
|
+
// `specs/backlog/` is the ordinary state of a project with nothing queued.
|
|
107
|
+
function activeSpecs(dir) {
|
|
108
|
+
const out = []
|
|
109
|
+
for (const bucket of ACTIVE_BUCKETS) {
|
|
110
|
+
const root = path.join(dir, 'specs', bucket)
|
|
111
|
+
let entries
|
|
112
|
+
try {
|
|
113
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
114
|
+
} catch {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
for (const e of entries) {
|
|
118
|
+
if (e.isDirectory()) out.push({ folder: e.name, bucket, path: path.join(root, e.name) })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check specs for a recorded gating decision.
|
|
126
|
+
*
|
|
127
|
+
* Advisory by construction: it returns findings and says nothing about what the
|
|
128
|
+
* caller should do. Nothing here exits, throws on a finding, or blocks.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{configured: boolean, findings: Array<{folder, bucket, kind, raw}>,
|
|
131
|
+
* checked: number, guidance: string}}
|
|
132
|
+
*/
|
|
133
|
+
function checkGating(dir, specs) {
|
|
134
|
+
const { config, present } = loadGatingConfig(dir)
|
|
135
|
+
if (!present) return { configured: false, findings: [], checked: 0, guidance: '' }
|
|
136
|
+
const targets = specs && specs.length ? specs : activeSpecs(dir)
|
|
137
|
+
const findings = []
|
|
138
|
+
for (const spec of targets) {
|
|
139
|
+
const { kind, raw } = readGatingField(spec.path)
|
|
140
|
+
if (kind === 'missing' || kind === 'invalid') {
|
|
141
|
+
findings.push({ folder: spec.folder, bucket: spec.bucket, kind, raw })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { configured: true, findings, checked: targets.length, guidance: config.guidance }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
CONFIG_FILE,
|
|
149
|
+
DEFAULT_CONFIG,
|
|
150
|
+
ACTIVE_BUCKETS,
|
|
151
|
+
loadGatingConfig,
|
|
152
|
+
readGatingField,
|
|
153
|
+
activeSpecs,
|
|
154
|
+
checkGating,
|
|
155
|
+
}
|
package/src/init.js
CHANGED
|
@@ -444,6 +444,24 @@ function installIsolation(dir, { enabled, workspaceMode }, opts) {
|
|
|
444
444
|
trustWorktreeRoot(dir)
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
// Activate opt-in release gating: write specs/.core/gating.config.json from the
|
|
448
|
+
// example asset, so /spec and friends start asking whether a change ships behind
|
|
449
|
+
// a feature flag and recording the answer.
|
|
450
|
+
//
|
|
451
|
+
// Only called when the operator opts in, and NEVER on `update` — for the same
|
|
452
|
+
// reason as isolation: adopting a policy is a deliberate choice, not something a
|
|
453
|
+
// re-sync flips on. Idempotent; copyAsset never clobbers an existing config
|
|
454
|
+
// without --force, so an operator's edited `guidance` survives a re-init.
|
|
455
|
+
function installGating(dir, { enabled }, opts) {
|
|
456
|
+
if (!enabled) return
|
|
457
|
+
copyAsset(
|
|
458
|
+
dir,
|
|
459
|
+
path.join('core', 'gating.config.json.example'),
|
|
460
|
+
path.join(dir, 'specs', '.core', 'gating.config.json'),
|
|
461
|
+
opts,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
447
465
|
// Seed the absolute worktree root into .claude/settings.local.json (gitignored)
|
|
448
466
|
// so the operator enabling isolation isn't prompted on every edit into a
|
|
449
467
|
// freshly-provisioned worktree. Best-effort: an unreadable config or malformed
|
|
@@ -682,6 +700,13 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
682
700
|
' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
|
|
683
701
|
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
684
702
|
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
703
|
+
const gatingOn = fs.existsSync(path.join(dir, 'specs', '.core', 'gating.config.json'))
|
|
704
|
+
const gatingNote = gatingOn
|
|
705
|
+
? 'Release gating is ON: /spec asks whether a change ships behind a feature' +
|
|
706
|
+
' flag and records the answer on the spec (skitterspec gating check reports' +
|
|
707
|
+
' any that have none).\n'
|
|
708
|
+
: 'Release gating is opt-in: re-run with --gating (or copy' +
|
|
709
|
+
' specs/.core/gating.config.json.example → gating.config.json) to enable it.\n'
|
|
685
710
|
// A provider superset ships its own `spec-<provider>-setup` skill; the base
|
|
686
711
|
// ships none. Discovering it from what was actually installed keeps this file
|
|
687
712
|
// tracker-free — it never has to know which tracker (if any) is in the box.
|
|
@@ -707,6 +732,7 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
707
732
|
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
708
733
|
" project's stack, then run /spec.\n" +
|
|
709
734
|
isolationNote +
|
|
735
|
+
gatingNote +
|
|
710
736
|
trackerNote,
|
|
711
737
|
)
|
|
712
738
|
}
|
|
@@ -714,7 +740,7 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
714
740
|
// `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
|
|
715
741
|
// unrelated to the config's own `mode` key — which arrives as `workspaceMode`
|
|
716
742
|
// precisely so the two cannot be confused at a call site.
|
|
717
|
-
async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
|
|
743
|
+
async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gating }) {
|
|
718
744
|
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
719
745
|
resetReport()
|
|
720
746
|
|
|
@@ -726,6 +752,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
|
|
|
726
752
|
installCore(dir, { force })
|
|
727
753
|
// Adopting isolation writes the live env.config.json — init only, never update.
|
|
728
754
|
if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
|
|
755
|
+
if (mode !== 'update') installGating(dir, { enabled: gating }, { force })
|
|
729
756
|
if (claudeMd) installClaudeMd(dir, { mode })
|
|
730
757
|
|
|
731
758
|
// Record what we wrote (and migrate a pre-manifest repo) so a later resync can
|
package/src/prompts.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* is `'worktree'` otherwise (the value the config defaults to anyway).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
async function promptSetup({ isolationSeed = false } = {}) {
|
|
14
|
+
async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
|
|
15
15
|
const prompts = require('prompts')
|
|
16
16
|
|
|
17
17
|
let cancelled = false
|
|
@@ -48,6 +48,14 @@ async function promptSetup({ isolationSeed = false } = {}) {
|
|
|
48
48
|
},
|
|
49
49
|
],
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
// Orthogonal to isolation, so it is asked unconditionally rather than
|
|
53
|
+
// nested under it — a project can adopt either, both, or neither.
|
|
54
|
+
type: 'confirm',
|
|
55
|
+
name: 'gating',
|
|
56
|
+
message: 'Record a release-gating decision on each spec — flag, or land live?',
|
|
57
|
+
initial: gatingSeed,
|
|
58
|
+
},
|
|
51
59
|
]
|
|
52
60
|
|
|
53
61
|
const ans = await prompts(questions, { onCancel })
|
|
@@ -59,6 +67,7 @@ async function promptSetup({ isolationSeed = false } = {}) {
|
|
|
59
67
|
return {
|
|
60
68
|
isolation: Boolean(ans.isolation),
|
|
61
69
|
mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
|
|
70
|
+
gating: Boolean(ans.gating),
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -242,6 +242,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
242
242
|
if (p.subIssues.update.length) lines.push(` sub-issues update: ${p.subIssues.update.map((s) => s.id).join(', ')}`)
|
|
243
243
|
lines.push(' (run with --json for the full plan the skill applies)')
|
|
244
244
|
}
|
|
245
|
+
lines.push(...unstampedLines(p))
|
|
245
246
|
out.write(lines.join('\n') + '\n')
|
|
246
247
|
return 0
|
|
247
248
|
}
|
|
@@ -249,6 +250,24 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
249
250
|
// `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
|
|
250
251
|
// plan or a status report is printed, because the alternative reading of a spec
|
|
251
252
|
// with no sub-issues is that its phase files failed to parse.
|
|
253
|
+
// Phases whose stamp is missing while the snapshot still remembers an id nobody
|
|
254
|
+
// claims. Reported OUTSIDE the empty-plan branch on purpose: a plan carrying
|
|
255
|
+
// nothing but these is "empty" for applying, and saying `up to date` about it
|
|
256
|
+
// would hide the one thing the operator has to act on.
|
|
257
|
+
function unstampedLines(plan) {
|
|
258
|
+
const u = (plan && plan.unstamped) || []
|
|
259
|
+
if (!u.length) return []
|
|
260
|
+
const lines = [
|
|
261
|
+
` unstamped: ${u.length} phase(s) have no linear_issue_id, and ${
|
|
262
|
+
u[0].candidates.length
|
|
263
|
+
} sub-issue(s) from the last push are unclaimed —`,
|
|
264
|
+
' not creating, since a lost stamp and a new phase look identical from here.',
|
|
265
|
+
]
|
|
266
|
+
for (const item of u) lines.push(` ${item.ref} — could be: ${item.candidates.join(', ')}`)
|
|
267
|
+
lines.push(' re-stamp the phase file (linear_issue_id) and push again.')
|
|
268
|
+
return lines
|
|
269
|
+
}
|
|
270
|
+
|
|
252
271
|
function deferredLines(n) {
|
|
253
272
|
return [
|
|
254
273
|
` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
|
|
@@ -483,6 +502,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
483
502
|
const u = plan.subIssues.update.length
|
|
484
503
|
lines.push(` push: pending — ${n} to create, ${u} to update${plan.issue ? ', issue changed' : ''}`)
|
|
485
504
|
}
|
|
505
|
+
lines.push(...unstampedLines(plan))
|
|
486
506
|
|
|
487
507
|
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
488
508
|
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
@@ -94,7 +94,9 @@ function snapshotOf(projection) {
|
|
|
94
94
|
/**
|
|
95
95
|
* Diff the local projection against the last-pushed snapshot.
|
|
96
96
|
* @returns {{ issue?: object, subIssues: {create,update} }}
|
|
97
|
-
*
|
|
97
|
+
* `unstamped` (when present) lists phases whose stamp is missing while the
|
|
98
|
+
* snapshot still remembers an unminted-for id — ambiguous, so neither created
|
|
99
|
+
* nor updated. create items carry a `ref` (local handle) and no id; update items carry both
|
|
98
100
|
* — the `ref` because the read-back check matches sub-issues to phases BY ref,
|
|
99
101
|
* and an update with only an id makes every one of them look unmatched.
|
|
100
102
|
* `plan.issue` (when present) is the spec issue's description + state; the push
|
|
@@ -105,16 +107,37 @@ function planChanges(projection, snapshot) {
|
|
|
105
107
|
const snap = snapshot || {}
|
|
106
108
|
const snapS = snap.subIssues || {}
|
|
107
109
|
|
|
110
|
+
// Ids the last push minted that no phase claims any more. A phase file's stamp
|
|
111
|
+
// is the only link back to its sub-issue, and it lives in frontmatter — so a
|
|
112
|
+
// whole-file rewrite, a hand edit or a bad merge drops it while the sub-issue
|
|
113
|
+
// carries on existing. This snapshot is the only memory that it was ever
|
|
114
|
+
// minted, and nothing else in the push path re-reads the tracker.
|
|
115
|
+
const claimed = new Set()
|
|
116
|
+
for (const s of p.subIssues || []) if (s.id != null) claimed.add(String(s.id))
|
|
117
|
+
const unclaimed = Object.keys(snapS).filter((id) => !claimed.has(id))
|
|
118
|
+
|
|
108
119
|
const subIssues = { create: [], update: [] }
|
|
120
|
+
const unstamped = []
|
|
109
121
|
for (const s of p.subIssues || []) {
|
|
110
122
|
if (s.id == null) {
|
|
111
|
-
|
|
123
|
+
// THREE STATES, NOT TWO. "No stamp" means "new phase" only when every id
|
|
124
|
+
// the snapshot remembers is still claimed. With an unclaimed id sitting
|
|
125
|
+
// beside an unstamped phase the two readings — a new phase, and a phase
|
|
126
|
+
// whose stamp was lost — are indistinguishable from here, so this routes to
|
|
127
|
+
// the harmless branch and mints nothing. Being wrong this way costs a
|
|
128
|
+
// re-stamp; the other way cost a duplicate sub-issue and a manual cancel.
|
|
129
|
+
if (unclaimed.length) {
|
|
130
|
+
unstamped.push({ ref: s.ref, name: s.name, candidates: unclaimed.slice() })
|
|
131
|
+
} else {
|
|
132
|
+
subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
|
|
133
|
+
}
|
|
112
134
|
} else if (snapS[String(s.id)] !== subIssueHash(s)) {
|
|
113
135
|
subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
114
136
|
}
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
const plan = { subIssues }
|
|
140
|
+
if (unstamped.length) plan.unstamped = unstamped
|
|
118
141
|
const issue = issueChanges(p, snap)
|
|
119
142
|
if (issue) plan.issue = issue
|
|
120
143
|
return plan
|