@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
package/src/sync/base.js DELETED
@@ -1,83 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * The committed base sidecar + the backup-before-force reflog.
5
- *
6
- * The base is the last-synced snapshot per spec, stored at
7
- * `{sync.baseDir}/{identifier}.base.json` and committed so each worktree carries
8
- * its own base and the three-way divergence check stays accurate. After any
9
- * successful pull/push/force the engine rewrites it (`writeBase`).
10
- *
11
- * `backup(side, …)` lands the about-to-be-clobbered side under `{sync.backupDir}`
12
- * BEFORE a `--force` overwrites it — force never destroys without first writing a
13
- * copy. The filename carries a caller-supplied timestamp (the engine takes no
14
- * Date.now(), for reproducible tests) and is made collision-safe with a counter.
15
- */
16
-
17
- const fs = require('node:fs')
18
- const path = require('node:path')
19
-
20
- function baseFile(dir, identifier, config) {
21
- return path.join(dir, config.sync.baseDir, `${identifier}.base.json`)
22
- }
23
-
24
- /**
25
- * Read a spec's committed base. Returns the parsed object, or `null` when no base
26
- * exists yet (never synced) — the compare treats null as "no prior state".
27
- */
28
- function readBase(dir, identifier, config) {
29
- const file = baseFile(dir, identifier, config)
30
- let raw
31
- try {
32
- raw = fs.readFileSync(file, 'utf-8')
33
- } catch (error) {
34
- if (error.code === 'ENOENT') return null
35
- throw error
36
- }
37
- try {
38
- return JSON.parse(raw)
39
- } catch (error) {
40
- throw new Error(`Invalid base ${path.relative(dir, file)}: ${error.message}`)
41
- }
42
- }
43
-
44
- /**
45
- * Rewrite a spec's committed base with the freshly-synced field set. Creates
46
- * `{sync.baseDir}` if needed. Returns the absolute path written.
47
- */
48
- function writeBase(dir, identifier, config, data) {
49
- const file = baseFile(dir, identifier, config)
50
- fs.mkdirSync(path.dirname(file), { recursive: true })
51
- fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
52
- return file
53
- }
54
-
55
- /**
56
- * Back up the about-to-be-clobbered `side` ('local' | 'remote') into
57
- * `{sync.backupDir}` before a --force. `timestamp` is caller-supplied (the engine
58
- * never reads the clock); the name is made collision-safe with a `-N` counter.
59
- * Returns the absolute path written, or null when `data` is nullish (nothing to
60
- * back up — e.g. forcing a pull with no prior remote).
61
- */
62
- function backup(side, dir, identifier, config, { timestamp, data }) {
63
- if (data == null) return null
64
- const backupRoot = path.join(dir, config.sync.backupDir)
65
- fs.mkdirSync(backupRoot, { recursive: true })
66
-
67
- const stem = `${identifier}.${side}.${timestamp}`
68
- let file = path.join(backupRoot, `${stem}.json`)
69
- let n = 1
70
- while (fs.existsSync(file)) {
71
- file = path.join(backupRoot, `${stem}-${n}.json`)
72
- n += 1
73
- }
74
- fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
75
- return file
76
- }
77
-
78
- module.exports = {
79
- readBase,
80
- writeBase,
81
- backup,
82
- baseFile,
83
- }
@@ -1,99 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * The three-way compare at the heart of the hybrid sync.
5
- *
6
- * `classify(local, remote, base, config)` compares each configured field across
7
- * the local snapshot, the remote (Linear) projection, and the committed base
8
- * (the last-synced state). Per field it returns a raw three-way `status`
9
- * (unchanged / local-only / remote-only / conflict), then collapses it through
10
- * the field's ownership (`both|pull|push`) into effective `pushable` / `pullable`
11
- * flags. Ownership is what makes most "both sides differ" cases *not* a real
12
- * conflict:
13
- * - a `pull` field never pushes (Linear wins) → conflict collapses to remote-only
14
- * - a `push` field never pulls (repo wins) → conflict collapses to local-only
15
- * - only a `both` field where both sides moved off base is a true `conflict`.
16
- *
17
- * Pure and deterministic: field identity is a stable content hash (sorted-key
18
- * JSON → SHA-1), so `null`, `undefined`, and a missing base all compare equal,
19
- * and object key order never causes a false diff. No Date.now()/Math.random().
20
- */
21
-
22
- const { createHash } = require('node:crypto')
23
-
24
- // Deterministic JSON: object keys sorted recursively; array order preserved
25
- // (order is meaningful for milestones/tasks). undefined normalises to null.
26
- function stableStringify(value) {
27
- if (value === undefined || value === null) return 'null'
28
- if (Array.isArray(value)) {
29
- return '[' + value.map(stableStringify).join(',') + ']'
30
- }
31
- if (typeof value === 'object') {
32
- const keys = Object.keys(value).sort()
33
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
34
- }
35
- return JSON.stringify(value)
36
- }
37
-
38
- // Stable content hash of a single field value.
39
- function hashField(value) {
40
- return createHash('sha1').update(stableStringify(value)).digest('hex')
41
- }
42
-
43
- // Raw three-way status from the three hashes.
44
- function rawStatus(localH, remoteH, baseH) {
45
- const localChanged = localH !== baseH
46
- const remoteChanged = remoteH !== baseH
47
- if (!localChanged && !remoteChanged) return 'unchanged'
48
- if (localChanged && !remoteChanged) return 'local-only'
49
- if (!localChanged && remoteChanged) return 'remote-only'
50
- // both moved off base — but they may have converged on the same value.
51
- if (localH === remoteH) return 'unchanged'
52
- return 'conflict'
53
- }
54
-
55
- // Collapse the raw status through ownership into an effective status + flags.
56
- function collapse(raw, ownership) {
57
- const canPush = ownership === 'both' || ownership === 'push'
58
- const canPull = ownership === 'both' || ownership === 'pull'
59
-
60
- if (raw === 'unchanged') return { status: 'unchanged', pushable: false, pullable: false }
61
- if (raw === 'local-only') return { status: 'local-only', pushable: canPush, pullable: false }
62
- if (raw === 'remote-only') return { status: 'remote-only', pushable: false, pullable: canPull }
63
-
64
- // conflict: both sides diverged off base.
65
- if (ownership === 'push') return { status: 'local-only', pushable: true, pullable: false }
66
- if (ownership === 'pull') return { status: 'remote-only', pushable: false, pullable: true }
67
- return { status: 'conflict', pushable: true, pullable: true }
68
- }
69
-
70
- /**
71
- * Classify every field in `config.sync.fieldOwnership`.
72
- *
73
- * @param {object} local normalized local snapshot (normalizeLocal output)
74
- * @param {object} remote normalized remote projection (normalizeRemote output)
75
- * @param {object|null} base the committed base (same shape) or null (never synced)
76
- * @returns {Array<{field, ownership, raw, status, pushable, pullable}>}
77
- * one entry per configured field, in config order.
78
- */
79
- function classify(local, remote, base, config) {
80
- const ownership = config.sync.fieldOwnership
81
- const baseObj = base || {}
82
- return Object.keys(ownership).map((field) => {
83
- const own = ownership[field]
84
- const localH = hashField(local ? local[field] : null)
85
- const remoteH = hashField(remote ? remote[field] : null)
86
- const baseH = hashField(field in baseObj ? baseObj[field] : null)
87
- const raw = rawStatus(localH, remoteH, baseH)
88
- const { status, pushable, pullable } = collapse(raw, own)
89
- return { field, ownership: own, raw, status, pushable, pullable }
90
- })
91
- }
92
-
93
- module.exports = {
94
- classify,
95
- hashField,
96
- stableStringify,
97
- rawStatus,
98
- collapse,
99
- }
@@ -1,198 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * Config loader for the Linear hybrid-sync feature (`/spec-status`, `/spec-pull`,
5
- * `/spec-push` and the Linear-aware paths of `/spec` and `/spec-go`).
6
- *
7
- * Reads `specs/.core/linear.config.json` from the project root and normalises it
8
- * over frozen defaults. The feature is strictly opt-in: when the file is absent
9
- * the loader never throws — it returns the defaults with `present:false`, which
10
- * every caller treats as "Linear sync unused".
11
- *
12
- * Mirrors the shape/idiom of `src/env/config.js` (frozen defaults, merge known
13
- * keys only, forward-compatible on unknown keys). Zero-dependency. The one place
14
- * it is stricter: a `sync.fieldOwnership` value outside `both|pull|push` is a
15
- * hard error — the engine's whole safety model rests on those enums.
16
- *
17
- * Shape (see assets/core/linear.config.md for field docs):
18
- * {
19
- * linear: { teamKey, teamId, initiativeId },
20
- * mapping: { specFolder, phases, tasks },
21
- * states: { backlog, "in-progress", complete, cancelled },
22
- * snapshot: { overviewFile },
23
- * branch: { pattern },
24
- * sync: {
25
- * baseDir, backupDir,
26
- * fieldOwnership: { <field>: "both" | "pull" | "push" },
27
- * localOnlySections: string[]
28
- * }
29
- * }
30
- */
31
-
32
- const { readFileSync } = require('node:fs')
33
- const { join } = require('node:path')
34
-
35
- const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
36
-
37
- const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
38
-
39
- const DEFAULT_CONFIG = Object.freeze({
40
- linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
41
- mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
42
- states: Object.freeze({
43
- backlog: 'Backlog',
44
- 'in-progress': 'In Progress',
45
- complete: 'Done',
46
- cancelled: 'Cancelled',
47
- }),
48
- snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
49
- branch: Object.freeze({ pattern: '{type}/{slug}' }),
50
- sync: Object.freeze({
51
- baseDir: 'specs/.core/linear-base',
52
- backupDir: 'specs/.core/linear-backups',
53
- fieldOwnership: Object.freeze({
54
- description: 'both',
55
- milestones: 'both',
56
- phaseBodies: 'both',
57
- acceptanceCriteria: 'both',
58
- taskBreakdown: 'both',
59
- workflowState: 'pull',
60
- priority: 'pull',
61
- labels: 'pull',
62
- }),
63
- localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
64
- }),
65
- })
66
-
67
- function isObject(value) {
68
- return value !== null && typeof value === 'object' && !Array.isArray(value)
69
- }
70
-
71
- // A fresh, deeply-mutable copy of the defaults to merge onto.
72
- function defaults() {
73
- return {
74
- linear: { ...DEFAULT_CONFIG.linear },
75
- mapping: { ...DEFAULT_CONFIG.mapping },
76
- states: { ...DEFAULT_CONFIG.states },
77
- snapshot: { ...DEFAULT_CONFIG.snapshot },
78
- branch: { ...DEFAULT_CONFIG.branch },
79
- sync: {
80
- baseDir: DEFAULT_CONFIG.sync.baseDir,
81
- backupDir: DEFAULT_CONFIG.sync.backupDir,
82
- fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
83
- localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
84
- },
85
- }
86
- }
87
-
88
- // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
89
- // Strings are trimmed and must be non-empty to override; `string?` may be empty.
90
- function assign(base, parsed, key, type) {
91
- const v = parsed[key]
92
- if (type === 'string') {
93
- if (typeof v === 'string' && v.trim()) base[key] = v.trim()
94
- } else if (type === 'string?') {
95
- if (typeof v === 'string') base[key] = v
96
- } else if (type === 'boolean') {
97
- if (typeof v === 'boolean') base[key] = v
98
- }
99
- }
100
-
101
- // Merge (and validate) sync.fieldOwnership. Any key the caller lists joins the
102
- // compared field set; the value MUST be one of both|pull|push.
103
- function mergeFieldOwnership(base, parsed) {
104
- if (!isObject(parsed)) return
105
- for (const [field, dir] of Object.entries(parsed)) {
106
- if (!OWNERSHIP.includes(dir)) {
107
- throw new Error(
108
- `Invalid ${CONFIG_FILE}: sync.fieldOwnership.${field} = ${JSON.stringify(dir)} ` +
109
- `(expected one of ${OWNERSHIP.join('|')})`,
110
- )
111
- }
112
- base[field] = dir
113
- }
114
- }
115
-
116
- /**
117
- * Merge a parsed config over the defaults. Only known keys are copied (unknown
118
- * keys ignored for forward-compat). Nested objects are merged field-by-field.
119
- */
120
- function mergeConfig(base, parsed) {
121
- if (!isObject(parsed)) return base
122
-
123
- if (isObject(parsed.linear)) {
124
- assign(base.linear, parsed.linear, 'teamKey', 'string?')
125
- assign(base.linear, parsed.linear, 'teamId', 'string?')
126
- assign(base.linear, parsed.linear, 'initiativeId', 'string?')
127
- }
128
-
129
- if (isObject(parsed.mapping)) {
130
- assign(base.mapping, parsed.mapping, 'specFolder', 'string')
131
- assign(base.mapping, parsed.mapping, 'phases', 'string')
132
- assign(base.mapping, parsed.mapping, 'tasks', 'string')
133
- }
134
-
135
- if (isObject(parsed.states)) {
136
- for (const key of Object.keys(base.states)) {
137
- assign(base.states, parsed.states, key, 'string')
138
- }
139
- }
140
-
141
- if (isObject(parsed.snapshot)) {
142
- assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
143
- }
144
-
145
- if (isObject(parsed.branch)) {
146
- assign(base.branch, parsed.branch, 'pattern', 'string')
147
- }
148
-
149
- if (isObject(parsed.sync)) {
150
- assign(base.sync, parsed.sync, 'baseDir', 'string')
151
- assign(base.sync, parsed.sync, 'backupDir', 'string')
152
- mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
153
- if (Array.isArray(parsed.sync.localOnlySections)) {
154
- base.sync.localOnlySections = parsed.sync.localOnlySections
155
- .filter((s) => typeof s === 'string' && s.trim())
156
- .map((s) => s.trim())
157
- }
158
- }
159
-
160
- return base
161
- }
162
-
163
- /**
164
- * Load and normalise `specs/.core/linear.config.json` from `dir` (default cwd).
165
- * Returns `{ config, present }`:
166
- * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
167
- * - present → `{ config: merged, present: true }`
168
- * Malformed JSON or a bad `fieldOwnership` enum → throws a clear Error.
169
- */
170
- function loadLinearConfig(dir = process.cwd()) {
171
- const base = defaults()
172
- const file = join(dir, CONFIG_FILE)
173
-
174
- let raw
175
- try {
176
- raw = readFileSync(file, 'utf-8')
177
- } catch (error) {
178
- if (error.code === 'ENOENT') return { config: base, present: false }
179
- throw error
180
- }
181
-
182
- let parsed
183
- try {
184
- parsed = JSON.parse(raw)
185
- } catch (error) {
186
- throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
187
- }
188
-
189
- return { config: mergeConfig(base, parsed), present: true }
190
- }
191
-
192
- module.exports = {
193
- loadLinearConfig,
194
- mergeConfig,
195
- DEFAULT_CONFIG,
196
- CONFIG_FILE,
197
- OWNERSHIP,
198
- }
package/src/sync/mcp.js DELETED
@@ -1,112 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * The Linear MCP boundary — the one place that knows concrete Linear tool names.
5
- *
6
- * `discoverLinear(tools)` resolves the operations the sync needs (read/update a
7
- * Project, list/create/update Milestones + Issues) against the *connected*
8
- * server's advertised tool list at runtime, rather than hardcoding names that
9
- * drift. If Linear isn't connected (empty / zero-match tool list) it returns a
10
- * clean `{ ok:false, error }` so the caller can stop and do nothing destructive.
11
- *
12
- * `makeAdapter(callTool, resolved)` wraps a generic `callTool(name, args)` (the
13
- * skill's MCP invoker) into the typed async operations push/pull consume. Tests
14
- * inject a fake adapter directly (an in-memory Project), so the engine stays
15
- * offline and deterministic; production wires `callTool` to the real MCP server.
16
- */
17
-
18
- // Canonical operations, and the regexes that match a Linear MCP tool name to
19
- // each. Ordered patterns: first match wins. Verified against the connected
20
- // Linear MCP server during build (resolves the overview's Open questions).
21
- const MATCHERS = {
22
- projectRead: [/get_?project\b/i, /read_?project/i, /project_?get/i],
23
- projectUpdate: [/update_?project/i, /project_?update/i],
24
- projectCreate: [/create_?project/i, /project_?create/i],
25
- milestoneList: [/list_?.*milestone/i, /milestones?_?list/i, /get_?.*milestones?/i],
26
- milestoneCreate: [/create_?.*milestone/i, /milestone_?create/i],
27
- milestoneUpdate: [/update_?.*milestone/i, /milestone_?update/i],
28
- issueList: [/list_?issues?/i, /issues?_?list/i, /get_?issues?/i],
29
- issueCreate: [/create_?issue/i, /issue_?create/i],
30
- issueUpdate: [/update_?issue/i, /issue_?update/i],
31
- }
32
-
33
- // The minimum the push/pull engine can't run without. Milestone/issue ops are
34
- // optional in Phase 2 (project description round-trips first).
35
- const REQUIRED = ['projectRead', 'projectUpdate']
36
-
37
- // Normalise a tools argument (array of strings or {name} objects) to names.
38
- function toolNames(tools) {
39
- if (!Array.isArray(tools)) return []
40
- return tools
41
- .map((t) => (typeof t === 'string' ? t : t && typeof t === 'object' ? t.name : null))
42
- .filter((n) => typeof n === 'string' && n)
43
- }
44
-
45
- /**
46
- * Resolve Linear operations against the connected server's tool list.
47
- * @returns {{ok:true, tools:Record<string,string>}} on success, or
48
- * {{ok:false, error:string, resolved?:object, missing?:string[]}}.
49
- */
50
- function discoverLinear(tools) {
51
- const names = toolNames(tools)
52
- if (!names.length) {
53
- return {
54
- ok: false,
55
- error: 'Linear not connected — connect the `linear` MCP server, then retry.',
56
- }
57
- }
58
-
59
- const resolved = {}
60
- for (const [op, patterns] of Object.entries(MATCHERS)) {
61
- const hit = names.find((n) => patterns.some((p) => p.test(n)))
62
- if (hit) resolved[op] = hit
63
- }
64
-
65
- const missing = REQUIRED.filter((op) => !resolved[op])
66
- if (missing.length) {
67
- return {
68
- ok: false,
69
- error:
70
- `Linear MCP is connected but missing required tools: ${missing.join(', ')}. ` +
71
- 'Check the linear server exposes project read + update.',
72
- resolved,
73
- missing,
74
- }
75
- }
76
-
77
- return { ok: true, tools: resolved }
78
- }
79
-
80
- /**
81
- * Wrap a generic `callTool(name, args) → Promise<result>` into the typed ops the
82
- * engine uses. `resolved` is `discoverLinear(...).tools`.
83
- */
84
- function makeAdapter(callTool, resolved) {
85
- const need = (op) => {
86
- const name = resolved[op]
87
- if (!name) throw new Error(`Linear MCP op not available: ${op}`)
88
- return name
89
- }
90
- return {
91
- async readProject(id) {
92
- return callTool(need('projectRead'), { id })
93
- },
94
- async updateProject(id, updates) {
95
- return callTool(need('projectUpdate'), { id, ...updates })
96
- },
97
- async createMilestone(projectId, milestone) {
98
- return callTool(need('milestoneCreate'), { projectId, ...milestone })
99
- },
100
- async updateMilestone(id, updates) {
101
- return callTool(need('milestoneUpdate'), { id, ...updates })
102
- },
103
- }
104
- }
105
-
106
- module.exports = {
107
- discoverLinear,
108
- makeAdapter,
109
- toolNames,
110
- MATCHERS,
111
- REQUIRED,
112
- }