@skitterbyte/skitterspec 22.0.0 → 22.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +66 -9
- package/assets/skills/no-spec/SKILL.md +221 -0
- package/assets/skills/spec/SKILL.md +138 -37
- package/assets/skills/spec-bug/SKILL.md +8 -2
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +36 -2
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +50 -3
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +749 -62
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +113 -4
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- 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))
|
|
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
|
-
|
|
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
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
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
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/env/teardown.js
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
559
|
+
res = ensureHooks(dir)
|
|
451
560
|
} catch {
|
|
452
|
-
report.warnings.push('could not write .claude/settings.json —
|
|
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
|
|
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 (
|
|
577
|
+
report.updated.push('.claude/settings.json (skitterspec hooks repointed at the renamed script)')
|
|
469
578
|
} else {
|
|
470
|
-
report.skipped.push('.claude/settings.json (
|
|
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
|
}
|