@skitterbyte/skitterspec 1.0.0 → 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.
Files changed (42) hide show
  1. package/README.md +27 -244
  2. package/assets/claude-md-section.md +0 -6
  3. package/assets/core/env.config.json.example +5 -1
  4. package/assets/core/env.config.md +21 -5
  5. package/assets/rules/spec-planning.md +14 -10
  6. package/assets/skills/spec/SKILL.md +11 -38
  7. package/assets/skills/spec-complete/SKILL.md +31 -4
  8. package/assets/skills/spec-env/SKILL.md +6 -0
  9. package/assets/skills/spec-env-down/SKILL.md +16 -8
  10. package/assets/skills/spec-go/SKILL.md +15 -17
  11. package/package.json +6 -11
  12. package/src/cli.js +174 -318
  13. package/src/deprecate.js +138 -0
  14. package/src/env/config.js +17 -4
  15. package/src/env/integrate.js +46 -0
  16. package/src/env/resolve.js +54 -45
  17. package/src/env/teardown.js +19 -4
  18. package/src/env/trust.js +87 -0
  19. package/src/init.js +78 -170
  20. package/src/prompts.js +26 -63
  21. package/LICENSE +0 -21
  22. package/assets/core/linear.config.json.example +0 -39
  23. package/assets/core/linear.config.md +0 -121
  24. package/assets/rules/commit-messages.md +0 -85
  25. package/assets/scripts/generate-changelog.js +0 -274
  26. package/assets/scripts/generate-releases.js +0 -360
  27. package/assets/scripts/lib/config.js +0 -127
  28. package/assets/scripts/lib/git-commits.js +0 -265
  29. package/assets/skills/commit/SKILL.md +0 -28
  30. package/assets/skills/spec-pull/SKILL.md +0 -46
  31. package/assets/skills/spec-push/SKILL.md +0 -53
  32. package/assets/skills/spec-status/SKILL.md +0 -46
  33. package/src/config.js +0 -13
  34. package/src/sync/apply.js +0 -66
  35. package/src/sync/base.js +0 -83
  36. package/src/sync/compare.js +0 -99
  37. package/src/sync/config.js +0 -198
  38. package/src/sync/mcp.js +0 -112
  39. package/src/sync/normalize.js +0 -249
  40. package/src/sync/pull.js +0 -84
  41. package/src/sync/push.js +0 -106
  42. 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
- }