@skitterbyte/skitterspec 18.0.0 → 20.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +296 -10
- package/README.md +53 -4
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/env.config.json.example +7 -3
- package/assets/core/env.config.md +90 -30
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +1787 -0
- package/assets/rules/spec-planning.md +250 -10
- package/assets/rules/spec-reports.md +321 -0
- package/assets/skills/spec/SKILL.md +32 -4
- package/assets/skills/spec-bug/SKILL.md +113 -8
- package/assets/skills/spec-cancel/SKILL.md +84 -19
- package/assets/skills/spec-complete/SKILL.md +80 -23
- package/assets/skills/spec-diff/SKILL.md +678 -0
- package/assets/skills/spec-hotfix/SKILL.md +113 -10
- package/assets/skills/spec-init/SKILL.md +56 -7
- package/assets/skills/spec-next/SKILL.md +408 -7
- package/assets/skills/spec-review/SKILL.md +26 -3
- package/assets/skills/spec-reviewed/SKILL.md +258 -0
- package/assets/skills/spec-start/SKILL.md +283 -106
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1808 -89
- package/src/env/building.js +143 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +58 -9
- package/src/env/hooks.js +117 -0
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1536 -0
- package/src/env/serve.js +573 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +150 -1
- package/LICENSE +0 -21
package/src/init.js
CHANGED
|
@@ -5,6 +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
9
|
const { repoInfo, expandTokens } = require('./env/resolve.js')
|
|
9
10
|
|
|
10
11
|
const ASSETS = path.join(__dirname, '..', 'assets')
|
|
@@ -38,6 +39,20 @@ function listCommands() {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Hook scripts shipped as `assets/hooks/*.js`, installed to `.claude/hooks/`.
|
|
43
|
+
// Discovered from the bundled tree like everything else, so a distribution
|
|
44
|
+
// installs precisely what it ships and a hook can be retired by deleting it.
|
|
45
|
+
function listHooks() {
|
|
46
|
+
try {
|
|
47
|
+
return fs
|
|
48
|
+
.readdirSync(path.join(ASSETS, 'hooks'))
|
|
49
|
+
.filter((f) => f.endsWith('.js'))
|
|
50
|
+
.sort()
|
|
51
|
+
} catch {
|
|
52
|
+
return [] // a distribution may ship no hooks
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
function listRules() {
|
|
42
57
|
return fs
|
|
43
58
|
.readdirSync(path.join(ASSETS, 'rules'))
|
|
@@ -61,6 +76,8 @@ const COMMANDS = listCommands()
|
|
|
61
76
|
|
|
62
77
|
const RULES = listRules()
|
|
63
78
|
|
|
79
|
+
const HOOKS = listHooks()
|
|
80
|
+
|
|
64
81
|
const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
|
|
65
82
|
|
|
66
83
|
// Opt-in config templates, scaffolded into specs/.core/ (the base ships the
|
|
@@ -153,7 +170,7 @@ function assertComposedAssets() {
|
|
|
153
170
|
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
154
171
|
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
155
172
|
|
|
156
|
-
const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
173
|
+
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
157
174
|
|
|
158
175
|
function resetReport() {
|
|
159
176
|
for (const k of Object.keys(report)) report[k].length = 0
|
|
@@ -209,6 +226,7 @@ function managedTargets(dir) {
|
|
|
209
226
|
for (const name of COMMANDS)
|
|
210
227
|
add(path.join('commands', name), path.join(dir, '.claude', 'commands', name), renderCommand)
|
|
211
228
|
for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
|
|
229
|
+
for (const name of HOOKS) add(path.join('hooks', name), path.join(dir, '.claude', 'hooks', name))
|
|
212
230
|
for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
|
|
213
231
|
return out
|
|
214
232
|
}
|
|
@@ -293,6 +311,25 @@ function writeFile(dir, target, content, { force }) {
|
|
|
293
311
|
if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
|
|
294
312
|
fs.unlinkSync(target)
|
|
295
313
|
}
|
|
314
|
+
// A LIVE symlink is the opposite case, and `--force` is what makes it
|
|
315
|
+
// dangerous: `writeFileSync` follows the link, so forcing would write composed
|
|
316
|
+
// content — seam markers resolved, provider text spliced in — straight through
|
|
317
|
+
// it and into whatever it points at. In a checkout that dogfoods its own
|
|
318
|
+
// assets that is `packages/*/assets`, i.e. the SOURCE the link exists to keep
|
|
319
|
+
// live. Refuse: the staleness `--force` was reached for is a smaller problem
|
|
320
|
+
// than corrupting the file it would overwrite.
|
|
321
|
+
//
|
|
322
|
+
// WHAT WOULD MAKE THIS LIE: a HARD link. It has no distinguishing lstat — it
|
|
323
|
+
// simply is the file — so it takes the same corrupting path and nothing here
|
|
324
|
+
// can see it. Out of scope deliberately, and said out loud rather than left to
|
|
325
|
+
// be discovered; nothing in this project's install creates one.
|
|
326
|
+
//
|
|
327
|
+
// It cannot fire in an ordinary consumer install, because nothing there is
|
|
328
|
+
// linked — `skitterspec update` writes copies by design.
|
|
329
|
+
if (force && link && link.isSymbolicLink() && fs.existsSync(target)) {
|
|
330
|
+
report.refused.push(rel(dir, target))
|
|
331
|
+
return
|
|
332
|
+
}
|
|
296
333
|
if (fs.existsSync(target)) {
|
|
297
334
|
if (!force) {
|
|
298
335
|
report.skipped.push(rel(dir, target))
|
|
@@ -353,6 +390,40 @@ function installRule(dir, opts) {
|
|
|
353
390
|
}
|
|
354
391
|
}
|
|
355
392
|
|
|
393
|
+
// Register the review-gate hook in the project's committed settings, so a
|
|
394
|
+
// phase that owes a verdict is enforced one level below the skills. Best-effort
|
|
395
|
+
// in exactly the way `trustWorktreeRoot` is: a settings file we cannot parse is
|
|
396
|
+
// reported and left alone, never rewritten, and never fatal — the hook is an
|
|
397
|
+
// extra layer, and the engine and `/spec-next` hold the gate without it.
|
|
398
|
+
function registerReviewGateHook(dir) {
|
|
399
|
+
const label = '.claude/settings.json (review-gate hook)'
|
|
400
|
+
let res
|
|
401
|
+
try {
|
|
402
|
+
res = ensureReviewGateHook(dir)
|
|
403
|
+
} catch {
|
|
404
|
+
report.warnings.push('could not write .claude/settings.json — review-gate hook not registered')
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (res.reason === 'malformed') {
|
|
408
|
+
report.warnings.push(
|
|
409
|
+
'.claude/settings.json is not valid JSON — did not register the review-gate hook',
|
|
410
|
+
)
|
|
411
|
+
} else if (res.reason === 'created') {
|
|
412
|
+
report.created.push(label)
|
|
413
|
+
} else if (res.reason === 'added') {
|
|
414
|
+
report.updated.push(label)
|
|
415
|
+
} else {
|
|
416
|
+
report.skipped.push('.claude/settings.json (review-gate hook already registered)')
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function installHooks(dir, opts) {
|
|
421
|
+
for (const name of HOOKS) {
|
|
422
|
+
copyAsset(dir, path.join('hooks', name), path.join(dir, '.claude', 'hooks', name), opts)
|
|
423
|
+
}
|
|
424
|
+
registerReviewGateHook(dir)
|
|
425
|
+
}
|
|
426
|
+
|
|
356
427
|
function installFolders(dir) {
|
|
357
428
|
for (const folder of SPEC_FOLDERS) {
|
|
358
429
|
const abs = path.join(dir, 'specs', folder)
|
|
@@ -499,6 +570,67 @@ function trustWorktreeRoot(dir) {
|
|
|
499
570
|
}
|
|
500
571
|
}
|
|
501
572
|
|
|
573
|
+
// Is the CLAUDE.md section this project has installed the one we ship?
|
|
574
|
+
//
|
|
575
|
+
// THREE answers, not two. `differs` deliberately does NOT mean "stale": the
|
|
576
|
+
// block is a COPY, so a difference is either an out-of-date copy or the user's
|
|
577
|
+
// own edit, and from here those read identically. Rule 4 of
|
|
578
|
+
// `.claude/rules/negative-checks.md` — route the unknown case to the harmless
|
|
579
|
+
// branch, which here means reporting a difference and naming the fix rather
|
|
580
|
+
// than accusing them of being behind.
|
|
581
|
+
//
|
|
582
|
+
// WHAT WOULD FOOL THIS: absent markers mean the section was never installed, OR
|
|
583
|
+
// that someone stripped it deliberately (`stripClaudeMdSection` exists and is
|
|
584
|
+
// reachable from `reset`). Neither is a fault, so both answer `not installed`
|
|
585
|
+
// and neither is reported as a problem.
|
|
586
|
+
function claudeMdSectionState(dir) {
|
|
587
|
+
const target = path.join(dir, 'CLAUDE.md')
|
|
588
|
+
if (!fs.existsSync(target)) return 'not installed'
|
|
589
|
+
const existing = fs.readFileSync(target, 'utf8')
|
|
590
|
+
if (!existing.includes(SPEC_MARKER_START) || !existing.includes(SPEC_MARKER_END)) {
|
|
591
|
+
return 'not installed'
|
|
592
|
+
}
|
|
593
|
+
const shipped = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
594
|
+
const start = existing.indexOf(SPEC_MARKER_START) + SPEC_MARKER_START.length
|
|
595
|
+
const installed = existing.slice(start, existing.indexOf(SPEC_MARKER_END)).trim()
|
|
596
|
+
return installed === shipped ? 'fresh' : 'differs'
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// `update --check`: say what `update` would change, write nothing, exit 0.
|
|
600
|
+
// It reports; `update` without the flag stays the only thing that touches a
|
|
601
|
+
// file. This exists because the section is a copy and a copy goes quietly out
|
|
602
|
+
// of date — this repo's own was a whole spec behind the template it ships,
|
|
603
|
+
// through a spec about that template, with every test green.
|
|
604
|
+
function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
|
|
605
|
+
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
606
|
+
const manifest = readManifest(dir)
|
|
607
|
+
const rows = []
|
|
608
|
+
// Mirror `resyncManagedFile`'s decision exactly rather than re-deriving it:
|
|
609
|
+
// missing → it would create; customized → it would KEEP yours and say so;
|
|
610
|
+
// pristine → it would write only when the shipped content actually differs.
|
|
611
|
+
for (const { relPath, abs, bundled } of managedTargets(dir)) {
|
|
612
|
+
const state = managedState(dir, relPath, manifest, bundled)
|
|
613
|
+
if (state === 'missing') rows.push([relPath, 'missing — would be created'])
|
|
614
|
+
else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
|
|
615
|
+
else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
|
|
616
|
+
}
|
|
617
|
+
const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
|
|
618
|
+
// A healthy area says NOTHING. A report that lists what is already fine is a
|
|
619
|
+
// report people learn to skim, and then the one line that mattered is missed.
|
|
620
|
+
if (section === 'differs') {
|
|
621
|
+
rows.push([
|
|
622
|
+
'CLAUDE.md (spec workflow section)',
|
|
623
|
+
'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)',
|
|
624
|
+
])
|
|
625
|
+
}
|
|
626
|
+
if (!rows.length) log('skitterspec update --check: everything is up to date.')
|
|
627
|
+
else {
|
|
628
|
+
log('skitterspec update --check: `skitterspec update` would:')
|
|
629
|
+
for (const [name, why] of rows) log(` ${name} — ${why}`)
|
|
630
|
+
}
|
|
631
|
+
return { rows, section }
|
|
632
|
+
}
|
|
633
|
+
|
|
502
634
|
function installClaudeMd(dir, { mode }) {
|
|
503
635
|
const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
|
|
504
636
|
const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
|
|
@@ -657,6 +789,7 @@ function reset(dir, { claudeMd = true } = {}) {
|
|
|
657
789
|
installSkills(dir, { force: true })
|
|
658
790
|
installCommands(dir, { force: true })
|
|
659
791
|
installRule(dir, { force: true })
|
|
792
|
+
installHooks(dir, { force: true })
|
|
660
793
|
installFolders(dir)
|
|
661
794
|
removeRetiredFiles(dir)
|
|
662
795
|
installCore(dir, { force: true })
|
|
@@ -681,6 +814,15 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
681
814
|
)
|
|
682
815
|
line('manifest repaired', report.healed)
|
|
683
816
|
line('unchanged', report.skipped)
|
|
817
|
+
if (report.refused.length) {
|
|
818
|
+
process.stdout.write('\nrefused (a symlink — writing would overwrite what it points at):\n')
|
|
819
|
+
for (const it of report.refused) process.stdout.write(` ${it}\n`)
|
|
820
|
+
process.stdout.write(
|
|
821
|
+
' These are links into the shipped assets. --force would follow them and\n' +
|
|
822
|
+
' write composed content into the source. Unlink one to take the copy\n' +
|
|
823
|
+
' (rm <path>, then re-run), or leave it linked and edit the asset.\n',
|
|
824
|
+
)
|
|
825
|
+
}
|
|
684
826
|
if (report.warnings.length) {
|
|
685
827
|
process.stdout.write('\nwarnings:\n')
|
|
686
828
|
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
@@ -747,6 +889,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
|
|
|
747
889
|
installSkills(dir, { force })
|
|
748
890
|
installCommands(dir, { force })
|
|
749
891
|
installRule(dir, { force })
|
|
892
|
+
installHooks(dir, { force })
|
|
750
893
|
installFolders(dir)
|
|
751
894
|
removeRetiredFiles(dir)
|
|
752
895
|
installCore(dir, { force })
|
|
@@ -764,6 +907,10 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
|
|
|
764
907
|
|
|
765
908
|
module.exports = {
|
|
766
909
|
init,
|
|
910
|
+
// A snapshot of the last run's report, for tests that need to assert on what a
|
|
911
|
+
// run DECIDED rather than only on what it left on disk. Copied, so a caller
|
|
912
|
+
// cannot mutate the live report between phases of a run.
|
|
913
|
+
lastReport: () => JSON.parse(JSON.stringify(report)),
|
|
767
914
|
SKILLS,
|
|
768
915
|
COMMANDS,
|
|
769
916
|
RULES,
|
|
@@ -774,6 +921,8 @@ module.exports = {
|
|
|
774
921
|
writeManifest,
|
|
775
922
|
managedTargets,
|
|
776
923
|
managedState,
|
|
924
|
+
claudeMdSectionState,
|
|
925
|
+
checkSync,
|
|
777
926
|
isExistingSetup,
|
|
778
927
|
resync,
|
|
779
928
|
reset,
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Reuben Greaves
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|