@skitterbyte/skitterspec 22.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 (37) hide show
  1. package/README.md +14 -0
  2. package/assets/claude-md-section.md +13 -0
  3. package/assets/commands/allow-main.md +33 -0
  4. package/assets/commands/spec-skip.md +24 -0
  5. package/assets/core/env.config.json.example +2 -1
  6. package/assets/core/env.config.md +83 -2
  7. package/assets/hooks/main-guard.cjs +145 -0
  8. package/assets/hooks/review-gate.cjs +8 -3
  9. package/assets/review/page.html +132 -2
  10. package/assets/rules/offered-unblocks.md +100 -0
  11. package/assets/rules/spec-planning.md +160 -11
  12. package/assets/rules/spec-reports.md +50 -9
  13. package/assets/skills/no-spec/SKILL.md +219 -0
  14. package/assets/skills/spec/SKILL.md +135 -36
  15. package/assets/skills/spec-bug/SKILL.md +5 -0
  16. package/assets/skills/spec-cancel/SKILL.md +29 -0
  17. package/assets/skills/spec-complete/SKILL.md +25 -0
  18. package/assets/skills/spec-diff/SKILL.md +33 -1
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +47 -2
  21. package/assets/skills/spec-reviewed/SKILL.md +3 -3
  22. package/assets/skills/spec-start/SKILL.md +14 -0
  23. package/package.json +1 -1
  24. package/src/cli.js +702 -59
  25. package/src/env/config.js +121 -4
  26. package/src/env/hooks.js +102 -33
  27. package/src/env/live.js +30 -1
  28. package/src/env/mainguard.js +240 -0
  29. package/src/env/provision.js +106 -10
  30. package/src/env/registry.js +70 -6
  31. package/src/env/resolve.js +92 -3
  32. package/src/env/review.js +324 -20
  33. package/src/env/reviewers/coderabbit.js +228 -0
  34. package/src/env/reviewers.js +517 -0
  35. package/src/env/serve.js +189 -44
  36. package/src/env/teardown.js +12 -1
  37. package/src/init.js +125 -15
package/src/env/serve.js CHANGED
@@ -36,8 +36,9 @@ const {
36
36
  resolveSpec,
37
37
  resolveBaseBranch,
38
38
  } = require('./resolve.js')
39
+ const { readRegistry } = require('./registry.js')
39
40
  const { loadEnvConfig } = require('./config.js')
40
- const { specDocsIn } = require('./classify.js')
41
+ const { specDocsIn, classifyDirtyTree, dirtyPaths } = require('./classify.js')
41
42
  const { liveStateFor } = require('./live.js')
42
43
  const {
43
44
  rawGitReader,
@@ -52,6 +53,8 @@ const {
52
53
  addPending,
53
54
  readNotes,
54
55
  readGate,
56
+ readRenderRecord,
57
+ DEFAULT_BUTTON_SET,
55
58
  } = require('./review.js')
56
59
 
57
60
  /**
@@ -195,6 +198,23 @@ ${body}
195
198
  * spec with no worktree BY DEFINITION, so gating the route on a worktree
196
199
  * excludes precisely the specs the docs view exists for.
197
200
  */
201
+ /**
202
+ * Is this worktree a spec being WRITTEN rather than built?
203
+ *
204
+ * Returns the owned document paths when it is, and null for every other
205
+ * answer — including the ones we cannot work out. Never throws: a page is a
206
+ * convenience and a git that will not read is not evidence of anything.
207
+ */
208
+ function docsWorktree(wt, spec, config) {
209
+ if (!spec || spec.bucket !== 'backlog') return null
210
+ const paths = dirtyPaths(trimmedGitReader(wt))
211
+ // Cannot tell — not "no documents".
212
+ if (paths === null) return null
213
+ const { owned, foreign } = classifyDirtyTree(spec, paths, config)
214
+ if (!owned.length || foreign.length) return null
215
+ return { owned }
216
+ }
217
+
198
218
  function viewFor(dir, config, spec, git, { fallback = false } = {}) {
199
219
  if (!spec) return null
200
220
  // LIVE COMES FIRST, because while a spec is live its worktree still exists —
@@ -203,7 +223,34 @@ function viewFor(dir, config, spec, git, { fallback = false } = {}) {
203
223
  // here too, so this checkout is the tree that answers.
204
224
  if (spec.branch && headBranchOf(git) === spec.branch) return { kind: 'live', tree: dir }
205
225
  const wt = spec.worktreePath
206
- if (wt && wt !== dir && fs.existsSync(wt)) return { kind: 'worktree', tree: wt }
226
+ if (wt && wt !== dir && fs.existsSync(wt)) {
227
+ // A DOCS WORKTREE IS NOT A CODE WORKTREE, and this used to be unreachable.
228
+ //
229
+ // `/spec` once wrote into the primary checkout, so a backlog spec had no
230
+ // worktree at all and fell through to the docs branch below. It now
231
+ // provisions the spec's own `--docs` worktree and writes there — so this
232
+ // check began winning for exactly the specs the docs branch exists to
233
+ // serve, and their pages were served the COMMITTING set. A reader pressed
234
+ // `Commit & Continue` on a backlog spec that has no phase to continue.
235
+ //
236
+ // Asked as a POSITIVE SIGNAL and never as "no code yet"
237
+ // (`.claude/rules/negative-checks.md` rule 1), and it takes BOTH halves:
238
+ //
239
+ // - the spec is in the BACKLOG, which is what `authoring` means. Without
240
+ // this, the window right after `/spec-start` — where the `git mv` and
241
+ // the header edit are uncommitted and no phase code exists yet — reads
242
+ // as documents-only and would offer `Commit & Start` for a spec
243
+ // already in flight.
244
+ // - the worktree holds NOTHING BUT this spec's own documents. Without
245
+ // this, a backlog spec whose tree had been used for anything else
246
+ // would have that work rendered under an authoring verdict.
247
+ //
248
+ // Anything else is the ordinary worktree view, including every cannot-tell:
249
+ // an unresolvable bucket, a git that will not read, an empty tree.
250
+ const docs = docsWorktree(wt, spec, config)
251
+ if (docs) return { kind: 'docs', tree: wt, owned: docs.owned }
252
+ return { kind: 'worktree', tree: wt }
253
+ }
207
254
  const found = specDocsIn(dir, spec, config, trimmedGitReader(dir))
208
255
  // `error` is cannot-tell — not a page, and not an error page either (rule 4:
209
256
  // the harmless branch).
@@ -303,9 +350,12 @@ function trimmedGitReader(treePath) {
303
350
  * IT INCLUDES WORKTREE-LESS SPECS NOW, and both halves are needed together: a
304
351
  * page that serves but is absent from the index is a page nobody finds.
305
352
  */
306
- function servableSpecs(dir, config, git) {
353
+ function servableSpecs(dir, config, git, specless = {}) {
307
354
  const worktreePaths = liveWorktreePaths(git)
308
- return allSpecs(dir, config, worktreePaths)
355
+ // `allSpecs` scans `specs/**`, where a specless branch is by definition not —
356
+ // so it takes the registry's map and adds them. Defaulted to empty, because
357
+ // every caller that has no concept of them should behave exactly as before.
358
+ return allSpecs(dir, config, worktreePaths, specless)
309
359
  .filter((s) => Boolean(viewFor(dir, config, s, git)))
310
360
  .sort((a, b) => a.folder.localeCompare(b.folder))
311
361
  }
@@ -368,13 +418,65 @@ function receivePass(dir, config, spec, blob) {
368
418
  return { code: added.code, accepted: parsed.accepted.length, comments: parsed.comments.length }
369
419
  }
370
420
 
421
+ /**
422
+ * The button set a SERVED page shows.
423
+ *
424
+ * Two sources, and each answers only what it knows.
425
+ *
426
+ * THE TREE ANSWERS THE FAMILY, and it answers it better than any record can,
427
+ * because a record is as old as the last render while the tree is now. A spec's
428
+ * uncommitted documents are `authoring`; a branch with no spec at all is
429
+ * `nospec`; a phase in a worktree is `committing`. Those three are exactly the
430
+ * sets that describe what the work IS, so they are read off what the work is.
431
+ *
432
+ * THE RECORD ANSWERS WHETHER THE RUN HAD FINISHED, which no tree can show.
433
+ * `midrun` and `refresh` exist for that — one offers `Continue` in place of the
434
+ * committing pair, the other drops the start verdict — and only the caller that
435
+ * rendered knew. So they, and nothing else, are taken from the record.
436
+ *
437
+ * WHICH MAKES THE RECORD NARROWING-ONLY. A word it does not recognise, a word
438
+ * that would WIDEN the offer, an unreadable file, no file at all: every one of
439
+ * them falls through to the tree (`.claude/rules/negative-checks.md` rule 4).
440
+ * The cost of a stale record is therefore a reader shown `Continue` where they
441
+ * could have been shown `Commit` — one refresh away from the right page — and
442
+ * never a specless branch handed a `Commit & Start` for a spec that does not
443
+ * exist. That was the bug: `renderSpecPage` took `{ branch }` and no set at all,
444
+ * so every `/no-spec` page served over http offered to put nothing in flight,
445
+ * and the pass came back naming a verdict its own skill could not act on.
446
+ */
447
+ const NARROWING_SETS = ['midrun', 'refresh']
448
+
449
+ function buttonsForView(viewKind, spec, recorded) {
450
+ if (NARROWING_SETS.includes(recorded)) return recorded
451
+ if (viewKind === 'docs' || viewKind === 'docs-committed') return 'authoring'
452
+ if (spec && spec.specless) return 'nospec'
453
+ return DEFAULT_BUTTON_SET
454
+ }
455
+
456
+ // What the last CLI render of this spec declared, or null when it declared
457
+ // nothing readable. Never throws: the page is a convenience, and a sidecar is
458
+ // not what it is for.
459
+ function recordedButtons(dir, spec) {
460
+ try {
461
+ return readRenderRecord(reviewOutPath(dir, spec.folder, null), spec.folder).buttons
462
+ } catch {
463
+ return null
464
+ }
465
+ }
466
+
371
467
  function renderSpecPage(dir, config, spec, { branch = false } = {}) {
372
468
  const view = viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })
373
469
  if (!view) return null
374
470
 
375
- // THE DOCS VIEW IS A DIFFERENT TREE AND A DIFFERENT FILE SET, and it takes
376
- // the authoring buttons: a spec with no worktree has no phase in flight, so
377
- // "commit and build the next phase" is the wrong offer for it.
471
+ // Resolved once, for BOTH paths below. The docs path used to hard-code
472
+ // `authoring` here and everything else took the default by saying nothing —
473
+ // which is how a specless branch ended up offering `Commit & Start`.
474
+ const buttons = buttonsForView(view.kind, spec, recordedButtons(dir, spec))
475
+
476
+ // THE DOCS VIEW IS A DIFFERENT TREE AND A DIFFERENT FILE SET. Its buttons
477
+ // come from `buttonsForView` above like every other view's — it used to name
478
+ // `authoring` here itself, which was right for this one case and left every
479
+ // other case with no answer at all.
378
480
  if (view.kind === 'docs' || view.kind === 'docs-committed') {
379
481
  const docsGit = rawGitReader(view.tree)
380
482
  const out = reviewOutPath(dir, spec.folder, null)
@@ -391,7 +493,7 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
391
493
  now: new Date().toISOString(),
392
494
  notes,
393
495
  gate: gateRead.corrupt ? null : gateRead.gate,
394
- buttons: 'authoring',
496
+ buttons,
395
497
  only: view.owned,
396
498
  treePath: view.tree,
397
499
  })
@@ -460,6 +562,7 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
460
562
  now,
461
563
  notes,
462
564
  gate,
565
+ buttons,
463
566
  live,
464
567
  tiers,
465
568
  })
@@ -468,6 +571,10 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
468
571
  const fallbackBase = base()
469
572
  const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
470
573
  if (mergeBase) {
574
+ // THE SET SURVIVES THE FALLBACK, and forgetting it here is not a smaller
575
+ // version of the same mistake — it is the one that fires most. A clean
576
+ // worktree is the state a phase ENDS in: the page is rendered, the commit
577
+ // lands, and from then on every render of that spec comes through here.
471
578
  const wider = collectReview({
472
579
  spec,
473
580
  git,
@@ -477,6 +584,7 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
477
584
  now,
478
585
  notes,
479
586
  gate,
587
+ buttons,
480
588
  live,
481
589
  tiers,
482
590
  fellBack: true,
@@ -770,53 +878,55 @@ function staleServer(recorded, running, recordedMtime, runningMtime) {
770
878
  return 'current'
771
879
  }
772
880
 
773
- module.exports = {
774
- mintToken,
775
- readBody,
776
- MAX_PASS_BYTES,
777
- engineVersionFor,
778
- staleServer,
779
- specSummary,
780
- viewFor,
781
- headBranchOf,
782
- pageTiers,
783
- routeFor,
784
- renderIndex,
785
- servableSpecs,
786
- renderSpecPage,
787
- receivePass,
788
- createReviewServer,
789
- startReviewServer,
790
- }
791
-
792
- // Entry point: run detached by the CLI, reading its settings from a file so a
793
- // restart is a rewrite of that file — the same contract `proxy.js` uses. Only
794
- // `dir` is stored, never a snapshot of the specs: resolving per request is what
795
- // keeps a spec provisioned after the server started from being invisible to it.
796
- if (require.main === module) {
797
- const settingsFile = process.argv[2]
798
- if (!settingsFile) {
799
- process.stderr.write('serve: usage: node serve.js <settingsFile>\n')
800
- process.exit(1)
801
- }
802
- const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
803
- const { config } = loadEnvConfig(dir)
881
+ /**
882
+ * The hooks `createReviewServer` needs, built once.
883
+ *
884
+ * WHY THIS IS A FUNCTION AND NOT INLINE IN THE ENTRY POINT. It used to be
885
+ * inline, and the test suite built its own copy the same way — so the two
886
+ * drifted independently, and a suite standing up its own wiring could be green
887
+ * about a server nobody was shipping. That is exactly what happened: both
888
+ * copies called `resolveSpec` with no `specless` map, every `/no-spec` page
889
+ * 404'd, and nothing failed.
890
+ *
891
+ * THE SPECLESS MAP IS THE POINT. A `/no-spec` branch has a worktree and a
892
+ * branch and no document anywhere under `specs/**`, so it can only ever be
893
+ * resolved on the registry's word — which is a POSITIVE SIGNAL, and the reason
894
+ * a typo still 404s (`.claude/rules/negative-checks.md` rule 1). Both resolvers
895
+ * already took it; the server simply never passed it.
896
+ */
897
+ function serverHooks(dir, config) {
804
898
  const git = rawGitReader(dir)
805
899
  const trimmedGit = (argv) => {
806
900
  const out = git(argv)
807
901
  return out == null ? null : String(out).trim() || null
808
902
  }
809
903
 
904
+ // READ PER REQUEST, never cached. A branch provisioned after the daemon
905
+ // started must be servable without restarting it — the same reason `dir` is
906
+ // all the settings file stores. An unreadable registry is cannot-tell and
907
+ // yields an empty map: the specless branches lose their pages, and every
908
+ // ordinary spec carries on being served.
909
+ const specless = () => {
910
+ try {
911
+ return readRegistry(dir, config).specless || {}
912
+ } catch {
913
+ return {}
914
+ }
915
+ }
916
+
810
917
  const resolveOne = (folder) => {
811
918
  try {
812
- return resolveSpec(folder, dir, config, { searchDirs: [...liveWorktreePaths(trimmedGit)] })
919
+ return resolveSpec(folder, dir, config, {
920
+ searchDirs: [...liveWorktreePaths(trimmedGit)],
921
+ specless: specless(),
922
+ })
813
923
  } catch {
814
924
  return null
815
925
  }
816
926
  }
817
927
 
818
928
  const resolveEntries = () =>
819
- servableSpecs(dir, config, trimmedGit).map((s) => {
929
+ servableSpecs(dir, config, trimmedGit, specless()).map((s) => {
820
930
  const one = resolveOne(s.folder)
821
931
  return {
822
932
  folder: s.folder,
@@ -825,7 +935,7 @@ if (require.main === module) {
825
935
  }
826
936
  })
827
937
 
828
- const server = createReviewServer({
938
+ return {
829
939
  resolveEntries,
830
940
  render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
831
941
  receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
@@ -837,8 +947,43 @@ if (require.main === module) {
837
947
  if (!one) return { state: 'unknown' }
838
948
  return readPassState(reviewOutPath(dir, one.folder, null), one.folder, code)
839
949
  },
840
- token,
841
- })
950
+ }
951
+ }
952
+
953
+ module.exports = {
954
+ mintToken,
955
+ readBody,
956
+ MAX_PASS_BYTES,
957
+ engineVersionFor,
958
+ staleServer,
959
+ specSummary,
960
+ viewFor,
961
+ headBranchOf,
962
+ pageTiers,
963
+ routeFor,
964
+ renderIndex,
965
+ servableSpecs,
966
+ serverHooks,
967
+ renderSpecPage,
968
+ buttonsForView,
969
+ receivePass,
970
+ createReviewServer,
971
+ startReviewServer,
972
+ }
973
+
974
+ // Entry point: run detached by the CLI, reading its settings from a file so a
975
+ // restart is a rewrite of that file — the same contract `proxy.js` uses. Only
976
+ // `dir` is stored, never a snapshot of the specs: resolving per request is what
977
+ // keeps a spec provisioned after the server started from being invisible to it.
978
+ if (require.main === module) {
979
+ const settingsFile = process.argv[2]
980
+ if (!settingsFile) {
981
+ process.stderr.write('serve: usage: node serve.js <settingsFile>\n')
982
+ process.exit(1)
983
+ }
984
+ const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
985
+ const { config } = loadEnvConfig(dir)
986
+ const server = createReviewServer({ ...serverHooks(dir, config), token })
842
987
  startReviewServer(server, { port, host }).then(
843
988
  () => {},
844
989
  (err) => {
@@ -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 -------------------------------------------------
@@ -425,6 +518,19 @@ function installCommands(dir, opts) {
425
518
  )
426
519
  writeFile(dir, path.join(dir, '.claude', 'commands', name), content, opts)
427
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
+ }
428
534
  }
429
535
 
430
536
  function installRule(dir, opts) {
@@ -444,17 +550,20 @@ function installRule(dir, opts) {
444
550
  // reported and left alone, never rewritten, and never fatal — the hook is an
445
551
  // extra layer, and the engine and `/spec-next` hold the gate without it.
446
552
  function registerReviewGateHook(dir) {
447
- 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)'
448
557
  let res
449
558
  try {
450
- res = ensureReviewGateHook(dir)
559
+ res = ensureHooks(dir)
451
560
  } catch {
452
- 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')
453
562
  return
454
563
  }
455
564
  if (res.reason === 'malformed') {
456
565
  report.warnings.push(
457
- '.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',
458
567
  )
459
568
  } else if (res.reason === 'created') {
460
569
  report.created.push(label)
@@ -465,9 +574,9 @@ function registerReviewGateHook(dir) {
465
574
  // that: rewriting the registered path and then reporting "already
466
575
  // registered" — a run that acts and says it did not, which is the shape of
467
576
  // the bug this whole change exists to fix.
468
- 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)')
469
578
  } else {
470
- report.skipped.push('.claude/settings.json (review-gate hook already registered)')
579
+ report.skipped.push('.claude/settings.json (skitterspec hooks already registered)')
471
580
  }
472
581
  }
473
582
 
@@ -1004,5 +1113,6 @@ module.exports = {
1004
1113
  assertSafeToDelete,
1005
1114
  assertComposedAssets,
1006
1115
  detectPackageManager,
1116
+ detectRunner,
1007
1117
  renderCommand,
1008
1118
  }