@skitterbyte/skitterspec 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  const { expandTokens } = require('./resolve.js')
25
+ const { composeFileArg } = require('./provision.js')
25
26
 
26
27
  /**
27
28
  * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
@@ -64,6 +65,14 @@ function planDown(spec, config, flags, ctx) {
64
65
  // (Stack: worktree) has no stack/volumes even when the project master switch is
65
66
  // on — tearing it down is just removing the worktree. A spec resolved without an
66
67
  // explicit stack (legacy/tests) follows the master switch (pre-`Stack` behaviour).
68
+ //
69
+ // DELIBERATELY NOT GATED ON THE COMPOSE FILE, where `planUp` is. The two
70
+ // directions are not symmetric: bringing a stack up without evidence there is
71
+ // one to bring costs a failed provision, while skipping a `down` costs an
72
+ // orphaned stack and its volumes, still running, that nothing will come back
73
+ // for. So `up` needs a positive signal and `down` errs toward cleaning up —
74
+ // a `down` for a stack that was never started is a no-op (rule 4, pointing
75
+ // the other way because the harmless branch is the other branch).
67
76
  const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
68
77
  const wantsDocker = stack === 'docker' && config.docker.enabled
69
78
 
@@ -86,7 +95,9 @@ function planDown(spec, config, flags, ctx) {
86
95
 
87
96
  // --- docker compose down (drop volumes unless kept) ---
88
97
  if (wantsDocker) {
89
- const base = `docker compose --project-name ${spec.projectName} down`
98
+ const base =
99
+ `docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
100
+ ` --project-name ${spec.projectName} down`
90
101
  commands.push(volumesDropped ? `${base} --volumes` : base)
91
102
  }
92
103
 
package/src/init.js CHANGED
@@ -5,7 +5,7 @@ const path = require('path')
5
5
  const crypto = require('crypto')
6
6
 
7
7
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
8
- const { ensureReviewGateHook } = require('./env/hooks.js')
8
+ const { ensureHooks } = require('./env/hooks.js')
9
9
  const { repoInfo, expandTokens } = require('./env/resolve.js')
10
10
 
11
11
  const ASSETS = path.join(__dirname, '..', 'assets')
@@ -93,14 +93,20 @@ const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled'
93
93
  // env.config isolation templates; a provider superset also ships its own).
94
94
  const CORE_FILES = listCoreTemplates()
95
95
 
96
- // The CLI is a local devDependency and never on PATH, so a command file that
97
- // pre-executes it must carry a literal, working invocation. Detect the runner
98
- // from the lockfile and bake it in at write time.
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
99
  //
100
- // The lockfile is a POSITIVE signal — a file that must be present for the answer
101
- // to be yes — rather than an absence. When none is found we do not guess a
102
- // package manager we have no evidence for; `npx` is the fallback because it is
103
- // the one runner that works across all three installs.
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.
104
110
  const PACKAGE_MANAGERS = [
105
111
  ['pnpm-lock.yaml', 'pnpm exec'],
106
112
  ['yarn.lock', 'yarn'],
@@ -108,6 +114,8 @@ const PACKAGE_MANAGERS = [
108
114
  ['bun.lockb', 'bunx'],
109
115
  ]
110
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.
111
119
  function detectPackageManager(dir) {
112
120
  for (const [lockfile, exec] of PACKAGE_MANAGERS) {
113
121
  if (fs.existsSync(path.join(dir, lockfile))) return exec
@@ -115,12 +123,97 @@ function detectPackageManager(dir) {
115
123
  return 'npx'
116
124
  }
117
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']
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
+
118
202
  // Fill a command file's `{{exec}}` placeholders. Kept a pure function of
119
203
  // (content, dir) so `managedTargets` can compare against exactly what
120
204
  // `installCommands` would write — otherwise every install would hash as
121
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.
122
210
  function renderCommand(content, dir) {
123
- return content.split('{{exec}}').join(detectPackageManager(dir))
211
+ const { runner } = detectRunner(dir)
212
+ return content
213
+ .split('{{exec}} ')
214
+ .join(runner ? `${runner} ` : '')
215
+ .split('{{exec}}')
216
+ .join(runner)
124
217
  }
125
218
 
126
219
  // --- composed-assets guard -------------------------------------------------
@@ -179,7 +272,7 @@ function assertComposedAssets() {
179
272
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
180
273
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
181
274
 
182
- const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
275
+ const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], adopted: [], healed: [], warnings: [] }
183
276
 
184
277
  function resetReport() {
185
278
  for (const k of Object.keys(report)) report[k].length = 0
@@ -241,16 +334,31 @@ function managedTargets(dir) {
241
334
  }
242
335
 
243
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.
244
348
  function readManifest(dir) {
245
349
  try {
246
350
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
247
351
  if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
248
- return { version: parsed.version || MANIFEST_VERSION, files: parsed.files }
352
+ return {
353
+ version: parsed.version || MANIFEST_VERSION,
354
+ files: parsed.files,
355
+ baselined: Object.keys(parsed.files).length > 0,
356
+ }
249
357
  }
250
358
  } catch {
251
359
  /* missing or malformed → empty baseline */
252
360
  }
253
- return { version: MANIFEST_VERSION, files: {} }
361
+ return { version: MANIFEST_VERSION, files: {}, baselined: false }
254
362
  }
255
363
 
256
364
  function writeManifest(dir, files) {
@@ -265,6 +373,29 @@ function writeManifest(dir, files) {
265
373
  // missing — not on disk
266
374
  // pristine — ours to update: it matches the package asset, or the hash we recorded
267
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.
268
399
  //
269
400
  // `bundled` (the current package asset) is optional but decisive: a file whose
270
401
  // CONTENT equals what we ship is not customized, whatever the manifest says.
@@ -280,7 +411,8 @@ function managedState(dir, relPath, manifest, bundled) {
280
411
  const onDisk = fs.readFileSync(abs, 'utf8')
281
412
  if (bundled !== undefined && onDisk === bundled) return 'pristine'
282
413
  const known = manifest.files[relPath]
283
- return known && sha1(onDisk) === known ? 'pristine' : 'customized'
414
+ if (known) return sha1(onDisk) === known ? 'pristine' : 'customized'
415
+ return manifest.baselined ? 'adopted' : 'customized'
284
416
  }
285
417
 
286
418
  // Reconcile and persist the manifest after an install/resync run: keep prior
@@ -386,6 +518,19 @@ function installCommands(dir, opts) {
386
518
  )
387
519
  writeFile(dir, path.join(dir, '.claude', 'commands', name), content, opts)
388
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
+ }
389
534
  }
390
535
 
391
536
  function installRule(dir, opts) {
@@ -405,17 +550,20 @@ function installRule(dir, opts) {
405
550
  // reported and left alone, never rewritten, and never fatal — the hook is an
406
551
  // extra layer, and the engine and `/spec-next` hold the gate without it.
407
552
  function registerReviewGateHook(dir) {
408
- const label = '.claude/settings.json (review-gate hook)'
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)'
409
557
  let res
410
558
  try {
411
- res = ensureReviewGateHook(dir)
559
+ res = ensureHooks(dir)
412
560
  } catch {
413
- report.warnings.push('could not write .claude/settings.json — review-gate hook not registered')
561
+ report.warnings.push('could not write .claude/settings.json — skitterspec hooks not registered')
414
562
  return
415
563
  }
416
564
  if (res.reason === 'malformed') {
417
565
  report.warnings.push(
418
- '.claude/settings.json is not valid JSON — did not register the review-gate hook',
566
+ '.claude/settings.json is not valid JSON — did not register the skitterspec hooks',
419
567
  )
420
568
  } else if (res.reason === 'created') {
421
569
  report.created.push(label)
@@ -426,9 +574,9 @@ function registerReviewGateHook(dir) {
426
574
  // that: rewriting the registered path and then reporting "already
427
575
  // registered" — a run that acts and says it did not, which is the shape of
428
576
  // the bug this whole change exists to fix.
429
- report.updated.push('.claude/settings.json (review-gate hook repointed at the renamed script)')
577
+ report.updated.push('.claude/settings.json (skitterspec hooks repointed at the renamed script)')
430
578
  } else {
431
- report.skipped.push('.claude/settings.json (review-gate hook already registered)')
579
+ report.skipped.push('.claude/settings.json (skitterspec hooks already registered)')
432
580
  }
433
581
  }
434
582
 
@@ -627,6 +775,8 @@ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
627
775
  const state = managedState(dir, relPath, manifest, bundled)
628
776
  if (state === 'missing') rows.push([relPath, 'missing — would be created'])
629
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)'])
630
780
  else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
631
781
  }
632
782
  const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
@@ -714,14 +864,16 @@ function resyncManagedFile(dir, target, manifest, force) {
714
864
  report[bucket].push(relPath)
715
865
  }
716
866
  if (state === 'missing') return write('created')
717
- if (state === 'customized') {
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') {
718
870
  if (force) return write('updated')
719
871
  writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
720
872
  // Carry the change the user just DECLINED. A bare filename tells them a
721
873
  // decision was made on their behalf but not what it was, which leaves
722
874
  // "clobber and re-apply my edits by hand" as the only safe way to upgrade.
723
875
  const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
724
- return report.customized.push({ relPath, added, removed, hunks })
876
+ return report[state].push({ relPath, added, removed, hunks })
725
877
  }
726
878
  // pristine — update only if the bundled content actually changed
727
879
  if (fs.readFileSync(abs, 'utf8') === bundled) {
@@ -836,6 +988,10 @@ function printReport(dir, mode, { diff = false } = {}) {
836
988
  'customized (kept)',
837
989
  report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
838
990
  )
991
+ line(
992
+ 'adopted upstream (kept — ours was never installed)',
993
+ report.adopted.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
994
+ )
839
995
  line('manifest repaired', report.healed)
840
996
  line('unchanged', report.skipped)
841
997
  if (report.refused.length) {
@@ -851,13 +1007,17 @@ function printReport(dir, mode, { diff = false } = {}) {
851
1007
  process.stdout.write('\nwarnings:\n')
852
1008
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
853
1009
  }
1010
+ const declined = [
1011
+ ...report.customized.map((c) => [c, 'kept — this is what you declined']),
1012
+ ...report.adopted.map((c) => [c, 'kept — this is what was not installed']),
1013
+ ]
854
1014
  if (diff) {
855
- for (const c of report.customized) {
1015
+ for (const [c, why] of declined) {
856
1016
  if (!c.hunks.length) continue
857
- process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
1017
+ process.stdout.write(`\n--- ${c.relPath} (${why})\n`)
858
1018
  for (const h of c.hunks) process.stdout.write(`${h}\n`)
859
1019
  }
860
- } else if (report.customized.length) {
1020
+ } else if (declined.length) {
861
1021
  process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
862
1022
  }
863
1023
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
@@ -953,5 +1113,6 @@ module.exports = {
953
1113
  assertSafeToDelete,
954
1114
  assertComposedAssets,
955
1115
  detectPackageManager,
1116
+ detectRunner,
956
1117
  renderCommand,
957
1118
  }