@skitterbyte/skitterspec 1.0.1 → 2.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/README.md +27 -244
- package/assets/claude-md-section.md +0 -6
- package/assets/core/env.config.json.example +5 -1
- package/assets/core/env.config.md +21 -5
- package/assets/rules/spec-planning.md +14 -10
- package/assets/skills/spec/SKILL.md +11 -38
- package/assets/skills/spec-complete/SKILL.md +31 -4
- package/assets/skills/spec-env/SKILL.md +6 -0
- package/assets/skills/spec-env-down/SKILL.md +16 -8
- package/assets/skills/spec-go/SKILL.md +15 -17
- package/package.json +6 -11
- package/src/cli.js +174 -318
- package/src/deprecate.js +138 -0
- package/src/env/config.js +17 -4
- package/src/env/integrate.js +46 -0
- package/src/env/resolve.js +54 -45
- package/src/env/teardown.js +19 -4
- package/src/env/trust.js +87 -0
- package/src/init.js +78 -170
- package/src/prompts.js +26 -63
- package/LICENSE +0 -21
- package/assets/core/linear.config.json.example +0 -39
- package/assets/core/linear.config.md +0 -121
- package/assets/rules/commit-messages.md +0 -85
- package/assets/scripts/generate-changelog.js +0 -274
- package/assets/scripts/generate-releases.js +0 -360
- package/assets/scripts/lib/config.js +0 -127
- package/assets/scripts/lib/git-commits.js +0 -265
- package/assets/skills/commit/SKILL.md +0 -28
- package/assets/skills/spec-pull/SKILL.md +0 -46
- package/assets/skills/spec-push/SKILL.md +0 -53
- package/assets/skills/spec-status/SKILL.md +0 -46
- package/src/config.js +0 -13
- package/src/sync/apply.js +0 -66
- package/src/sync/base.js +0 -83
- package/src/sync/compare.js +0 -99
- package/src/sync/config.js +0 -198
- package/src/sync/mcp.js +0 -112
- package/src/sync/normalize.js +0 -249
- package/src/sync/pull.js +0 -84
- package/src/sync/push.js +0 -106
- package/src/sync/write.js +0 -86
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Config loader for the release-artifact generators.
|
|
5
|
-
*
|
|
6
|
-
* Reads `skitterspec.config.json` from the repo root and normalises it over
|
|
7
|
-
* documented defaults. Shipped alongside the generators (copied into the
|
|
8
|
-
* consumer's `scripts/lib/`) so the consumer's scripts never depend back into
|
|
9
|
-
* the skitterspec package. Zero-dependency.
|
|
10
|
-
*
|
|
11
|
-
* Shape:
|
|
12
|
-
* {
|
|
13
|
-
* "version": 1,
|
|
14
|
-
* "changelog": { "enabled": true, "file": "CHANGELOG.md" },
|
|
15
|
-
* "releases": { "enabled": true, "file": "RELEASES.md",
|
|
16
|
-
* "productName": "<repo name>", "scopeAreas": {} },
|
|
17
|
-
* "versionHook": true
|
|
18
|
-
* }
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const { readFileSync } = require('node:fs')
|
|
22
|
-
const { basename, join } = require('node:path')
|
|
23
|
-
|
|
24
|
-
const SCHEMA_VERSION = 1
|
|
25
|
-
const CONFIG_FILE = 'skitterspec.config.json'
|
|
26
|
-
|
|
27
|
-
// Static template (productName is derived from the repo dir when blank).
|
|
28
|
-
const DEFAULT_CONFIG = Object.freeze({
|
|
29
|
-
version: SCHEMA_VERSION,
|
|
30
|
-
changelog: Object.freeze({ enabled: true, file: 'CHANGELOG.md' }),
|
|
31
|
-
releases: Object.freeze({
|
|
32
|
-
enabled: true,
|
|
33
|
-
file: 'RELEASES.md',
|
|
34
|
-
productName: '',
|
|
35
|
-
scopeAreas: Object.freeze({}),
|
|
36
|
-
}),
|
|
37
|
-
versionHook: true,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
function isObject(value) {
|
|
41
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function defaultsFor(dir) {
|
|
45
|
-
return {
|
|
46
|
-
version: SCHEMA_VERSION,
|
|
47
|
-
changelog: { enabled: true, file: 'CHANGELOG.md' },
|
|
48
|
-
releases: {
|
|
49
|
-
enabled: true,
|
|
50
|
-
file: 'RELEASES.md',
|
|
51
|
-
productName: basename(dir),
|
|
52
|
-
scopeAreas: {},
|
|
53
|
-
},
|
|
54
|
-
versionHook: true,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
60
|
-
* keys are ignored for forward-compat); `scopeAreas` is replaced wholesale, not
|
|
61
|
-
* deep-merged, since it's a complete map.
|
|
62
|
-
*/
|
|
63
|
-
function mergeConfig(base, parsed) {
|
|
64
|
-
if (!isObject(parsed)) return base
|
|
65
|
-
|
|
66
|
-
if (typeof parsed.version === 'number') base.version = parsed.version
|
|
67
|
-
if (typeof parsed.versionHook === 'boolean') base.versionHook = parsed.versionHook
|
|
68
|
-
|
|
69
|
-
if (isObject(parsed.changelog)) {
|
|
70
|
-
if (typeof parsed.changelog.enabled === 'boolean') {
|
|
71
|
-
base.changelog.enabled = parsed.changelog.enabled
|
|
72
|
-
}
|
|
73
|
-
if (typeof parsed.changelog.file === 'string' && parsed.changelog.file.trim()) {
|
|
74
|
-
base.changelog.file = parsed.changelog.file.trim()
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (isObject(parsed.releases)) {
|
|
79
|
-
if (typeof parsed.releases.enabled === 'boolean') {
|
|
80
|
-
base.releases.enabled = parsed.releases.enabled
|
|
81
|
-
}
|
|
82
|
-
if (typeof parsed.releases.file === 'string' && parsed.releases.file.trim()) {
|
|
83
|
-
base.releases.file = parsed.releases.file.trim()
|
|
84
|
-
}
|
|
85
|
-
if (typeof parsed.releases.productName === 'string' && parsed.releases.productName.trim()) {
|
|
86
|
-
base.releases.productName = parsed.releases.productName.trim()
|
|
87
|
-
}
|
|
88
|
-
if (isObject(parsed.releases.scopeAreas)) {
|
|
89
|
-
base.releases.scopeAreas = { ...parsed.releases.scopeAreas }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return base
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Load and normalise config from `dir` (default cwd). Missing file → all
|
|
98
|
-
* defaults. Malformed JSON → throws a clear Error (callers exit non-zero).
|
|
99
|
-
*/
|
|
100
|
-
function loadConfig(dir = process.cwd()) {
|
|
101
|
-
const base = defaultsFor(dir)
|
|
102
|
-
const file = join(dir, CONFIG_FILE)
|
|
103
|
-
|
|
104
|
-
let raw
|
|
105
|
-
try {
|
|
106
|
-
raw = readFileSync(file, 'utf-8')
|
|
107
|
-
} catch (error) {
|
|
108
|
-
if (error.code === 'ENOENT') return base
|
|
109
|
-
throw error
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let parsed
|
|
113
|
-
try {
|
|
114
|
-
parsed = JSON.parse(raw)
|
|
115
|
-
} catch (error) {
|
|
116
|
-
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return mergeConfig(base, parsed)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
module.exports = {
|
|
123
|
-
loadConfig,
|
|
124
|
-
DEFAULT_CONFIG,
|
|
125
|
-
SCHEMA_VERSION,
|
|
126
|
-
CONFIG_FILE,
|
|
127
|
-
}
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Shared git-history plumbing for the release artifact generators.
|
|
5
|
-
*
|
|
6
|
-
* Both `generate-changelog.js` (dev-facing CHANGELOG, from commit subjects) and
|
|
7
|
-
* `generate-releases.js` (user-facing RELEASES, from `Release-Note:` footers)
|
|
8
|
-
* walk the same tag ranges and parse the same conventional-commit format. This
|
|
9
|
-
* module is the single source of that logic so the two generators cannot drift.
|
|
10
|
-
*
|
|
11
|
-
* Commit serialisation: git log emits `hash\0subject\0body\0` per commit (NUL
|
|
12
|
-
* delimiters so multi-line bodies survive). `reconstructCommits` regroups the
|
|
13
|
-
* flat NUL-split array back into per-commit `hash\0subject\0body` strings.
|
|
14
|
-
*
|
|
15
|
-
* A parsed commit is a plain object:
|
|
16
|
-
* { type, scope?, message, body?, hash, breaking }
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const { execSync } = require('node:child_process')
|
|
20
|
-
|
|
21
|
-
function getCommitsSinceLastTag(currentVersion) {
|
|
22
|
-
try {
|
|
23
|
-
// Fetch tags to ensure they're available (important in CI)
|
|
24
|
-
try {
|
|
25
|
-
execSync('git fetch --tags --force', { encoding: 'utf-8', stdio: 'pipe' })
|
|
26
|
-
} catch {
|
|
27
|
-
// If fetch fails, continue - tags might already be available
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Get all tags sorted by version (newest first)
|
|
31
|
-
const allTags = execSync('git tag --sort=-version:refname', {
|
|
32
|
-
encoding: 'utf-8',
|
|
33
|
-
stdio: 'pipe',
|
|
34
|
-
})
|
|
35
|
-
.trim()
|
|
36
|
-
.split('\n')
|
|
37
|
-
.filter((tag) => tag.trim().length > 0)
|
|
38
|
-
|
|
39
|
-
// Determine the previous tag to compare against
|
|
40
|
-
let previousTag = null
|
|
41
|
-
let currentTag = null
|
|
42
|
-
|
|
43
|
-
if (allTags.length === 0) {
|
|
44
|
-
// No tags exist, get all commits
|
|
45
|
-
const output = execSync('git log --pretty=format:"%h%x00%s%x00%b%x00" --no-merges', {
|
|
46
|
-
encoding: 'utf-8',
|
|
47
|
-
stdio: 'pipe',
|
|
48
|
-
}).trim()
|
|
49
|
-
|
|
50
|
-
return reconstructCommits(output)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Check if HEAD is at a tag
|
|
54
|
-
try {
|
|
55
|
-
currentTag = execSync('git describe --tags --exact-match HEAD', {
|
|
56
|
-
encoding: 'utf-8',
|
|
57
|
-
stdio: 'pipe',
|
|
58
|
-
}).trim()
|
|
59
|
-
} catch {
|
|
60
|
-
// HEAD is not at a tag - try to get current branch/tag from environment
|
|
61
|
-
// In CI, Build.SourceBranchName might be available
|
|
62
|
-
const sourceBranch = process.env.BUILD_SOURCEBRANCHNAME || process.env.BUILD_SOURCEBRANCH
|
|
63
|
-
if (sourceBranch && sourceBranch.startsWith('v')) {
|
|
64
|
-
currentTag = sourceBranch
|
|
65
|
-
} else if (currentVersion) {
|
|
66
|
-
// Use the version parameter as fallback (e.g., "8.0.0" -> "v8.0.0")
|
|
67
|
-
const versionTag = `v${currentVersion}`
|
|
68
|
-
if (allTags.includes(versionTag)) {
|
|
69
|
-
currentTag = versionTag
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (currentTag && allTags.includes(currentTag)) {
|
|
75
|
-
// HEAD is at a tag - find the previous tag
|
|
76
|
-
const currentIndex = allTags.indexOf(currentTag)
|
|
77
|
-
if (currentIndex > 0) {
|
|
78
|
-
// There is a previous tag
|
|
79
|
-
previousTag = allTags[currentIndex - 1]
|
|
80
|
-
} else {
|
|
81
|
-
// This is the first tag, get all commits
|
|
82
|
-
const output = execSync('git log --pretty=format:"%h%x00%s%x00%b%x00" --no-merges', {
|
|
83
|
-
encoding: 'utf-8',
|
|
84
|
-
stdio: 'pipe',
|
|
85
|
-
}).trim()
|
|
86
|
-
|
|
87
|
-
return reconstructCommits(output)
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
// HEAD is not at a tag, use the most recent tag
|
|
91
|
-
previousTag = allTags[0]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!previousTag) {
|
|
95
|
-
// No previous tag found, get all commits
|
|
96
|
-
const output = execSync('git log --pretty=format:"%h%x00%s%x00%b%x00" --no-merges', {
|
|
97
|
-
encoding: 'utf-8',
|
|
98
|
-
stdio: 'pipe',
|
|
99
|
-
}).trim()
|
|
100
|
-
|
|
101
|
-
return reconstructCommits(output)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// When HEAD is at a tag, use the tag explicitly instead of HEAD
|
|
105
|
-
// This ensures we get commits up to and including the tag commit
|
|
106
|
-
const rangeEnd = currentTag || 'HEAD'
|
|
107
|
-
|
|
108
|
-
// Get commits since previous tag (inclusive of rangeEnd)
|
|
109
|
-
// Use null character as delimiter to handle multi-line bodies
|
|
110
|
-
// Format: hash\0subject\0body\0hash2\0subject2\0body2\0...
|
|
111
|
-
// NOTE: do NOT pass --all here — it traverses every ref (branches,
|
|
112
|
-
// remotes, tags) and leaks commits from unmerged branches into the
|
|
113
|
-
// range. Shallow-clone fallback below uses git fetch --unshallow.
|
|
114
|
-
const output = execSync(
|
|
115
|
-
`git log ${previousTag}..${rangeEnd} --pretty=format:"%h%x00%s%x00%b%x00" --no-merges`,
|
|
116
|
-
{ encoding: 'utf-8', stdio: 'pipe' },
|
|
117
|
-
).trim()
|
|
118
|
-
|
|
119
|
-
const commits = reconstructCommits(output)
|
|
120
|
-
|
|
121
|
-
// If no commits found and we're in CI, try unshallow the repo
|
|
122
|
-
if (commits.length === 0) {
|
|
123
|
-
try {
|
|
124
|
-
execSync('git fetch --unshallow', { encoding: 'utf-8', stdio: 'pipe' })
|
|
125
|
-
// Try again after unshallow
|
|
126
|
-
const retryOutput = execSync(
|
|
127
|
-
`git log ${previousTag}..${rangeEnd} --pretty=format:"%h%x00%s%x00%b%x00" --no-merges`,
|
|
128
|
-
{ encoding: 'utf-8', stdio: 'pipe' },
|
|
129
|
-
).trim()
|
|
130
|
-
return reconstructCommits(retryOutput)
|
|
131
|
-
} catch {
|
|
132
|
-
// Unshallow failed or not a shallow clone, return empty
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return commits
|
|
137
|
-
} catch (error) {
|
|
138
|
-
// If git commands fail, try to get all commits as fallback
|
|
139
|
-
try {
|
|
140
|
-
const output = execSync('git log --pretty=format:"%h%x00%s%x00%b%x00" --no-merges', {
|
|
141
|
-
encoding: 'utf-8',
|
|
142
|
-
stdio: 'pipe',
|
|
143
|
-
}).trim()
|
|
144
|
-
|
|
145
|
-
return reconstructCommits(output)
|
|
146
|
-
} catch {
|
|
147
|
-
console.error('Failed to get git commits:', error)
|
|
148
|
-
return []
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function reconstructCommits(output) {
|
|
154
|
-
if (!output.trim()) {
|
|
155
|
-
return []
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Split by null character - DO NOT filter empty parts yet
|
|
159
|
-
// Empty bodies are valid and needed to maintain correct grouping
|
|
160
|
-
const parts = output.split('\0')
|
|
161
|
-
|
|
162
|
-
const commits = []
|
|
163
|
-
|
|
164
|
-
// Group parts into commits: each commit has hash, subject, body
|
|
165
|
-
// Parts array: [hash1, subject1, body1, hash2, subject2, body2, ...]
|
|
166
|
-
// Trailing empty string from final \0 is expected and ignored
|
|
167
|
-
for (let i = 0; i < parts.length - 1; i += 3) {
|
|
168
|
-
const hash = parts[i] || ''
|
|
169
|
-
const subject = parts[i + 1] || ''
|
|
170
|
-
const body = parts[i + 2] || ''
|
|
171
|
-
|
|
172
|
-
// Only add commit if we have hash and subject (body can be empty)
|
|
173
|
-
if (hash.trim() && subject.trim()) {
|
|
174
|
-
// Reconstruct commit string with null delimiters
|
|
175
|
-
commits.push(`${hash}\0${subject}\0${body}`)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return commits
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function parseCommit(commitLine) {
|
|
183
|
-
// Split by null character (used as delimiter in git log format)
|
|
184
|
-
const parts = commitLine.split('\0')
|
|
185
|
-
|
|
186
|
-
// Need at least hash and subject (body is optional)
|
|
187
|
-
if (parts.length < 2) {
|
|
188
|
-
return null // Invalid format, skip
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const hash = parts[0].trim()
|
|
192
|
-
const subject = parts[1].trim()
|
|
193
|
-
const body = (parts[2] && parts[2].trim()) || undefined
|
|
194
|
-
|
|
195
|
-
// Parse conventional commit format: type(scope)!: description
|
|
196
|
-
// The optional `!` marks a breaking change per the Conventional Commits spec.
|
|
197
|
-
const conventionalCommitRegex = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/
|
|
198
|
-
const match = subject.match(conventionalCommitRegex)
|
|
199
|
-
|
|
200
|
-
if (!match) {
|
|
201
|
-
return null // Skip non-conventional commits
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const [, type, scope, bang, message] = match
|
|
205
|
-
|
|
206
|
-
// Breaking change markers:
|
|
207
|
-
// 1. `!` suffix on type/scope (e.g. `feat!:` or `feat(api)!:`)
|
|
208
|
-
// 2. A `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer in the body
|
|
209
|
-
const breakingFooterRegex = /(^|\n)BREAKING[- ]CHANGE:/i
|
|
210
|
-
const breaking = Boolean(bang) || (body ? breakingFooterRegex.test(body) : false)
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
type: type.toLowerCase(),
|
|
214
|
-
scope: scope || undefined,
|
|
215
|
-
message: message.trim(),
|
|
216
|
-
body: body,
|
|
217
|
-
hash: hash.trim(),
|
|
218
|
-
breaking,
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function getAllVersionTags() {
|
|
223
|
-
try {
|
|
224
|
-
execSync('git fetch --tags --force', { encoding: 'utf-8', stdio: 'pipe' })
|
|
225
|
-
} catch {
|
|
226
|
-
// fetch is best-effort
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return execSync('git tag --sort=-version:refname', { encoding: 'utf-8', stdio: 'pipe' })
|
|
230
|
-
.trim()
|
|
231
|
-
.split('\n')
|
|
232
|
-
.map((t) => t.trim())
|
|
233
|
-
.filter((t) => /^v?\d+\.\d+\.\d+/.test(t))
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function getCommitsBetween(fromTag, toTag) {
|
|
237
|
-
const range = fromTag ? `${fromTag}..${toTag}` : toTag
|
|
238
|
-
const output = execSync(`git log ${range} --pretty=format:"%h%x00%s%x00%b%x00" --no-merges`, {
|
|
239
|
-
encoding: 'utf-8',
|
|
240
|
-
stdio: 'pipe',
|
|
241
|
-
}).trim()
|
|
242
|
-
return reconstructCommits(output)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function getTagDate(tag) {
|
|
246
|
-
try {
|
|
247
|
-
return execSync(`git log -1 --format=%cs ${tag}`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
|
|
248
|
-
} catch {
|
|
249
|
-
return new Date().toISOString().split('T')[0]
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function escapeRegex(value) {
|
|
254
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
module.exports = {
|
|
258
|
-
getCommitsSinceLastTag,
|
|
259
|
-
reconstructCommits,
|
|
260
|
-
parseCommit,
|
|
261
|
-
getAllVersionTags,
|
|
262
|
-
getCommitsBetween,
|
|
263
|
-
getTagDate,
|
|
264
|
-
escapeRegex,
|
|
265
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: commit
|
|
3
|
-
description: Stage and commit the current change with a concise conventional-commit message. Stages only files related to the task, runs typecheck and the relevant tests first, and appends a Release-Note: footer when the change is user-visible (grammar in .claude/rules/commit-messages.md). Use when the user says "/commit", "commit this", or wants their working changes committed.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /commit — stage and commit the current change
|
|
7
|
-
|
|
8
|
-
A disciplined commit: stage only what belongs to the task, prove it's green,
|
|
9
|
-
then write a conventional-commit message — with a `Release-Note:` footer when an
|
|
10
|
-
end user would notice the change. Message grammar and length limits live in
|
|
11
|
-
`.claude/rules/commit-messages.md`.
|
|
12
|
-
|
|
13
|
-
1. Run `git status` and `git diff --staged`.
|
|
14
|
-
2. Stage ONLY files related to the current task (ignore unrelated UI/config
|
|
15
|
-
drift).
|
|
16
|
-
3. Run typecheck and the relevant tests.
|
|
17
|
-
4. Write a concise conventional commit message scoped to the change.
|
|
18
|
-
5. **Decide if the change is user-visible.** If an end user would notice it
|
|
19
|
-
(feature, fix, improvement), append a `Release-Note:` footer in plain user
|
|
20
|
-
language — what they can now do, not the implementation. Use `Release-Note!:`
|
|
21
|
-
for a release headline, and `Release-Area:` to override the area when the
|
|
22
|
-
scope isn't a user area. Omit the footer for internal/dev-only changes
|
|
23
|
-
(`chore`, `test`, `docs`, refactors with no user effect). Put a blank line
|
|
24
|
-
before the footer. See `.claude/rules/commit-messages.md` → "Release notes
|
|
25
|
-
footer" for the grammar. When the release tooling is installed, these footers
|
|
26
|
-
are what the generated release notes are built from at `npm version` — so the
|
|
27
|
-
note is the user-facing record of the change, not just metadata.
|
|
28
|
-
6. Do NOT ask about unrelated uncommitted files.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spec-pull
|
|
3
|
-
description: Pull a spec's linked Linear project into the local spec (Linear → repo), three-way aware. Applies remote-only fields; refuses to clobber local edits on a conflict unless --force (which backs up the local side first). Fetches Linear over MCP and runs `skitterspec spec-sync pull`. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-pull", "pull from Linear", "sync Linear changes down", or "update this spec from Linear".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /spec-pull — bring Linear changes into the spec
|
|
7
|
-
|
|
8
|
-
Linear → repo. Applies fields Linear changed since the last sync (status,
|
|
9
|
-
priority, labels, and co-authored fields), rewrites the committed base, and
|
|
10
|
-
stamps `last_synced_at`. It **refuses** to overwrite a local edit that conflicts
|
|
11
|
-
with a Linear edit unless you pass `--force`.
|
|
12
|
-
|
|
13
|
-
**Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
|
|
14
|
-
tell the user how to enable Linear sync and stop.
|
|
15
|
-
|
|
16
|
-
## 1. Identify the target spec
|
|
17
|
-
|
|
18
|
-
Use the argument, else the spec in context; ask if unclear.
|
|
19
|
-
|
|
20
|
-
## 2. Fetch the Linear project
|
|
21
|
-
|
|
22
|
-
- Read `linear_project_id` from `00-overview.md` frontmatter; if missing, the
|
|
23
|
-
spec isn't linked — stop and point at `/spec`.
|
|
24
|
-
- Discover the Linear MCP project-read tool at runtime. If Linear isn't
|
|
25
|
-
connected, relay the fix and stop — **do nothing destructive**.
|
|
26
|
-
- Call it and write the project JSON to a temp file.
|
|
27
|
-
|
|
28
|
-
## 3. Run the engine
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
skitterspec spec-sync pull <spec> --remote <tempfile> [--force]
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
- **No conflict** — the engine applies remote-only fields to the local snapshot,
|
|
35
|
-
rewrites the base, and stamps the sync. Body fields with no local home yet are
|
|
36
|
-
reported as `deferred` (apply them by hand from Linear if needed).
|
|
37
|
-
- **Conflict** (a co-authored field changed on both sides) — the engine
|
|
38
|
-
**refuses** and lists the fields. Relay that; do not force on the user's behalf.
|
|
39
|
-
- **`--force`** — only when the user explicitly asks. Remote wins after the engine
|
|
40
|
-
backs up the local side under `sync.backupDir` (the reflog). Relay the backup
|
|
41
|
-
path.
|
|
42
|
-
|
|
43
|
-
## 4. Report
|
|
44
|
-
|
|
45
|
-
Relay the git-like summary (applied / deferred / conflicts / backup / base). If
|
|
46
|
-
fields were applied, remind the user to review and commit the refreshed snapshot.
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spec-push
|
|
3
|
-
description: Push a spec's local content up to its linked Linear project (repo → Linear), three-way aware and ownership-respecting. Never pushes pull-owned fields or local-only sections; aborts if Linear moved since the last sync unless --force (which backs up the remote side first). Runs `skitterspec spec-sync push` then applies the blessed writes over MCP. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-push", "push to Linear", "sync my spec up to Linear", or "update the Linear project from this spec".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /spec-push — send spec content up to Linear
|
|
7
|
-
|
|
8
|
-
Repo → Linear. Sends the fields the repo owns/co-authors (description, phases,
|
|
9
|
-
tasks per config) up to the linked project. It **never** writes `pull`-owned
|
|
10
|
-
fields (status/priority/labels) or `localOnlySections`, and it **aborts** if
|
|
11
|
-
Linear moved since the last sync (pull first) unless you `--force`.
|
|
12
|
-
|
|
13
|
-
**Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
|
|
14
|
-
tell the user how to enable Linear sync and stop.
|
|
15
|
-
|
|
16
|
-
## 1. Identify the target spec
|
|
17
|
-
|
|
18
|
-
Use the argument, else the spec in context; ask if unclear.
|
|
19
|
-
|
|
20
|
-
## 2. Fetch the Linear project
|
|
21
|
-
|
|
22
|
-
- Read `linear_project_id` from `00-overview.md` frontmatter; if missing, stop
|
|
23
|
-
(link via `/spec` first).
|
|
24
|
-
- Discover the Linear MCP tools at runtime (project read **and** update). If
|
|
25
|
-
Linear isn't connected — or the update tool is missing — relay the fix and stop,
|
|
26
|
-
**writing nothing**.
|
|
27
|
-
- Call the read tool and write the project JSON to a temp file.
|
|
28
|
-
|
|
29
|
-
## 3. Run the engine (the guard)
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
skitterspec spec-sync push <spec> --remote <tempfile> --out <mergedfile> [--force]
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
- **Refused** (`remote-moved` / `concurrent-write` / conflict) — relay the message
|
|
36
|
-
and **stop**. Do not write to Linear. Suggest `/spec-pull` first.
|
|
37
|
-
- **OK** — the engine has confirmed it's safe, rewritten the base, and stamped
|
|
38
|
-
`last_synced_at`. Its summary lists the `written` fields (and any `skipped`
|
|
39
|
-
because they're not pushable).
|
|
40
|
-
- **`--force`** — only when the user explicitly asks. Local wins after the engine
|
|
41
|
-
backs up the remote side under `sync.backupDir`. Relay the backup path.
|
|
42
|
-
|
|
43
|
-
## 4. Apply the blessed writes to Linear
|
|
44
|
-
|
|
45
|
-
Only when step 3 returned OK: for each `written` field, call the Linear update
|
|
46
|
-
tool with that field's local value (e.g. `description` → the project description).
|
|
47
|
-
The engine has already vetted the change and moved the base — so if a Linear
|
|
48
|
-
write fails, re-run `/spec-pull` to reconcile rather than retrying blindly.
|
|
49
|
-
|
|
50
|
-
## 5. Report
|
|
51
|
-
|
|
52
|
-
Relay the git-like summary (written / skipped / backup / base) plus which Linear
|
|
53
|
-
fields you updated.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spec-status
|
|
3
|
-
description: Show a spec's sync status against its linked Linear project — a read-only, git-status-style per-field divergence (local-only / remote-only / conflict / in-sync). Fetches the Linear project over MCP and runs `skitterspec spec-sync status`. Changes nothing. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-status", "is this spec in sync with Linear", "what's diverged from Linear", or "show spec sync status".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /spec-status — show a spec's divergence from Linear
|
|
7
|
-
|
|
8
|
-
Read-only. Prints, per field, whether the spec and its linked Linear project have
|
|
9
|
-
diverged since the last sync — the `git status` of the hybrid sync. Writes
|
|
10
|
-
nothing to either side.
|
|
11
|
-
|
|
12
|
-
This skill is **opt-in**: it only runs when `specs/.core/linear.config.json`
|
|
13
|
-
exists. If it's absent, tell the user to copy `linear.config.json.example` →
|
|
14
|
-
`linear.config.json` to enable Linear sync, and stop.
|
|
15
|
-
|
|
16
|
-
## 1. Identify the target spec
|
|
17
|
-
|
|
18
|
-
Use the spec named as an argument, else the spec **currently in context**. If
|
|
19
|
-
neither is clear, ask which spec.
|
|
20
|
-
|
|
21
|
-
## 2. Fetch the Linear project (read-only)
|
|
22
|
-
|
|
23
|
-
- Read the spec's `linear_project_id` from `00-overview.md` frontmatter. If it's
|
|
24
|
-
missing, the spec isn't linked yet — say so and stop (link it via `/spec`).
|
|
25
|
-
- Discover the connected Linear MCP tools at runtime (the project-read tool). If
|
|
26
|
-
Linear isn't connected, relay "connect the `linear` MCP server" and stop — do
|
|
27
|
-
nothing else.
|
|
28
|
-
- Call the project-read tool for that id and write the returned JSON to a temp
|
|
29
|
-
file (e.g. under the OS temp dir).
|
|
30
|
-
|
|
31
|
-
## 3. Run the engine
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
skitterspec spec-sync status <spec> --remote <tempfile>
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
The engine does the three-way compare (local vs Linear vs the committed base) and
|
|
38
|
-
prints each diverged field with its classification and sync direction. Without
|
|
39
|
-
`--remote` it falls back to a local-vs-base comparison (still read-only).
|
|
40
|
-
|
|
41
|
-
## 4. Report
|
|
42
|
-
|
|
43
|
-
Relay the engine's summary verbatim, then offer the natural next step:
|
|
44
|
-
`/spec-pull` for remote-only changes, `/spec-push` for local-only, and — for a
|
|
45
|
-
`conflict` — resolve locally or use `--force` (which backs up the losing side).
|
|
46
|
-
Never write anything from this skill.
|
package/src/config.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Config helpers for the skitterspec CLI.
|
|
5
|
-
*
|
|
6
|
-
* The implementation lives in `assets/scripts/lib/config.js` — the same file
|
|
7
|
-
* that ships into a consumer's `scripts/lib/`, so the loader has one source of
|
|
8
|
-
* truth and the consumer's copied scripts never depend back into this package.
|
|
9
|
-
* This module just re-exports it for use by the CLI (`init`, the install
|
|
10
|
-
* prompts in Phase 3).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
module.exports = require('../assets/scripts/lib/config.js')
|
package/src/sync/apply.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Translate normalized field values into a local frontmatter patch (pull side).
|
|
5
|
-
*
|
|
6
|
-
* Only the `pull`-owned, frontmatter-backed fields have a local home in Phase 2:
|
|
7
|
-
* workflowState → spec_status (Linear state name mapped back to the bucket),
|
|
8
|
-
* priority → priority,
|
|
9
|
-
* labels → labels.
|
|
10
|
-
* Any other field handed in (a body field like `description`/`milestones`) has no
|
|
11
|
-
* frontmatter mapping yet, so it's returned in `deferred` — the caller must NOT
|
|
12
|
-
* advance its base, keeping the remote edit pending instead of falsely synced.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// field name → frontmatter key.
|
|
16
|
-
const FRONTMATTER_FIELD = {
|
|
17
|
-
workflowState: 'spec_status',
|
|
18
|
-
priority: 'priority',
|
|
19
|
-
labels: 'labels',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Invert config.states ({ bucket: "Linear Name" }) → { "linear name": bucket }.
|
|
23
|
-
function invertStates(config) {
|
|
24
|
-
const out = {}
|
|
25
|
-
const states = (config && config.states) || {}
|
|
26
|
-
for (const [bucket, name] of Object.entries(states)) {
|
|
27
|
-
if (typeof name === 'string') out[name.toLowerCase()] = bucket
|
|
28
|
-
}
|
|
29
|
-
return out
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Map a remote workflowState (a Linear state name) back to a local bucket. Falls
|
|
33
|
-
// back to the raw value when it isn't one of the configured states.
|
|
34
|
-
function localWorkflowState(value, config) {
|
|
35
|
-
if (value == null) return null
|
|
36
|
-
const bucket = invertStates(config)[String(value).toLowerCase()]
|
|
37
|
-
return bucket || String(value)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Build the frontmatter patch for a set of applied field values.
|
|
42
|
-
* @param {object} fieldValues { fieldName: value } to write locally
|
|
43
|
-
* @returns {{ patch:object, applied:string[], deferred:string[] }}
|
|
44
|
-
*/
|
|
45
|
-
function frontmatterPatchFor(fieldValues, config) {
|
|
46
|
-
const patch = {}
|
|
47
|
-
const applied = []
|
|
48
|
-
const deferred = []
|
|
49
|
-
for (const [field, value] of Object.entries(fieldValues)) {
|
|
50
|
-
const key = FRONTMATTER_FIELD[field]
|
|
51
|
-
if (!key) {
|
|
52
|
-
deferred.push(field)
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
patch[key] = field === 'workflowState' ? localWorkflowState(value, config) : value
|
|
56
|
-
applied.push(field)
|
|
57
|
-
}
|
|
58
|
-
return { patch, applied, deferred }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
module.exports = {
|
|
62
|
-
frontmatterPatchFor,
|
|
63
|
-
localWorkflowState,
|
|
64
|
-
invertStates,
|
|
65
|
-
FRONTMATTER_FIELD,
|
|
66
|
-
}
|