@skitterbyte/skitterspec 0.1.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/LICENSE +21 -0
- package/README.md +164 -0
- package/assets/claude-md-section.md +34 -0
- package/assets/rules/commit-messages.md +85 -0
- package/assets/rules/spec-planning.md +130 -0
- package/assets/scripts/generate-changelog.js +274 -0
- package/assets/scripts/generate-releases.js +360 -0
- package/assets/scripts/lib/config.js +127 -0
- package/assets/scripts/lib/git-commits.js +265 -0
- package/assets/skills/commit/SKILL.md +28 -0
- package/assets/skills/spec/SKILL.md +176 -0
- package/assets/skills/spec-bug/SKILL.md +114 -0
- package/assets/skills/spec-cancel/SKILL.md +56 -0
- package/assets/skills/spec-complete/SKILL.md +60 -0
- package/assets/skills/spec-go/SKILL.md +87 -0
- package/assets/skills/spec-init/SKILL.md +92 -0
- package/assets/skills/spec-ready/SKILL.md +52 -0
- package/assets/skills/spec-review/SKILL.md +69 -0
- package/bin/skitterspec.js +9 -0
- package/package.json +42 -0
- package/src/cli.js +132 -0
- package/src/config.js +13 -0
- package/src/init.js +348 -0
- package/src/prompts.js +82 -0
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec
|
|
3
|
+
description: Create a new spec-driven-development spec. Grills the user to a clear, shared understanding of the requirement AND the proposed solution FIRST, then writes one concise, phased, test-included, change-logged spec into specs/backlog/. Use when the user wants to plan a feature, write a spec, capture a requirement, or says "/spec" or "spec this out".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /spec — author a new spec
|
|
7
|
+
|
|
8
|
+
Produce ONE concise spec in `specs/backlog/`. Do not start coding — this skill
|
|
9
|
+
plans only. Implementation happens later via `/spec-go`.
|
|
10
|
+
|
|
11
|
+
Lifecycle (the governing skills) — status in parentheses:
|
|
12
|
+
`/spec` (Draft, backlog) → `/spec-ready` (Ready, still backlog) → `/spec-go`
|
|
13
|
+
(In Progress, in-progress; implement phase 1) → `/spec-complete` (Complete) /
|
|
14
|
+
`/spec-cancel` (Cancelled). See `.claude/rules/spec-planning.md`.
|
|
15
|
+
|
|
16
|
+
## Phase A — reach a clear shared understanding (grill first)
|
|
17
|
+
|
|
18
|
+
Interview the user until requirement AND proposed solution are unambiguous. Do
|
|
19
|
+
not write the spec until this is resolved.
|
|
20
|
+
|
|
21
|
+
- Break the problem into **distinctive areas** and work them in logical order,
|
|
22
|
+
resolving dependencies between decisions one at a time.
|
|
23
|
+
- Ask **one question at a time**. For each, give your **recommended answer**.
|
|
24
|
+
- If a question can be answered by **reading the codebase, read it** instead of
|
|
25
|
+
asking. Verify endpoints/models/files actually exist before relying on them.
|
|
26
|
+
- Cover, at minimum, the areas that apply:
|
|
27
|
+
1. **Problem & why** — what's broken/missing, who feels it, why now.
|
|
28
|
+
2. **Scope & non-goals** — explicit out-of-scope items.
|
|
29
|
+
3. **Affected areas** — concrete files/modules/packages this touches.
|
|
30
|
+
4. **Proposed solution shape** — the chosen approach and the alternatives
|
|
31
|
+
rejected, with the reason (this becomes "Decisions").
|
|
32
|
+
5. **Data / API impact** — schema/model changes, new endpoints, and
|
|
33
|
+
**backward compatibility** (additive = safe; breaking = needs explicit
|
|
34
|
+
permission and coordination).
|
|
35
|
+
6. **Security & multi-tenancy** — authz, tenant scoping, untrusted input.
|
|
36
|
+
7. **Edge cases & failure modes.**
|
|
37
|
+
8. **Testing approach** — what proves each phase correct.
|
|
38
|
+
9. **Open questions** — anything still undecided.
|
|
39
|
+
|
|
40
|
+
Stop grilling when there are no unresolved branches that would change the spec.
|
|
41
|
+
Briefly play back the agreed understanding before writing.
|
|
42
|
+
|
|
43
|
+
## Phase B — write the spec
|
|
44
|
+
|
|
45
|
+
This skill is for **features**. For bugs, use `/spec-bug` (test-first, red→green).
|
|
46
|
+
|
|
47
|
+
- **Every spec is a folder** — never a bare file, even for a one-line change:
|
|
48
|
+
`specs/backlog/feat-<kebab-name>/`. Create it with `mkdir -p`.
|
|
49
|
+
- The entry point is **always `00-overview.md`** — the index/dashboard for the
|
|
50
|
+
spec. It holds the header block, Problem, Decisions, Solution overview, the
|
|
51
|
+
**phase index** (a table linking to each phase file), Open questions, State
|
|
52
|
+
log, and Changelog. It does **not** hold the per-phase task lists.
|
|
53
|
+
- **Each phase is its own file** — `01-<phase-slug>.md`, `02-<phase-slug>.md`, …
|
|
54
|
+
numbered in execution order; the slug is a short kebab description of the phase
|
|
55
|
+
goal (e.g. `01-data-model.md`, `02-api-endpoints.md`). The phase file holds
|
|
56
|
+
that phase's goal, its task checkboxes (tests included), and any phase-specific
|
|
57
|
+
notes. **Even a single-phase spec gets `01-….md`** — never lump phase tasks
|
|
58
|
+
into `00-overview.md`. This keeps each phase easy to dive into on its own.
|
|
59
|
+
- Choose a short kebab-case name and **prefix it `feat-`** (the bug counterpart
|
|
60
|
+
uses `bug-`).
|
|
61
|
+
|
|
62
|
+
Use this template (keep it **as concise as possible** — no filler, no restating
|
|
63
|
+
the codebase, link rather than duplicate):
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
# <Feature title>
|
|
67
|
+
|
|
68
|
+
> **Type:** Feature
|
|
69
|
+
> **Status:** Draft — not started
|
|
70
|
+
> **Author:** <git user.name — `git config user.name`>
|
|
71
|
+
> **Developer:** —
|
|
72
|
+
> **Raised:** <YYYY-MM-DD (today)>
|
|
73
|
+
> **Area:** <comma-separated files/modules this touches>
|
|
74
|
+
|
|
75
|
+
## Problem
|
|
76
|
+
|
|
77
|
+
<2–6 sentences: what's wrong/missing and why it matters. No fluff.>
|
|
78
|
+
|
|
79
|
+
## Decisions
|
|
80
|
+
|
|
81
|
+
<Numbered, confirmed decisions from Phase A. Each: the choice + one-line why,
|
|
82
|
+
and the rejected alternative when it sharpens the choice. This is the heart of
|
|
83
|
+
the spec — be specific.>
|
|
84
|
+
|
|
85
|
+
## Solution overview
|
|
86
|
+
|
|
87
|
+
<Short prose or bullets describing the chosen shape end-to-end. Optional small
|
|
88
|
+
schema/grammar/output snippets where they remove ambiguity.>
|
|
89
|
+
|
|
90
|
+
## Phases
|
|
91
|
+
|
|
92
|
+
Each phase lives in its own file in this folder. Status: ⬜ not started ·
|
|
93
|
+
🔄 in progress · ✅ done.
|
|
94
|
+
|
|
95
|
+
| # | Phase | Status | File |
|
|
96
|
+
|---|-------|--------|------|
|
|
97
|
+
| 1 | <goal> | ⬜ | [01-<phase-slug>.md](01-<phase-slug>.md) |
|
|
98
|
+
| 2 | <goal> | ⬜ | [02-<phase-slug>.md](02-<phase-slug>.md) |
|
|
99
|
+
|
|
100
|
+
## Open questions
|
|
101
|
+
|
|
102
|
+
- [ ] <anything deferred — or "None">
|
|
103
|
+
|
|
104
|
+
## State log
|
|
105
|
+
|
|
106
|
+
| Date | Status | Folder | By |
|
|
107
|
+
|------|--------|--------|----|
|
|
108
|
+
| <YYYY-MM-DD> | Draft | backlog | <author> |
|
|
109
|
+
|
|
110
|
+
## Changelog
|
|
111
|
+
|
|
112
|
+
- <YYYY-MM-DD> — Spec created.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then create **one file per phase** (`01-<phase-slug>.md`, `02-…`, in execution
|
|
116
|
+
order). Each phase file uses this template:
|
|
117
|
+
|
|
118
|
+
```markdown
|
|
119
|
+
# Phase 1 — <goal> ⬜
|
|
120
|
+
|
|
121
|
+
> Spec: [00-overview.md](00-overview.md) · **Status:** Not started
|
|
122
|
+
|
|
123
|
+
**Goal:** <one line — what this phase delivers and how it's proven>.
|
|
124
|
+
|
|
125
|
+
## Tasks
|
|
126
|
+
|
|
127
|
+
- [ ] <clear, verb-first task>
|
|
128
|
+
- [ ] <clear, verb-first task>
|
|
129
|
+
- [ ] Add/extend tests covering this phase; run the project's typecheck and
|
|
130
|
+
test commands (see `.claude/rules/spec-planning.md`) — green before the
|
|
131
|
+
phase is done.
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
<Phase-specific decisions, gotchas, or context. Delete if empty.>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Keep the `00-overview.md` phase index and the phase files in sync: the index row
|
|
139
|
+
is the one-line summary + status; the phase file is the detail.
|
|
140
|
+
|
|
141
|
+
The **State log** is the audit trail of folder/status transitions — every
|
|
142
|
+
lifecycle skill (`/spec-ready`, `/spec-go`, `/spec-complete`, `/spec-cancel`)
|
|
143
|
+
appends one row when it moves the spec. The **Changelog** is for decisions and
|
|
144
|
+
course-corrections only — keep the two separate.
|
|
145
|
+
|
|
146
|
+
Rules for the spec body:
|
|
147
|
+
|
|
148
|
+
- **Every phase is independently shippable and ends with tests.** A phase is
|
|
149
|
+
not "done" until its tests are written and the suite is green. Bake a test
|
|
150
|
+
task into each phase — never a separate "testing phase" at the end only.
|
|
151
|
+
- **Tasks are checkboxes** (`- [ ]`), clear, verb-first, and granular enough to
|
|
152
|
+
finish in one session. They live in the **phase files**, not the overview. Use
|
|
153
|
+
`⬜`/`🔄`/`✅` on each phase-file heading and mirror it in the `00-overview.md`
|
|
154
|
+
phase index.
|
|
155
|
+
- **Honour project conventions** when writing tasks — reference the relevant
|
|
156
|
+
`.claude/rules/*.md` rather than re-explaining them.
|
|
157
|
+
- **Changelog** is mandatory and lives in the spec. Every later decision or
|
|
158
|
+
course-correction gets a dated one-line entry. Convert relative dates to
|
|
159
|
+
absolute.
|
|
160
|
+
- Keep it tight. If a section adds no information, delete it.
|
|
161
|
+
|
|
162
|
+
## Phase C — index the spec
|
|
163
|
+
|
|
164
|
+
Prepend a row to `specs/backlog/00-index.md` (newest first — directly under the table
|
|
165
|
+
header row, above existing rows):
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
| <YYYY-MM-DD> | <feat-name> | Feature | Draft |
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This is the live view of the backlog; `/spec-go` / `/spec-cancel` remove the row
|
|
172
|
+
when the spec leaves. Create `00-index.md` from a header if it's somehow missing
|
|
173
|
+
(`/spec-init` normally ensures it).
|
|
174
|
+
|
|
175
|
+
After writing, tell the user the path and that it's a `Draft` in `backlog`. Next
|
|
176
|
+
step is `/spec-ready` once it's groomed, then `/spec-go` to start building.
|