@skitterbyte/skitterspec-jira 0.0.0-stage → 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/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
package/src/init.js
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const crypto = require('crypto')
|
|
6
|
+
|
|
7
|
+
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
8
|
+
const { ensureHooks } = require('./env/hooks.js')
|
|
9
|
+
const { repoInfo, expandTokens } = require('./env/resolve.js')
|
|
10
|
+
|
|
11
|
+
const ASSETS = path.join(__dirname, '..', 'assets')
|
|
12
|
+
|
|
13
|
+
// Skills, rules, and specs/.core templates are discovered from the bundled assets
|
|
14
|
+
// tree rather than hardcoded, so each distribution installs exactly what it ships:
|
|
15
|
+
// the tracker-free base carries the neutral skill set + env.config templates; a
|
|
16
|
+
// provider superset (built by composing its fragments in) additionally carries its
|
|
17
|
+
// sync skills and provider config templates, and they install with no code change.
|
|
18
|
+
function listSkills() {
|
|
19
|
+
const dir = path.join(ASSETS, 'skills')
|
|
20
|
+
return fs
|
|
21
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
22
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
23
|
+
.map((e) => e.name)
|
|
24
|
+
.sort()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Slash commands shipped as `assets/commands/*.md`, installed to
|
|
28
|
+
// `.claude/commands/`. Discovered from the bundled tree exactly like skills, so
|
|
29
|
+
// each distribution installs precisely what it ships.
|
|
30
|
+
function listCommands() {
|
|
31
|
+
const dir = path.join(ASSETS, 'commands')
|
|
32
|
+
try {
|
|
33
|
+
return fs
|
|
34
|
+
.readdirSync(dir)
|
|
35
|
+
.filter((f) => f.endsWith('.md'))
|
|
36
|
+
.sort()
|
|
37
|
+
} catch {
|
|
38
|
+
return [] // a distribution may ship no commands
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Hook scripts shipped as `assets/hooks/*.cjs` (or `*.mjs`), installed to
|
|
43
|
+
// `.claude/hooks/`. Discovered from the bundled tree like everything else, so a
|
|
44
|
+
// distribution installs precisely what it ships and a hook can be retired by
|
|
45
|
+
// deleting it.
|
|
46
|
+
//
|
|
47
|
+
// A BARE `.js` IS DELIBERATELY NOT DISCOVERED. These files are copied into the
|
|
48
|
+
// TARGET project, where that project's `package.json` — the one file skitterspec
|
|
49
|
+
// does not control — decides how node parses a `.js`. Shipping CommonJS as `.js`
|
|
50
|
+
// crashed the review-gate hook in every `"type": "module"` project, on every
|
|
51
|
+
// Bash tool call. The extension is the only thing that settles it at the file,
|
|
52
|
+
// so both accepted forms pin it; `env-review-hook.test.js` asserts the rule over
|
|
53
|
+
// the whole directory rather than over this filter.
|
|
54
|
+
function listHooks() {
|
|
55
|
+
try {
|
|
56
|
+
return fs
|
|
57
|
+
.readdirSync(path.join(ASSETS, 'hooks'))
|
|
58
|
+
.filter((f) => f.endsWith('.cjs') || f.endsWith('.mjs'))
|
|
59
|
+
.sort()
|
|
60
|
+
} catch {
|
|
61
|
+
return [] // a distribution may ship no hooks
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listRules() {
|
|
66
|
+
return fs
|
|
67
|
+
.readdirSync(path.join(ASSETS, 'rules'))
|
|
68
|
+
.filter((f) => f.endsWith('.md'))
|
|
69
|
+
.sort()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Templates scaffolded into specs/.core/ (the *.example configs + their *.md docs).
|
|
73
|
+
// A consumer copies an example → live config to adopt the matching feature.
|
|
74
|
+
function listCoreTemplates() {
|
|
75
|
+
return fs
|
|
76
|
+
.readdirSync(path.join(ASSETS, 'core'))
|
|
77
|
+
.filter((f) => f.endsWith('.example') || f.endsWith('.md'))
|
|
78
|
+
.sort()
|
|
79
|
+
.map((f) => path.join('core', f))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SKILLS = listSkills()
|
|
83
|
+
|
|
84
|
+
const COMMANDS = listCommands()
|
|
85
|
+
|
|
86
|
+
const RULES = listRules()
|
|
87
|
+
|
|
88
|
+
const HOOKS = listHooks()
|
|
89
|
+
|
|
90
|
+
const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
|
|
91
|
+
|
|
92
|
+
// Opt-in config templates, scaffolded into specs/.core/ (the base ships the
|
|
93
|
+
// env.config isolation templates; a provider superset also ships its own).
|
|
94
|
+
const CORE_FILES = listCoreTemplates()
|
|
95
|
+
|
|
96
|
+
// A command file pre-executes the CLI, so it must carry a literal invocation
|
|
97
|
+
// that actually resolves in THIS project. Three installs are possible — local
|
|
98
|
+
// dependency, global, or none — and the runner differs for each.
|
|
99
|
+
//
|
|
100
|
+
// A LOCKFILE DOES NOT ANSWER THIS QUESTION. It says which runner would be used
|
|
101
|
+
// *if* the CLI were installed here, which is a different thing, and reading it
|
|
102
|
+
// as evidence of an install is inferring from an absence
|
|
103
|
+
// (`.claude/rules/negative-checks.md` rule 1). It cost the non-Node case
|
|
104
|
+
// outright: a project with no lockfile got `npx skitterspec` baked into every
|
|
105
|
+
// command file, and the unscoped name `skitterspec` does not exist on npm, so
|
|
106
|
+
// all four 404'd.
|
|
107
|
+
//
|
|
108
|
+
// So the lockfile keeps the job it is good at — picking the runner once a local
|
|
109
|
+
// install is established — and a ladder decides whether there is one.
|
|
110
|
+
const PACKAGE_MANAGERS = [
|
|
111
|
+
['pnpm-lock.yaml', 'pnpm exec'],
|
|
112
|
+
['yarn.lock', 'yarn'],
|
|
113
|
+
['package-lock.json', 'npx'],
|
|
114
|
+
['bun.lockb', 'bunx'],
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
// Which runner would reach a LOCAL install here? Rung 1's answer only — it says
|
|
118
|
+
// nothing about whether the CLI is present, which is `detectRunner`'s job.
|
|
119
|
+
function detectPackageManager(dir) {
|
|
120
|
+
for (const [lockfile, exec] of PACKAGE_MANAGERS) {
|
|
121
|
+
if (fs.existsSync(path.join(dir, lockfile))) return exec
|
|
122
|
+
}
|
|
123
|
+
return 'npx'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The bin names a distribution may expose. A provider superset ships its own
|
|
127
|
+
// name alongside `skitterspec`, and either proves the engine is reachable.
|
|
128
|
+
const ENGINE_BINS = ['skitterspec', 'skitterspec-linear', 'skitterspec-jira', 'skitterspec-github', 'skitterspec-azure-boards']
|
|
129
|
+
|
|
130
|
+
function binNames() {
|
|
131
|
+
return process.platform === 'win32'
|
|
132
|
+
? ENGINE_BINS.flatMap((n) => [n, `${n}.cmd`, `${n}.exe`])
|
|
133
|
+
: ENGINE_BINS
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Rung 1 — a local install, found by walking up. Bounded at 6 levels, matching
|
|
137
|
+
// the hooks' own `findEngine`: a git worktree's checkout has its own
|
|
138
|
+
// `node_modules`, and a monorepo package hoists to the root.
|
|
139
|
+
function hasLocalInstall(dir) {
|
|
140
|
+
let at = path.resolve(dir)
|
|
141
|
+
for (let i = 0; i < 6; i++) {
|
|
142
|
+
for (const name of binNames()) {
|
|
143
|
+
if (fs.existsSync(path.join(at, 'node_modules', '.bin', name))) return true
|
|
144
|
+
}
|
|
145
|
+
const up = path.dirname(at)
|
|
146
|
+
if (up === at) break
|
|
147
|
+
at = up
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// An npx cache bin directory. Matched on the path SEGMENT, so a project that
|
|
153
|
+
// happens to live under a directory called `_npxtools` is not mistaken for one.
|
|
154
|
+
const NPX_CACHE_RE = /[\\/]_npx[\\/]/
|
|
155
|
+
|
|
156
|
+
// Rung 2 — a global install, found on PATH.
|
|
157
|
+
//
|
|
158
|
+
// WHAT WOULD FOOL THIS, and why the filter is not optional: `npx` PREPENDS its
|
|
159
|
+
// own cache bin directory to PATH for the processes it spawns. Verified, not
|
|
160
|
+
// assumed. So running `npx @skitterbyte/skitterspec init` puts a `skitterspec`
|
|
161
|
+
// on PATH that exists only for the duration of that one command — and probing
|
|
162
|
+
// for it here would bake a bare `skitterspec …` into four COMMITTED files that
|
|
163
|
+
// stop working the moment init exits. Excluding `_npx` entries is what makes
|
|
164
|
+
// this rung evidence of an install rather than evidence of the install running.
|
|
165
|
+
function hasPathInstall(env = process.env) {
|
|
166
|
+
const raw = env.PATH || env.Path || ''
|
|
167
|
+
if (!raw) return false
|
|
168
|
+
for (const entry of raw.split(path.delimiter)) {
|
|
169
|
+
if (!entry || NPX_CACHE_RE.test(entry)) continue
|
|
170
|
+
for (const name of binNames()) {
|
|
171
|
+
if (fs.existsSync(path.join(entry, name))) return true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How should a command file invoke the CLI here?
|
|
179
|
+
*
|
|
180
|
+
* A positive-signal ladder: each rung asserts something that must be PRESENT,
|
|
181
|
+
* and only the last is a fallback.
|
|
182
|
+
*
|
|
183
|
+
* local — a local install; the lockfile picks the runner
|
|
184
|
+
* path — a global install; no runner at all, the bin is invoked bare
|
|
185
|
+
* none — neither could be found. `npx` is written as before, and the caller
|
|
186
|
+
* warns, because this is the cannot-tell case rather than a third
|
|
187
|
+
* kind of install (rule 4: the unknown routes to the harmless branch,
|
|
188
|
+
* which here is "write what we always wrote and say so").
|
|
189
|
+
*
|
|
190
|
+
* PROJECT-VISIBLE SIGNALS OUTRANK MACHINE-VISIBLE ONES, which is why `local` is
|
|
191
|
+
* checked first. `.claude/commands/*.md` is committed and `renderCommand` feeds
|
|
192
|
+
* `managedTargets`, so a render that varies by machine makes the file flip
|
|
193
|
+
* between pristine and updatable as different people run `update`. A lockfile
|
|
194
|
+
* is committed and answers the same everywhere; PATH is one machine's business.
|
|
195
|
+
*/
|
|
196
|
+
function detectRunner(dir, env = process.env) {
|
|
197
|
+
if (hasLocalInstall(dir)) return { runner: detectPackageManager(dir), rung: 'local' }
|
|
198
|
+
if (hasPathInstall(env)) return { runner: '', rung: 'path' }
|
|
199
|
+
return { runner: 'npx', rung: 'none' }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Fill a command file's `{{exec}}` placeholders. Kept a pure function of
|
|
203
|
+
// (content, dir) so `managedTargets` can compare against exactly what
|
|
204
|
+
// `installCommands` would write — otherwise every install would hash as
|
|
205
|
+
// customized on the next run.
|
|
206
|
+
//
|
|
207
|
+
// The token is consumed WITH its trailing space, so an empty runner leaves
|
|
208
|
+
// `Bash(skitterspec …)` rather than `Bash( skitterspec …)`. The second pass
|
|
209
|
+
// catches a bare `{{exec}}` in case a template ever lands without one.
|
|
210
|
+
function renderCommand(content, dir) {
|
|
211
|
+
const { runner } = detectRunner(dir)
|
|
212
|
+
return content
|
|
213
|
+
.split('{{exec}} ')
|
|
214
|
+
.join(runner ? `${runner} ` : '')
|
|
215
|
+
.split('{{exec}}')
|
|
216
|
+
.join(runner)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- composed-assets guard -------------------------------------------------
|
|
220
|
+
//
|
|
221
|
+
// A source package's `assets/` is PRE-composition: `<!-- seam:NAME -->` is still
|
|
222
|
+
// literal text that scripts/build-dist.js replaces — with a provider's fragment
|
|
223
|
+
// for the provider distribution, with nothing for the base. Only a built
|
|
224
|
+
// distribution's assets are installable.
|
|
225
|
+
//
|
|
226
|
+
// This is a POSITIVE signal, not an absence: a seam marker must be **present**
|
|
227
|
+
// for the guard to fire, so it cannot misfire on a tree it simply failed to read.
|
|
228
|
+
// And it refuses rather than repairing — installing the wrong thing is the
|
|
229
|
+
// expensive mistake here. In a dev-linked checkout the installed skills are
|
|
230
|
+
// symlinks to the composed distribution, so a `--force` install would write
|
|
231
|
+
// these markers straight through the link and into the built assets.
|
|
232
|
+
const SEAM_RE = /<!--\s*seam:[A-Za-z0-9_-]+\s*-->/
|
|
233
|
+
|
|
234
|
+
function assetFiles(dir) {
|
|
235
|
+
const out = []
|
|
236
|
+
const walk = (d) => {
|
|
237
|
+
let entries
|
|
238
|
+
try {
|
|
239
|
+
entries = fs.readdirSync(d, { withFileTypes: true })
|
|
240
|
+
} catch {
|
|
241
|
+
return // a distribution need not ship every asset kind
|
|
242
|
+
}
|
|
243
|
+
for (const e of entries) {
|
|
244
|
+
const p = path.join(d, e.name)
|
|
245
|
+
if (e.isDirectory()) walk(p)
|
|
246
|
+
else if (e.name.endsWith('.md')) out.push(p)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
walk(dir)
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Throws when this package's assets are uncomposed. Called from the **bins** —
|
|
254
|
+
// the outermost boundary, and the only way a real install happens — never from
|
|
255
|
+
// init()/resync()/run(), which the unit tests legitimately drive against this
|
|
256
|
+
// source tree. Guarding any deeper would make the library untestable while
|
|
257
|
+
// protecting nothing extra.
|
|
258
|
+
function assertComposedAssets() {
|
|
259
|
+
const offenders = assetFiles(ASSETS)
|
|
260
|
+
.filter((f) => SEAM_RE.test(fs.readFileSync(f, 'utf8')))
|
|
261
|
+
.map((f) => path.relative(ASSETS, f))
|
|
262
|
+
if (!offenders.length) return
|
|
263
|
+
throw new Error(
|
|
264
|
+
`refusing to install: this package's assets are uncomposed (${offenders.length} ` +
|
|
265
|
+
`file(s) still carry a <!-- seam:… --> marker, e.g. ${offenders[0]}).\n` +
|
|
266
|
+
' These are a workspace source package\'s assets, not a distribution\'s. ' +
|
|
267
|
+
'Run "npm run build" in the skitterspec repo, then run the command again ' +
|
|
268
|
+
'from the built distribution.',
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
273
|
+
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
274
|
+
|
|
275
|
+
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], adopted: [], healed: [], warnings: [], suggestions: [] }
|
|
276
|
+
|
|
277
|
+
function resetReport() {
|
|
278
|
+
for (const k of Object.keys(report)) report[k].length = 0
|
|
279
|
+
for (const k of Object.keys(writtenHashes)) delete writtenHashes[k]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Folder index files scaffolded by earlier versions, now retired. `init`/`update`
|
|
283
|
+
// deletes any left behind so upgrading projects don't keep stale caches.
|
|
284
|
+
const RETIRED_FILES = [
|
|
285
|
+
path.join('specs', 'backlog', '00-index.md'),
|
|
286
|
+
path.join('specs', 'complete', '00-index.md'),
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
function rel(dir, p) {
|
|
290
|
+
return path.relative(dir, p) || '.'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function ensureDir(p) {
|
|
294
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- install manifest (safe re-run baseline) --------------------------------
|
|
298
|
+
//
|
|
299
|
+
// specs/.core/.skitterspec-manifest.json records, per managed file, the sha1 of
|
|
300
|
+
// the content we last wrote. It's the baseline that lets a later resync tell "an
|
|
301
|
+
// old version we own" (safe to update) from "a file the user edited" (keep). It
|
|
302
|
+
// lists only managed FILES (skills, rules, .core templates) — never user content.
|
|
303
|
+
|
|
304
|
+
const { linesDiff } = require('./lines-diff.js')
|
|
305
|
+
|
|
306
|
+
const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
|
|
307
|
+
const MANIFEST_VERSION = 1
|
|
308
|
+
|
|
309
|
+
// Hashes of files actually written in the current run (populated by writeFile),
|
|
310
|
+
// reset each init() alongside `report`.
|
|
311
|
+
const writtenHashes = {}
|
|
312
|
+
|
|
313
|
+
function sha1(content) {
|
|
314
|
+
return crypto.createHash('sha1').update(content).digest('hex')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The full managed set for a target dir: repo-relative path, absolute path, and
|
|
318
|
+
// the bundled content that ships in this distribution's assets.
|
|
319
|
+
function managedTargets(dir) {
|
|
320
|
+
const out = []
|
|
321
|
+
const add = (assetRel, targetAbs, render = (c) => c) =>
|
|
322
|
+
out.push({
|
|
323
|
+
relPath: rel(dir, targetAbs),
|
|
324
|
+
abs: targetAbs,
|
|
325
|
+
bundled: render(fs.readFileSync(path.join(ASSETS, assetRel), 'utf8'), dir),
|
|
326
|
+
})
|
|
327
|
+
for (const name of SKILLS) add(path.join('skills', name, 'SKILL.md'), path.join(dir, '.claude', 'skills', name, 'SKILL.md'))
|
|
328
|
+
for (const name of COMMANDS)
|
|
329
|
+
add(path.join('commands', name), path.join(dir, '.claude', 'commands', name), renderCommand)
|
|
330
|
+
for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
|
|
331
|
+
for (const name of HOOKS) add(path.join('hooks', name), path.join(dir, '.claude', 'hooks', name))
|
|
332
|
+
for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Read the manifest (tolerant: missing/malformed → an empty baseline).
|
|
337
|
+
// `baselined` answers a question `files` cannot: could this manifest have SEEN
|
|
338
|
+
// a path? It is true only when the file parsed into the expected shape AND
|
|
339
|
+
// records at least one entry — a POSITIVE signal, not an absence, which is what
|
|
340
|
+
// lets `managedState` read a path's absence from it as evidence of anything
|
|
341
|
+
// (`.claude/rules/negative-checks.md` rule 1).
|
|
342
|
+
//
|
|
343
|
+
// WHAT WOULD FOOL IT: nothing silently. A missing manifest and a malformed one
|
|
344
|
+
// both fall into the same empty baseline below — correct for reading hashes,
|
|
345
|
+
// and useless for reasoning about absence, since on a repo with no manifest yet
|
|
346
|
+
// EVERY path is absent. Both answer `baselined: false`, so the one conclusion
|
|
347
|
+
// that depends on it is simply not drawn.
|
|
348
|
+
function readManifest(dir) {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
|
|
351
|
+
if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
|
|
352
|
+
return {
|
|
353
|
+
version: parsed.version || MANIFEST_VERSION,
|
|
354
|
+
files: parsed.files,
|
|
355
|
+
baselined: Object.keys(parsed.files).length > 0,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
/* missing or malformed → empty baseline */
|
|
360
|
+
}
|
|
361
|
+
return { version: MANIFEST_VERSION, files: {}, baselined: false }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function writeManifest(dir, files) {
|
|
365
|
+
const target = path.join(dir, MANIFEST_FILE)
|
|
366
|
+
ensureDir(path.dirname(target))
|
|
367
|
+
const sorted = {}
|
|
368
|
+
for (const k of Object.keys(files).sort()) sorted[k] = files[k]
|
|
369
|
+
fs.writeFileSync(target, JSON.stringify({ version: MANIFEST_VERSION, files: sorted }, null, 2) + '\n')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Classify a managed file against the manifest baseline.
|
|
373
|
+
// missing — not on disk
|
|
374
|
+
// pristine — ours to update: it matches the package asset, or the hash we recorded
|
|
375
|
+
// customized — on disk but differs from both — a user edit; keep it
|
|
376
|
+
// adopted — a path this version has NEWLY CLAIMED, where the project
|
|
377
|
+
// already had a file: it differs from what we ship, and the
|
|
378
|
+
// manifest that could have named it does not. Keep it too — the
|
|
379
|
+
// two states differ only in what the report CLAIMS about it.
|
|
380
|
+
//
|
|
381
|
+
// The split exists because `customized` made a false statement about the second
|
|
382
|
+
// case. "Your edit — kept" describes our file at a path upstream had not
|
|
383
|
+
// claimed, and the reader acts on it: v21 renamed the commit hook to
|
|
384
|
+
// `review-gate.cjs`, which is the same name anyone would have picked to work
|
|
385
|
+
// around v20's ESM crash — so their shim was kept, the real hook was never
|
|
386
|
+
// installed, and `review-gate.js` was pruned out from under it. The shim fails
|
|
387
|
+
// open, so the gate went silently absent under a report that said success.
|
|
388
|
+
//
|
|
389
|
+
// WHAT WOULD FOOL THIS, and why neither does:
|
|
390
|
+
// - A repo with no manifest (or an unreadable one) has EVERY path absent.
|
|
391
|
+
// `baselined` is the positive signal that gates the conclusion, so those
|
|
392
|
+
// fall back to `customized` — which keeps the file either way
|
|
393
|
+
// (`.claude/rules/negative-checks.md` rules 1 and 4).
|
|
394
|
+
// - The signal is ONE-SHOT. `flushManifest`'s migration seed gives any
|
|
395
|
+
// present managed file an entry on the very next run, so an adopted path
|
|
396
|
+
// reads `customized` from then on. Anyone who already upgraded has spent
|
|
397
|
+
// it — which is why the `.cjs` trap is also written into MIGRATION.md,
|
|
398
|
+
// where that reader is actually looking.
|
|
399
|
+
//
|
|
400
|
+
// `bundled` (the current package asset) is optional but decisive: a file whose
|
|
401
|
+
// CONTENT equals what we ship is not customized, whatever the manifest says.
|
|
402
|
+
// Without that check a stale hash pinned the file out of updates permanently —
|
|
403
|
+
// anything that changed it out-of-band (an errant tool, a partial restore, a
|
|
404
|
+
// manifest lost and re-seeded at the wrong version) froze it for good, silently.
|
|
405
|
+
// Comparing content first makes the tool self-healing after any restore.
|
|
406
|
+
// `pruneRetiredManaged` passes no `bundled` on purpose: the package no longer
|
|
407
|
+
// ships that file, so there is nothing to compare it against.
|
|
408
|
+
function managedState(dir, relPath, manifest, bundled) {
|
|
409
|
+
const abs = path.join(dir, relPath)
|
|
410
|
+
if (!fs.existsSync(abs)) return 'missing'
|
|
411
|
+
const onDisk = fs.readFileSync(abs, 'utf8')
|
|
412
|
+
if (bundled !== undefined && onDisk === bundled) return 'pristine'
|
|
413
|
+
const known = manifest.files[relPath]
|
|
414
|
+
if (known) return sha1(onDisk) === known ? 'pristine' : 'customized'
|
|
415
|
+
return manifest.baselined ? 'adopted' : 'customized'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Reconcile and persist the manifest after an install/resync run: keep prior
|
|
419
|
+
// entries, apply files written this run, seed any pre-existing managed file that
|
|
420
|
+
// has no entry yet from its bundled hash (migration for repos predating the
|
|
421
|
+
// manifest), and prune entries whose file is gone.
|
|
422
|
+
function flushManifest(dir) {
|
|
423
|
+
// Only managed files belong in the manifest — never live user config (e.g. an
|
|
424
|
+
// env.config.json that installIsolation happens to write through writeFile).
|
|
425
|
+
const managed = managedTargets(dir)
|
|
426
|
+
const managedRel = new Set(managed.map((t) => t.relPath))
|
|
427
|
+
const merged = { ...readManifest(dir).files, ...writtenHashes }
|
|
428
|
+
const next = {}
|
|
429
|
+
for (const [relPath, hash] of Object.entries(merged)) {
|
|
430
|
+
if (managedRel.has(relPath)) next[relPath] = hash
|
|
431
|
+
}
|
|
432
|
+
for (const { relPath, abs, bundled } of managed) {
|
|
433
|
+
if (!next[relPath] && fs.existsSync(abs)) next[relPath] = sha1(bundled) // migration seed
|
|
434
|
+
}
|
|
435
|
+
for (const relPath of Object.keys(next)) {
|
|
436
|
+
if (!fs.existsSync(path.join(dir, relPath))) delete next[relPath] // prune gone
|
|
437
|
+
}
|
|
438
|
+
writeManifest(dir, next)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function writeFile(dir, target, content, { force }) {
|
|
442
|
+
// A dangling symlink (its target no longer exists) is invisible to existsSync,
|
|
443
|
+
// which follows the link — but the link itself is still on disk, so a plain
|
|
444
|
+
// writeFileSync would follow it into a missing directory and throw ENOENT.
|
|
445
|
+
// Drop the broken link and write a real file in its place.
|
|
446
|
+
let link = null
|
|
447
|
+
try {
|
|
448
|
+
link = fs.lstatSync(target)
|
|
449
|
+
} catch {
|
|
450
|
+
/* no such path — nothing to clean up */
|
|
451
|
+
}
|
|
452
|
+
if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
|
|
453
|
+
fs.unlinkSync(target)
|
|
454
|
+
}
|
|
455
|
+
// A LIVE symlink is the opposite case, and `--force` is what makes it
|
|
456
|
+
// dangerous: `writeFileSync` follows the link, so forcing would write composed
|
|
457
|
+
// content — seam markers resolved, provider text spliced in — straight through
|
|
458
|
+
// it and into whatever it points at. In a checkout that dogfoods its own
|
|
459
|
+
// assets that is `packages/*/assets`, i.e. the SOURCE the link exists to keep
|
|
460
|
+
// live. Refuse: the staleness `--force` was reached for is a smaller problem
|
|
461
|
+
// than corrupting the file it would overwrite.
|
|
462
|
+
//
|
|
463
|
+
// WHAT WOULD MAKE THIS LIE: a HARD link. It has no distinguishing lstat — it
|
|
464
|
+
// simply is the file — so it takes the same corrupting path and nothing here
|
|
465
|
+
// can see it. Out of scope deliberately, and said out loud rather than left to
|
|
466
|
+
// be discovered; nothing in this project's install creates one.
|
|
467
|
+
//
|
|
468
|
+
// It cannot fire in an ordinary consumer install, because nothing there is
|
|
469
|
+
// linked — `skitterspec update` writes copies by design.
|
|
470
|
+
if (force && link && link.isSymbolicLink() && fs.existsSync(target)) {
|
|
471
|
+
report.refused.push(rel(dir, target))
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (fs.existsSync(target)) {
|
|
475
|
+
if (!force) {
|
|
476
|
+
report.skipped.push(rel(dir, target))
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
480
|
+
if (existing === content) {
|
|
481
|
+
// Already the content we'd write — record it as ours (pristine).
|
|
482
|
+
writtenHashes[rel(dir, target)] = sha1(content)
|
|
483
|
+
report.skipped.push(rel(dir, target))
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
fs.writeFileSync(target, content)
|
|
487
|
+
writtenHashes[rel(dir, target)] = sha1(content)
|
|
488
|
+
report.updated.push(rel(dir, target))
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
ensureDir(path.dirname(target))
|
|
492
|
+
fs.writeFileSync(target, content)
|
|
493
|
+
writtenHashes[rel(dir, target)] = sha1(content)
|
|
494
|
+
report.created.push(rel(dir, target))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function copyAsset(dir, assetRelPath, targetAbs, opts) {
|
|
498
|
+
const content = fs.readFileSync(path.join(ASSETS, assetRelPath), 'utf8')
|
|
499
|
+
writeFile(dir, targetAbs, content, opts)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function installSkills(dir, opts) {
|
|
503
|
+
for (const name of SKILLS) {
|
|
504
|
+
copyAsset(
|
|
505
|
+
dir,
|
|
506
|
+
path.join('skills', name, 'SKILL.md'),
|
|
507
|
+
path.join(dir, '.claude', 'skills', name, 'SKILL.md'),
|
|
508
|
+
opts,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function installCommands(dir, opts) {
|
|
514
|
+
for (const name of COMMANDS) {
|
|
515
|
+
const content = renderCommand(
|
|
516
|
+
fs.readFileSync(path.join(ASSETS, 'commands', name), 'utf8'),
|
|
517
|
+
dir,
|
|
518
|
+
)
|
|
519
|
+
writeFile(dir, path.join(dir, '.claude', 'commands', name), content, opts)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ONLY on the cannot-tell rung, and it warns rather than refusing: `init` has
|
|
523
|
+
// real work to do either way — skills, rules, folders — and none of it needs
|
|
524
|
+
// the CLI reachable later. Silence here is what let a project install cleanly
|
|
525
|
+
// and hand back four commands that 404 on first use.
|
|
526
|
+
if (COMMANDS.length && detectRunner(dir).rung === 'none') {
|
|
527
|
+
report.warnings.push(
|
|
528
|
+
`the skitterspec CLI could not be found here, so the ${COMMANDS.length} slash ` +
|
|
529
|
+
'command(s) were written to call `npx skitterspec`, which will fail — ' +
|
|
530
|
+
'install it (npm i -g @skitterbyte/skitterspec, or add it as a dev ' +
|
|
531
|
+
'dependency) and re-run `skitterspec update` to rewrite them',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function installRule(dir, opts) {
|
|
537
|
+
for (const name of RULES) {
|
|
538
|
+
copyAsset(
|
|
539
|
+
dir,
|
|
540
|
+
path.join('rules', name),
|
|
541
|
+
path.join(dir, '.claude', 'rules', name),
|
|
542
|
+
opts,
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Register the review-gate hook in the project's committed settings, so a
|
|
548
|
+
// phase that owes a verdict is enforced one level below the skills. Best-effort
|
|
549
|
+
// in exactly the way `trustWorktreeRoot` is: a settings file we cannot parse is
|
|
550
|
+
// reported and left alone, never rewritten, and never fatal — the hook is an
|
|
551
|
+
// extra layer, and the engine and `/spec-next` hold the gate without it.
|
|
552
|
+
function registerReviewGateHook(dir) {
|
|
553
|
+
// "hooks", plural, because two ship now — the review gate and the main guard.
|
|
554
|
+
// The label names the file and the subject rather than one script, so adding a
|
|
555
|
+
// third does not leave a report that undercounts what it wrote.
|
|
556
|
+
const label = '.claude/settings.json (skitterspec hooks)'
|
|
557
|
+
let res
|
|
558
|
+
try {
|
|
559
|
+
res = ensureHooks(dir)
|
|
560
|
+
} catch {
|
|
561
|
+
report.warnings.push('could not write .claude/settings.json — skitterspec hooks not registered')
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
if (res.reason === 'malformed') {
|
|
565
|
+
report.warnings.push(
|
|
566
|
+
'.claude/settings.json is not valid JSON — did not register the skitterspec hooks',
|
|
567
|
+
)
|
|
568
|
+
} else if (res.reason === 'created') {
|
|
569
|
+
report.created.push(label)
|
|
570
|
+
} else if (res.reason === 'added') {
|
|
571
|
+
report.updated.push(label)
|
|
572
|
+
} else if (res.reason === 'migrated') {
|
|
573
|
+
// A WRITE, so it may not fall through to `skipped`. It was doing exactly
|
|
574
|
+
// that: rewriting the registered path and then reporting "already
|
|
575
|
+
// registered" — a run that acts and says it did not, which is the shape of
|
|
576
|
+
// the bug this whole change exists to fix.
|
|
577
|
+
report.updated.push('.claude/settings.json (skitterspec hooks repointed at the renamed script)')
|
|
578
|
+
} else {
|
|
579
|
+
report.skipped.push('.claude/settings.json (skitterspec hooks already registered)')
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function installHooks(dir, opts) {
|
|
584
|
+
for (const name of HOOKS) {
|
|
585
|
+
copyAsset(dir, path.join('hooks', name), path.join(dir, '.claude', 'hooks', name), opts)
|
|
586
|
+
}
|
|
587
|
+
registerReviewGateHook(dir)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function installFolders(dir) {
|
|
591
|
+
for (const folder of SPEC_FOLDERS) {
|
|
592
|
+
const abs = path.join(dir, 'specs', folder)
|
|
593
|
+
if (!fs.existsSync(abs)) {
|
|
594
|
+
ensureDir(abs)
|
|
595
|
+
report.created.push(rel(dir, abs) + '/')
|
|
596
|
+
// keep otherwise-empty folders in git
|
|
597
|
+
if (!fs.readdirSync(abs).length) {
|
|
598
|
+
fs.writeFileSync(path.join(abs, '.gitkeep'), '')
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
report.skipped.push(rel(dir, abs) + '/')
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Delete retired folder index files left by earlier versions. If removing one
|
|
607
|
+
// empties its bucket, drop a `.gitkeep` so the folder stays tracked in git.
|
|
608
|
+
function removeRetiredFiles(dir) {
|
|
609
|
+
for (const relPath of RETIRED_FILES) {
|
|
610
|
+
const target = path.join(dir, relPath)
|
|
611
|
+
if (!fs.existsSync(target)) continue
|
|
612
|
+
fs.unlinkSync(target)
|
|
613
|
+
report.removed.push(rel(dir, target))
|
|
614
|
+
const folder = path.dirname(target)
|
|
615
|
+
if (fs.existsSync(folder) && !fs.readdirSync(folder).length) {
|
|
616
|
+
fs.writeFileSync(path.join(folder, '.gitkeep'), '')
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Prune a file the manifest records as managed but the current package no longer
|
|
622
|
+
// ships — a retired skill (e.g. `spec-pull` after the one-way switch), rule, or
|
|
623
|
+
// template. Without this, upgrading leaves a live, model-visible skill on disk
|
|
624
|
+
// whose instructions invoke a command that no longer exists. Delete it only when
|
|
625
|
+
// PRISTINE (still matches the hash we last wrote) so a user edit is never lost; a
|
|
626
|
+
// customized retired file is kept with a warning. An emptied skill folder is
|
|
627
|
+
// removed. Takes the pre-flush manifest (which still holds the retired entries).
|
|
628
|
+
function pruneRetiredManaged(dir, manifest) {
|
|
629
|
+
const managedRel = new Set(managedTargets(dir).map((t) => t.relPath))
|
|
630
|
+
for (const relPath of Object.keys(manifest.files || {})) {
|
|
631
|
+
if (managedRel.has(relPath)) continue // still shipped by this version
|
|
632
|
+
const abs = path.join(dir, relPath)
|
|
633
|
+
if (!fs.existsSync(abs)) continue // already gone
|
|
634
|
+
if (managedState(dir, relPath, manifest) === 'customized') {
|
|
635
|
+
report.warnings.push(`retired but kept (you edited it): ${relPath} — delete manually if unused`)
|
|
636
|
+
continue
|
|
637
|
+
}
|
|
638
|
+
fs.unlinkSync(abs)
|
|
639
|
+
report.removed.push(rel(dir, abs))
|
|
640
|
+
const folder = path.dirname(abs)
|
|
641
|
+
if (fs.existsSync(folder) && !fs.readdirSync(folder).length) fs.rmdirSync(folder)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Scaffold the opt-in isolation templates into specs/.core/ (the example config
|
|
646
|
+
// + its field docs). Copied, not activated: the feature stays off until the
|
|
647
|
+
// consumer copies env.config.json.example → env.config.json.
|
|
648
|
+
function installCore(dir, opts) {
|
|
649
|
+
for (const asset of CORE_FILES) {
|
|
650
|
+
copyAsset(
|
|
651
|
+
dir,
|
|
652
|
+
asset,
|
|
653
|
+
path.join(dir, 'specs', '.core', path.basename(asset)),
|
|
654
|
+
opts,
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
|
|
660
|
+
// example asset so /spec-start provisions a worktree for every in-progress spec.
|
|
661
|
+
// Only called when the operator opts in, and never on `update` (adopting isolation
|
|
662
|
+
// is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
|
|
663
|
+
// never clobbers an existing env.config.json without --force.
|
|
664
|
+
function installIsolation(dir, { enabled, workspaceMode }, opts) {
|
|
665
|
+
if (!enabled) return
|
|
666
|
+
const target = path.join(dir, 'specs', '.core', 'env.config.json')
|
|
667
|
+
copyAsset(dir, path.join('core', 'env.config.json.example'), target, opts)
|
|
668
|
+
|
|
669
|
+
// Only 'checkout' is written; 'worktree' is already what the template says and
|
|
670
|
+
// what the loader defaults to, so the common path leaves the file untouched.
|
|
671
|
+
// Guarded by existsSync because copyAsset legitimately declines to overwrite a
|
|
672
|
+
// config the operator already customized — rewriting it here would undo that.
|
|
673
|
+
if (workspaceMode === 'checkout' && fs.existsSync(target)) {
|
|
674
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'))
|
|
675
|
+
parsed.mode = 'checkout'
|
|
676
|
+
fs.writeFileSync(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
677
|
+
}
|
|
678
|
+
trustWorktreeRoot(dir)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Activate opt-in release gating: write specs/.core/gating.config.json from the
|
|
682
|
+
// example asset, so /spec and friends start asking whether a change ships behind
|
|
683
|
+
// a feature flag and recording the answer.
|
|
684
|
+
//
|
|
685
|
+
// Only called when the operator opts in, and NEVER on `update` — for the same
|
|
686
|
+
// reason as isolation: adopting a policy is a deliberate choice, not something a
|
|
687
|
+
// re-sync flips on. Idempotent; copyAsset never clobbers an existing config
|
|
688
|
+
// without --force, so an operator's edited `guidance` survives a re-init.
|
|
689
|
+
function installGating(dir, { enabled }, opts) {
|
|
690
|
+
if (!enabled) return
|
|
691
|
+
copyAsset(
|
|
692
|
+
dir,
|
|
693
|
+
path.join('core', 'gating.config.json.example'),
|
|
694
|
+
path.join(dir, 'specs', '.core', 'gating.config.json'),
|
|
695
|
+
opts,
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Seed the absolute worktree root into .claude/settings.local.json (gitignored)
|
|
700
|
+
// so the operator enabling isolation isn't prompted on every edit into a
|
|
701
|
+
// freshly-provisioned worktree. Best-effort: an unreadable config or malformed
|
|
702
|
+
// settings file is reported, never fatal. `spec-env up` re-ensures this on every
|
|
703
|
+
// provision, so a miss here self-heals.
|
|
704
|
+
function trustWorktreeRoot(dir) {
|
|
705
|
+
let root
|
|
706
|
+
try {
|
|
707
|
+
const cfg = JSON.parse(
|
|
708
|
+
fs.readFileSync(path.join(dir, 'specs', '.core', 'env.config.json'), 'utf8'),
|
|
709
|
+
)
|
|
710
|
+
root = cfg && cfg.worktree && cfg.worktree.root
|
|
711
|
+
} catch {
|
|
712
|
+
/* fall through to the warning below */
|
|
713
|
+
}
|
|
714
|
+
if (!root) {
|
|
715
|
+
report.warnings.push('could not read worktree.root — skipped trusting the worktree dir')
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
719
|
+
const rootAbs = path.resolve(dir, expandTokens(root, { repo, repoSlug }))
|
|
720
|
+
const res = ensureWorktreeDirTrusted(dir, rootAbs)
|
|
721
|
+
const label = '.claude/settings.local.json (trusted worktree root)'
|
|
722
|
+
if (res.reason === 'malformed') {
|
|
723
|
+
report.warnings.push(
|
|
724
|
+
'.claude/settings.local.json is not valid JSON — did not trust the worktree' +
|
|
725
|
+
` dir; add ${rootAbs} to permissions.additionalDirectories yourself`,
|
|
726
|
+
)
|
|
727
|
+
} else if (res.reason === 'created') {
|
|
728
|
+
report.created.push(label)
|
|
729
|
+
} else if (res.reason === 'added') {
|
|
730
|
+
report.updated.push(label)
|
|
731
|
+
} else {
|
|
732
|
+
report.skipped.push('.claude/settings.local.json (worktree root already trusted)')
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Is the CLAUDE.md section this project has installed the one we ship?
|
|
737
|
+
//
|
|
738
|
+
// THREE answers, not two. `differs` deliberately does NOT mean "stale": the
|
|
739
|
+
// block is a COPY, so a difference is either an out-of-date copy or the user's
|
|
740
|
+
// own edit, and from here those read identically. Rule 4 of
|
|
741
|
+
// `.claude/rules/negative-checks.md` — route the unknown case to the harmless
|
|
742
|
+
// branch, which here means reporting a difference and naming the fix rather
|
|
743
|
+
// than accusing them of being behind.
|
|
744
|
+
//
|
|
745
|
+
// WHAT WOULD FOOL THIS: absent markers mean the section was never installed, OR
|
|
746
|
+
// that someone stripped it deliberately (`stripClaudeMdSection` exists and is
|
|
747
|
+
// reachable from `reset`). Neither is a fault, so both answer `not installed`
|
|
748
|
+
// and neither is reported as a problem.
|
|
749
|
+
function claudeMdSectionState(dir) {
|
|
750
|
+
const target = path.join(dir, 'CLAUDE.md')
|
|
751
|
+
if (!fs.existsSync(target)) return 'not installed'
|
|
752
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
753
|
+
if (!existing.includes(SPEC_MARKER_START) || !existing.includes(SPEC_MARKER_END)) {
|
|
754
|
+
return 'not installed'
|
|
755
|
+
}
|
|
756
|
+
const shipped = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
757
|
+
const start = existing.indexOf(SPEC_MARKER_START) + SPEC_MARKER_START.length
|
|
758
|
+
const installed = existing.slice(start, existing.indexOf(SPEC_MARKER_END)).trim()
|
|
759
|
+
return installed === shipped ? 'fresh' : 'differs'
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// `update --check`: say what `update` would change, write nothing, exit 0.
|
|
763
|
+
// It reports; `update` without the flag stays the only thing that touches a
|
|
764
|
+
// file. This exists because the section is a copy and a copy goes quietly out
|
|
765
|
+
// of date — this repo's own was a whole spec behind the template it ships,
|
|
766
|
+
// through a spec about that template, with every test green.
|
|
767
|
+
function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
|
|
768
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
769
|
+
const manifest = readManifest(dir)
|
|
770
|
+
const rows = []
|
|
771
|
+
// Mirror `resyncManagedFile`'s decision exactly rather than re-deriving it:
|
|
772
|
+
// missing → it would create; customized → it would KEEP yours and say so;
|
|
773
|
+
// pristine → it would write only when the shipped content actually differs.
|
|
774
|
+
for (const { relPath, abs, bundled } of managedTargets(dir)) {
|
|
775
|
+
const state = managedState(dir, relPath, manifest, bundled)
|
|
776
|
+
if (state === 'missing') rows.push([relPath, 'missing — would be created'])
|
|
777
|
+
else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
|
|
778
|
+
else if (state === 'adopted')
|
|
779
|
+
rows.push([relPath, 'adopted upstream — yours kept, theirs not installed (--force takes theirs)'])
|
|
780
|
+
else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
|
|
781
|
+
}
|
|
782
|
+
const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
|
|
783
|
+
// A healthy area says NOTHING. A report that lists what is already fine is a
|
|
784
|
+
// report people learn to skim, and then the one line that mattered is missed.
|
|
785
|
+
if (section === 'differs') {
|
|
786
|
+
rows.push([
|
|
787
|
+
'CLAUDE.md (spec workflow section)',
|
|
788
|
+
'differs from the shipped one — `update` would replace it (it is a copy, so this is either your edit or an out-of-date one)',
|
|
789
|
+
])
|
|
790
|
+
}
|
|
791
|
+
if (!rows.length) log('skitterspec update --check: everything is up to date.')
|
|
792
|
+
else {
|
|
793
|
+
log('skitterspec update --check: `skitterspec update` would:')
|
|
794
|
+
for (const [name, why] of rows) log(` ${name} — ${why}`)
|
|
795
|
+
}
|
|
796
|
+
return { rows, section }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function installClaudeMd(dir, { mode }) {
|
|
800
|
+
const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
801
|
+
const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
|
|
802
|
+
const target = path.join(dir, 'CLAUDE.md')
|
|
803
|
+
|
|
804
|
+
if (!fs.existsSync(target)) {
|
|
805
|
+
fs.writeFileSync(target, `# ${path.basename(dir)}\n\n${block}`)
|
|
806
|
+
report.created.push('CLAUDE.md')
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
811
|
+
|
|
812
|
+
if (existing.includes(SPEC_MARKER_START) && existing.includes(SPEC_MARKER_END)) {
|
|
813
|
+
if (mode !== 'update') {
|
|
814
|
+
report.skipped.push('CLAUDE.md (spec workflow already present)')
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
const re = new RegExp(`${SPEC_MARKER_START}[\\s\\S]*?${SPEC_MARKER_END}\\n?`)
|
|
818
|
+
const next = existing.replace(re, block)
|
|
819
|
+
if (next === existing) {
|
|
820
|
+
report.skipped.push('CLAUDE.md')
|
|
821
|
+
} else {
|
|
822
|
+
fs.writeFileSync(target, next)
|
|
823
|
+
report.updated.push('CLAUDE.md (spec workflow section)')
|
|
824
|
+
}
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (/^##\s+Spec workflow/m.test(existing)) {
|
|
829
|
+
report.skipped.push('CLAUDE.md (has a manual "Spec workflow" section — left alone)')
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n'
|
|
834
|
+
fs.writeFileSync(target, `${existing}${sep}${block}`)
|
|
835
|
+
report.updated.push('CLAUDE.md (appended spec workflow section)')
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// --- detection, resync, reset (safe re-run) ---------------------------------
|
|
839
|
+
|
|
840
|
+
// True when the repo looks already set up: any managed file present, any spec
|
|
841
|
+
// lifecycle folder, or the CLAUDE.md spec marker (Decision 1 — detect eagerly).
|
|
842
|
+
// Is skitterspec already installed here? Matched on what we ACTUALLY install —
|
|
843
|
+
// our managed files, our lifecycle folders, our CLAUDE.md marker — never on
|
|
844
|
+
// `.claude/` merely existing: someone else's skills are not evidence of ours,
|
|
845
|
+
// and reading them as ours would treat every Claude Code project as a
|
|
846
|
+
// half-finished install.
|
|
847
|
+
function isExistingSetup(dir) {
|
|
848
|
+
if (managedTargets(dir).some((t) => fs.existsSync(t.abs))) return true
|
|
849
|
+
if (SPEC_FOLDERS.some((f) => fs.existsSync(path.join(dir, 'specs', f)))) return true
|
|
850
|
+
const claude = path.join(dir, 'CLAUDE.md')
|
|
851
|
+
return fs.existsSync(claude) && fs.readFileSync(claude, 'utf8').includes(SPEC_MARKER_START)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// RESYNC: bring managed files to the latest bundled version WITHOUT clobbering
|
|
855
|
+
// user edits. Per file: missing → create; pristine (matches the manifest) →
|
|
856
|
+
// update; customized (edited) → keep + report, unless `force`.
|
|
857
|
+
function resyncManagedFile(dir, target, manifest, force) {
|
|
858
|
+
const { relPath, abs, bundled } = target
|
|
859
|
+
const state = managedState(dir, relPath, manifest, bundled)
|
|
860
|
+
const write = (bucket) => {
|
|
861
|
+
ensureDir(path.dirname(abs))
|
|
862
|
+
fs.writeFileSync(abs, bundled)
|
|
863
|
+
writtenHashes[relPath] = sha1(bundled)
|
|
864
|
+
report[bucket].push(relPath)
|
|
865
|
+
}
|
|
866
|
+
if (state === 'missing') return write('created')
|
|
867
|
+
// `adopted` writes exactly as `customized` does — keep unless forced. Only the
|
|
868
|
+
// bucket differs, because only the REPORT was ever wrong about it.
|
|
869
|
+
if (state === 'customized' || state === 'adopted') {
|
|
870
|
+
if (force) return write('updated')
|
|
871
|
+
writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
|
|
872
|
+
// Carry the change the user just DECLINED. A bare filename tells them a
|
|
873
|
+
// decision was made on their behalf but not what it was, which leaves
|
|
874
|
+
// "clobber and re-apply my edits by hand" as the only safe way to upgrade.
|
|
875
|
+
const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
|
|
876
|
+
return report[state].push({ relPath, added, removed, hunks })
|
|
877
|
+
}
|
|
878
|
+
// pristine — update only if the bundled content actually changed
|
|
879
|
+
if (fs.readFileSync(abs, 'utf8') === bundled) {
|
|
880
|
+
// The file is ours and current, but the manifest disagreed — record the
|
|
881
|
+
// repair rather than healing in silence: a file that quietly starts
|
|
882
|
+
// updating again is as opaque as one that quietly stopped.
|
|
883
|
+
if (manifest.files[relPath] !== sha1(bundled)) report.healed.push(relPath)
|
|
884
|
+
writtenHashes[relPath] = sha1(bundled)
|
|
885
|
+
return report.skipped.push(relPath)
|
|
886
|
+
}
|
|
887
|
+
write('updated')
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
|
|
891
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
892
|
+
resetReport()
|
|
893
|
+
const manifest = readManifest(dir)
|
|
894
|
+
for (const t of managedTargets(dir)) resyncManagedFile(dir, t, manifest, force)
|
|
895
|
+
installFolders(dir)
|
|
896
|
+
removeRetiredFiles(dir)
|
|
897
|
+
pruneRetiredManaged(dir, manifest)
|
|
898
|
+
// The hook SCRIPT arrives above, as one more managed target; registering it is
|
|
899
|
+
// a separate write to a file we do not manage, so it has to be asked for here.
|
|
900
|
+
// It is easy to read this as duplication of `installHooks()` and delete it —
|
|
901
|
+
// it is not. `installHooks()` is unreachable from this path, and the two
|
|
902
|
+
// halves being in different functions is exactly how they drifted apart once:
|
|
903
|
+
// `update` copied `.claude/hooks/` and registered nothing, for every upgrading
|
|
904
|
+
// project, while reporting a file created. `init-review-gate-hook.test.js`
|
|
905
|
+
// asserts over the entry points rather than over this call.
|
|
906
|
+
registerReviewGateHook(dir)
|
|
907
|
+
if (claudeMd) installClaudeMd(dir, { mode: 'update' })
|
|
908
|
+
suggestReleaseWith(dir)
|
|
909
|
+
flushManifest(dir)
|
|
910
|
+
printReport(dir, 'resync', { diff })
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// The never-touch set: START AGAIN may only delete a known managed file, and may
|
|
914
|
+
// never delete spec content or active config (defense-in-depth — the manifest
|
|
915
|
+
// never lists these, but a tampered/foreign entry must still be refused).
|
|
916
|
+
const PROTECTED_SPEC_BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
|
|
917
|
+
const PROTECTED_CONFIG = ['env.config.json', 'linear.config.json', 'jira.config.json', 'github.config.json', 'azure-boards.config.json']
|
|
918
|
+
const PROTECTED_DIRS = ['linear-base', 'linear-backups', 'jira-base', 'jira-backups', 'github-base', 'github-backups', 'azure-base', 'azure-backups']
|
|
919
|
+
|
|
920
|
+
function assertSafeToDelete(relPath, managedSet) {
|
|
921
|
+
const norm = relPath.split(path.sep).join('/')
|
|
922
|
+
for (const b of PROTECTED_SPEC_BUCKETS) {
|
|
923
|
+
if (norm.startsWith(`specs/${b}/`)) throw new Error(`refusing to delete spec content: ${relPath}`)
|
|
924
|
+
}
|
|
925
|
+
if (PROTECTED_CONFIG.includes(path.posix.basename(norm))) {
|
|
926
|
+
throw new Error(`refusing to delete active config: ${relPath}`)
|
|
927
|
+
}
|
|
928
|
+
for (const d of PROTECTED_DIRS) {
|
|
929
|
+
if (norm.includes(`/${d}/`)) throw new Error(`refusing to delete sync state: ${relPath}`)
|
|
930
|
+
}
|
|
931
|
+
if (!managedSet.has(norm)) throw new Error(`refusing to delete a non-managed path: ${relPath}`)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Remove the marked spec-workflow block from CLAUDE.md (leaves the rest intact).
|
|
935
|
+
function stripClaudeMdSection(dir) {
|
|
936
|
+
const target = path.join(dir, 'CLAUDE.md')
|
|
937
|
+
if (!fs.existsSync(target)) return
|
|
938
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
939
|
+
const next = existing.replace(
|
|
940
|
+
new RegExp(`\\n?${SPEC_MARKER_START}[\\s\\S]*?${SPEC_MARKER_END}\\n?`),
|
|
941
|
+
'\n',
|
|
942
|
+
)
|
|
943
|
+
if (next !== existing) {
|
|
944
|
+
fs.writeFileSync(target, next)
|
|
945
|
+
report.removed.push('CLAUDE.md (spec workflow section)')
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// START AGAIN: delete exactly the manifest-listed managed files (each guarded),
|
|
950
|
+
// strip the CLAUDE.md marked section, then reinstall fresh. Never touches a
|
|
951
|
+
// non-manifest path. Destructive by design for managed files (that's the point).
|
|
952
|
+
function reset(dir, { claudeMd = true } = {}) {
|
|
953
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
954
|
+
resetReport()
|
|
955
|
+
const manifest = readManifest(dir)
|
|
956
|
+
const managedSet = new Set(managedTargets(dir).map((t) => t.relPath.split(path.sep).join('/')))
|
|
957
|
+
for (const relPath of Object.keys(manifest.files)) {
|
|
958
|
+
assertSafeToDelete(relPath, managedSet)
|
|
959
|
+
const abs = path.join(dir, relPath)
|
|
960
|
+
if (fs.existsSync(abs)) {
|
|
961
|
+
fs.unlinkSync(abs)
|
|
962
|
+
report.removed.push(relPath)
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (claudeMd) stripClaudeMdSection(dir)
|
|
966
|
+
installSkills(dir, { force: true })
|
|
967
|
+
installCommands(dir, { force: true })
|
|
968
|
+
installRule(dir, { force: true })
|
|
969
|
+
installHooks(dir, { force: true })
|
|
970
|
+
installFolders(dir)
|
|
971
|
+
removeRetiredFiles(dir)
|
|
972
|
+
installCore(dir, { force: true })
|
|
973
|
+
if (claudeMd) installClaudeMd(dir, { mode: 'init' })
|
|
974
|
+
flushManifest(dir)
|
|
975
|
+
printReport(dir, 'reset')
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function printReport(dir, mode, { diff = false } = {}) {
|
|
979
|
+
const line = (label, items) => {
|
|
980
|
+
if (!items.length) return
|
|
981
|
+
process.stdout.write(`\n${label}:\n`)
|
|
982
|
+
for (const it of items) process.stdout.write(` ${it}\n`)
|
|
983
|
+
}
|
|
984
|
+
process.stdout.write(`\nskitterspec ${mode} → ${dir}\n`)
|
|
985
|
+
line('created', report.created)
|
|
986
|
+
line('updated', report.updated)
|
|
987
|
+
line('removed', report.removed)
|
|
988
|
+
line(
|
|
989
|
+
'customized (kept)',
|
|
990
|
+
report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
991
|
+
)
|
|
992
|
+
line(
|
|
993
|
+
'adopted upstream (kept — ours was never installed)',
|
|
994
|
+
report.adopted.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
995
|
+
)
|
|
996
|
+
line('manifest repaired', report.healed)
|
|
997
|
+
line('unchanged', report.skipped)
|
|
998
|
+
if (report.refused.length) {
|
|
999
|
+
process.stdout.write('\nrefused (a symlink — writing would overwrite what it points at):\n')
|
|
1000
|
+
for (const it of report.refused) process.stdout.write(` ${it}\n`)
|
|
1001
|
+
process.stdout.write(
|
|
1002
|
+
' These are links into the shipped assets. --force would follow them and\n' +
|
|
1003
|
+
' write composed content into the source. Unlink one to take the copy\n' +
|
|
1004
|
+
' (rm <path>, then re-run), or leave it linked and edit the asset.\n',
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
if (report.suggestions.length) {
|
|
1008
|
+
process.stdout.write('\nsuggestions:\n')
|
|
1009
|
+
for (const x of report.suggestions) process.stdout.write(` - ${x}\n`)
|
|
1010
|
+
}
|
|
1011
|
+
if (report.warnings.length) {
|
|
1012
|
+
process.stdout.write('\nwarnings:\n')
|
|
1013
|
+
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
1014
|
+
}
|
|
1015
|
+
const declined = [
|
|
1016
|
+
...report.customized.map((c) => [c, 'kept — this is what you declined']),
|
|
1017
|
+
...report.adopted.map((c) => [c, 'kept — this is what was not installed']),
|
|
1018
|
+
]
|
|
1019
|
+
if (diff) {
|
|
1020
|
+
for (const [c, why] of declined) {
|
|
1021
|
+
if (!c.hunks.length) continue
|
|
1022
|
+
process.stdout.write(`\n--- ${c.relPath} (${why})\n`)
|
|
1023
|
+
for (const h of c.hunks) process.stdout.write(`${h}\n`)
|
|
1024
|
+
}
|
|
1025
|
+
} else if (declined.length) {
|
|
1026
|
+
process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
|
|
1027
|
+
}
|
|
1028
|
+
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
1029
|
+
const isolationNote = isolationOn
|
|
1030
|
+
? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
|
|
1031
|
+
' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
|
|
1032
|
+
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
1033
|
+
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
1034
|
+
const gatingOn = fs.existsSync(path.join(dir, 'specs', '.core', 'gating.config.json'))
|
|
1035
|
+
const gatingNote = gatingOn
|
|
1036
|
+
? 'Release gating is ON: /spec asks whether a change ships behind a feature' +
|
|
1037
|
+
' flag and records the answer on the spec (skitterspec gating check reports' +
|
|
1038
|
+
' any that have none).\n'
|
|
1039
|
+
: 'Release gating is opt-in: re-run with --gating (or copy' +
|
|
1040
|
+
' specs/.core/gating.config.json.example → gating.config.json) to enable it.\n'
|
|
1041
|
+
// A provider superset ships its own `spec-<provider>-setup` skill; the base
|
|
1042
|
+
// ships none. Discovering it from what was actually installed keeps this file
|
|
1043
|
+
// tracker-free — it never has to know which tracker (if any) is in the box.
|
|
1044
|
+
const setupSkill = SKILLS.find((s) => /^spec-.+-setup$/.test(s))
|
|
1045
|
+
const provider = setupSkill ? /^spec-(.+)-setup$/.exec(setupSkill)[1] : null
|
|
1046
|
+
// …and the same derivation gives the provider's config filename, so this can
|
|
1047
|
+
// report tracker sync the way it reports isolation above — from what is
|
|
1048
|
+
// actually on disk. It used to say "opt-in: run /…-setup" even on a repo that
|
|
1049
|
+
// had already configured it, telling you to set up what was already set up.
|
|
1050
|
+
const trackerOn =
|
|
1051
|
+
provider && fs.existsSync(path.join(dir, 'specs', '.core', `${provider}.config.json`))
|
|
1052
|
+
const trackerNote = !setupSkill
|
|
1053
|
+
? ''
|
|
1054
|
+
: trackerOn
|
|
1055
|
+
? `Tracker sync is ON: ${provider} — the repo stays the source of truth;` +
|
|
1056
|
+
' /spec-push mirrors a spec up and /spec-status reports drift.\n'
|
|
1057
|
+
: `Tracker sync is opt-in: run /${setupSkill} to configure it` +
|
|
1058
|
+
' (it discovers your workspace and writes the config), or see' +
|
|
1059
|
+
' specs/.core/SETUP.md.\n'
|
|
1060
|
+
process.stdout.write(
|
|
1061
|
+
'\nDone. Skills resolve as /spec, /spec-start, /spec-next, /spec-complete,' +
|
|
1062
|
+
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
|
1063
|
+
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
1064
|
+
" project's stack, then run /spec.\n" +
|
|
1065
|
+
isolationNote +
|
|
1066
|
+
gatingNote +
|
|
1067
|
+
trackerNote,
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
|
|
1072
|
+
// unrelated to the config's own `mode` key — which arrives as `workspaceMode`
|
|
1073
|
+
// precisely so the two cannot be confused at a call site.
|
|
1074
|
+
/**
|
|
1075
|
+
* Suggest `hotfix.releaseWith` where skittership's hotfix release is installed
|
|
1076
|
+
* and nothing is configured yet. SUGGESTED, NEVER WRITTEN: it changes what a
|
|
1077
|
+
* hotfix land does — a version commit and the project's own tag scheme — and
|
|
1078
|
+
* that is the project's decision, not something an install makes for it.
|
|
1079
|
+
*
|
|
1080
|
+
* Silent unless both signals are present: the script on disk, and an
|
|
1081
|
+
* isolation config that leaves `releaseWith` empty. A config that cannot be
|
|
1082
|
+
* read is cannot-tell, and says nothing.
|
|
1083
|
+
*/
|
|
1084
|
+
function suggestReleaseWith(dir) {
|
|
1085
|
+
if (!fs.existsSync(path.join(dir, 'scripts', 'release-hotfix.cjs'))) return
|
|
1086
|
+
let loaded
|
|
1087
|
+
try {
|
|
1088
|
+
loaded = require('./env/config.js').loadEnvConfig(dir)
|
|
1089
|
+
} catch {
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
if (!loaded || !loaded.present || loaded.config.hotfix.releaseWith) return
|
|
1093
|
+
report.suggestions.push(
|
|
1094
|
+
'scripts/release-hotfix.cjs is installed — to have hotfix land cut its release, set ' +
|
|
1095
|
+
'"hotfix": { "releaseWith": "node scripts/release-hotfix.cjs --json" } in ' +
|
|
1096
|
+
'specs/.core/env.config.json',
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gating }) {
|
|
1101
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
1102
|
+
resetReport()
|
|
1103
|
+
|
|
1104
|
+
installSkills(dir, { force })
|
|
1105
|
+
installCommands(dir, { force })
|
|
1106
|
+
installRule(dir, { force })
|
|
1107
|
+
installHooks(dir, { force })
|
|
1108
|
+
installFolders(dir)
|
|
1109
|
+
removeRetiredFiles(dir)
|
|
1110
|
+
installCore(dir, { force })
|
|
1111
|
+
// Adopting isolation writes the live env.config.json — init only, never update.
|
|
1112
|
+
if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
|
|
1113
|
+
if (mode !== 'update') installGating(dir, { enabled: gating }, { force })
|
|
1114
|
+
if (claudeMd) installClaudeMd(dir, { mode })
|
|
1115
|
+
suggestReleaseWith(dir)
|
|
1116
|
+
|
|
1117
|
+
// Record what we wrote (and migrate a pre-manifest repo) so a later resync can
|
|
1118
|
+
// tell our files from the user's.
|
|
1119
|
+
flushManifest(dir)
|
|
1120
|
+
|
|
1121
|
+
printReport(dir, mode)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
module.exports = {
|
|
1125
|
+
init,
|
|
1126
|
+
// A snapshot of the last run's report, for tests that need to assert on what a
|
|
1127
|
+
// run DECIDED rather than only on what it left on disk. Copied, so a caller
|
|
1128
|
+
// cannot mutate the live report between phases of a run.
|
|
1129
|
+
lastReport: () => JSON.parse(JSON.stringify(report)),
|
|
1130
|
+
SKILLS,
|
|
1131
|
+
COMMANDS,
|
|
1132
|
+
RULES,
|
|
1133
|
+
SPEC_FOLDERS,
|
|
1134
|
+
MANIFEST_FILE,
|
|
1135
|
+
sha1,
|
|
1136
|
+
readManifest,
|
|
1137
|
+
writeManifest,
|
|
1138
|
+
managedTargets,
|
|
1139
|
+
managedState,
|
|
1140
|
+
claudeMdSectionState,
|
|
1141
|
+
checkSync,
|
|
1142
|
+
isExistingSetup,
|
|
1143
|
+
resync,
|
|
1144
|
+
reset,
|
|
1145
|
+
assertSafeToDelete,
|
|
1146
|
+
assertComposedAssets,
|
|
1147
|
+
detectPackageManager,
|
|
1148
|
+
detectRunner,
|
|
1149
|
+
renderCommand,
|
|
1150
|
+
}
|