@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.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. 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
+ }