@skitterbyte/skitterspec 19.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 +88 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +14 -1
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +310 -24
- package/assets/rules/spec-planning.md +64 -12
- package/assets/rules/spec-reports.md +66 -14
- package/assets/skills/spec-diff/SKILL.md +122 -8
- package/assets/skills/spec-init/SKILL.md +22 -0
- package/assets/skills/spec-next/SKILL.md +144 -25
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +298 -3
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +117 -0
- package/src/env/review.js +208 -1
- package/src/env/serve.js +28 -4
- package/src/init.js +54 -0
package/src/env/review.js
CHANGED
|
@@ -231,7 +231,7 @@ function resolveReader(config, env = {}) {
|
|
|
231
231
|
* clean, not because the caller asked for it — see the fallback in
|
|
232
232
|
* `specEnvReview` (`cli.js`).
|
|
233
233
|
*/
|
|
234
|
-
function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, fellBack = false }) {
|
|
234
|
+
function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false }) {
|
|
235
235
|
const files = []
|
|
236
236
|
for (const f of trackedFiles(git, ref)) {
|
|
237
237
|
const { patch, whole } = patchFor(git, ref, f, false)
|
|
@@ -298,6 +298,9 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
|
|
|
298
298
|
// Absent stays absent, exactly as `phases` does: a spec this cannot read
|
|
299
299
|
// adds no key and the page renders its header-less self.
|
|
300
300
|
...(context ? { context } : {}),
|
|
301
|
+
// Same rule again: a gate that was never armed and never skipped adds no
|
|
302
|
+
// key at all.
|
|
303
|
+
...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
|
|
301
304
|
// WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
|
|
302
305
|
// happen per request — so a page rendered by a stale process looks entirely
|
|
303
306
|
// right: the counts move, `generatedAt` moves, the diff is correct. Only the
|
|
@@ -705,6 +708,199 @@ function claimPending(pending, code) {
|
|
|
705
708
|
return { pass, pending: { ...pending, passes: rest }, count: rest.length }
|
|
706
709
|
}
|
|
707
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Which pass arrived inside a WAIT WINDOW. Pure — it selects, never claims.
|
|
713
|
+
*
|
|
714
|
+
* THE WINDOW IS THE SCOPE, and it is what replaces "a person typed the
|
|
715
|
+
* command" when a watch wakes the session instead. A skill that asked to be
|
|
716
|
+
* woken when the store changed knows one thing nothing else does: the moment
|
|
717
|
+
* it started waiting. A pass sent after that, while it was waiting, is the
|
|
718
|
+
* pass it was waiting for — and a pass that was already there is a stranger's,
|
|
719
|
+
* or an older sitting's, and must not be swept up by a wait that was not about
|
|
720
|
+
* it.
|
|
721
|
+
*
|
|
722
|
+
* Three answers, and only one of them acts:
|
|
723
|
+
*
|
|
724
|
+
* - exactly one in the window → that is the pass, named by its code.
|
|
725
|
+
* - none → nothing to claim. Ordinary: the watch can fire on a write that was
|
|
726
|
+
* not a pass at all.
|
|
727
|
+
* - more than one → REFUSE and name the count, never pick. Two people, or two
|
|
728
|
+
* sittings, landed in one window; choosing between them is exactly the guess
|
|
729
|
+
* `claimPending` refuses to make, and the operator has the codes.
|
|
730
|
+
*
|
|
731
|
+
* A pass whose `at` cannot be parsed is never in the window. That is the
|
|
732
|
+
* cannot-tell case routed to inaction (`.claude/rules/negative-checks.md` rule
|
|
733
|
+
* 4): including it would auto-claim a pass whose age — the one tell a stranger
|
|
734
|
+
* has — could not be established.
|
|
735
|
+
*/
|
|
736
|
+
function passesSince(pending, since) {
|
|
737
|
+
const from = Date.parse(since)
|
|
738
|
+
if (!Number.isFinite(from)) return { codes: [], usable: false }
|
|
739
|
+
const codes = (pending.passes || [])
|
|
740
|
+
.filter((p) => {
|
|
741
|
+
const at = Date.parse(p.at)
|
|
742
|
+
return Number.isFinite(at) && at >= from
|
|
743
|
+
})
|
|
744
|
+
.map((p) => p.code)
|
|
745
|
+
return { codes, usable: true }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* ==========================================================================
|
|
749
|
+
* The gate — a standing obligation to review, not a message in flight
|
|
750
|
+
*
|
|
751
|
+
* A pending pass is something someone SENT. The gate is something the repo
|
|
752
|
+
* OWES: a phase ended, its page was rendered, and nobody has said what they
|
|
753
|
+
* concluded yet. Kept in its own sidecar for exactly that reason — claiming a
|
|
754
|
+
* pass consumes the pass, while only a COMMITTING verdict or a recorded skip
|
|
755
|
+
* consumes the gate, and one file holding both states would have to encode
|
|
756
|
+
* that difference anyway.
|
|
757
|
+
*
|
|
758
|
+
* It is the one thing in this engine that refuses. The marks still gate
|
|
759
|
+
* nothing and nothing counts them — what this asserts is narrower: a phase
|
|
760
|
+
* that ended is not finished until a person said something about it.
|
|
761
|
+
* ========================================================================== */
|
|
762
|
+
|
|
763
|
+
const GATE_VERSION = 1
|
|
764
|
+
|
|
765
|
+
// How a disarm happened. `verdict` is a review that reached a committing
|
|
766
|
+
// conclusion; `skip` is the operator saying, on the record, that they are
|
|
767
|
+
// moving on without one.
|
|
768
|
+
const DISARMED_BY = ['verdict', 'skip']
|
|
769
|
+
|
|
770
|
+
function reviewGatePath(outPath) {
|
|
771
|
+
return outPath.replace(/\.html$/, '') + '.gate.json'
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function emptyGate(specFolder) {
|
|
775
|
+
return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Read the gate sidecar. Never throws, and reports `corrupt` rather than
|
|
780
|
+
* hiding it — the same three states `readNotes` answers in, for the same
|
|
781
|
+
* reason. Here the third state is load-bearing in the other direction: a gate
|
|
782
|
+
* we cannot parse must never READ AS ARMED, because that would refuse a commit
|
|
783
|
+
* on the strength of a file nobody can interpret.
|
|
784
|
+
*/
|
|
785
|
+
function readGate(outPath, specFolder) {
|
|
786
|
+
let raw
|
|
787
|
+
try {
|
|
788
|
+
raw = fs.readFileSync(reviewGatePath(outPath), 'utf8')
|
|
789
|
+
} catch {
|
|
790
|
+
// Absent is the ordinary state — a project that never ends a phase through
|
|
791
|
+
// the review path has no gate file at all.
|
|
792
|
+
return { gate: emptyGate(specFolder), corrupt: false, present: false }
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
const parsed = JSON.parse(raw)
|
|
796
|
+
return {
|
|
797
|
+
gate: {
|
|
798
|
+
version: parsed.version,
|
|
799
|
+
spec: parsed.spec || specFolder,
|
|
800
|
+
armed: parsed.armed === true,
|
|
801
|
+
armedAt: parsed.armedAt || null,
|
|
802
|
+
phase: parsed.phase === undefined ? null : parsed.phase,
|
|
803
|
+
log: Array.isArray(parsed.log) ? parsed.log : [],
|
|
804
|
+
},
|
|
805
|
+
corrupt: false,
|
|
806
|
+
present: true,
|
|
807
|
+
}
|
|
808
|
+
} catch {
|
|
809
|
+
return { gate: emptyGate(specFolder), corrupt: true, present: true }
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function writeGate(outPath, gate) {
|
|
814
|
+
const p = reviewGatePath(outPath)
|
|
815
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
816
|
+
fs.writeFileSync(p, JSON.stringify(gate, null, 2) + '\n')
|
|
817
|
+
return p
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Arm the gate. Pure.
|
|
822
|
+
*
|
|
823
|
+
* IDEMPOTENT ON PURPOSE. Re-rendering a page for a phase already awaiting a
|
|
824
|
+
* verdict must not move `armedAt` — the timestamp answers "how long has this
|
|
825
|
+
* been waiting", and a render is not an event that resets that. A gate armed
|
|
826
|
+
* for a DIFFERENT phase is re-armed, because that is a new obligation.
|
|
827
|
+
*/
|
|
828
|
+
function armGate(gate, { at, phase = null }) {
|
|
829
|
+
if (gate.armed && gate.phase === phase) return gate
|
|
830
|
+
return { ...gate, armed: true, armedAt: at, phase }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Disarm it, and say how. Pure.
|
|
835
|
+
*
|
|
836
|
+
* The log is append-only and nothing reads it back to decide anything — it is
|
|
837
|
+
* the record that a decision was taken, which is the whole value of a skip
|
|
838
|
+
* over a silence. Disarming an already-clear gate logs nothing: there was no
|
|
839
|
+
* obligation, so there is no outcome to record.
|
|
840
|
+
*/
|
|
841
|
+
function disarmGate(gate, { at, by, reason = null }) {
|
|
842
|
+
if (!gate.armed) return { gate, logged: false }
|
|
843
|
+
const log = Array.isArray(gate.log) ? gate.log.slice() : []
|
|
844
|
+
log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
|
|
845
|
+
return { gate: { ...gate, armed: false, armedAt: null, phase: null, log }, logged: true }
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* What the gate says, in three states. Pure.
|
|
850
|
+
*
|
|
851
|
+
* `armed` is the only one that refuses, and it is reached only by a POSITIVE
|
|
852
|
+
* signal: a sidecar that is present, parseable, and says so
|
|
853
|
+
* (`.claude/rules/negative-checks.md` rule 1). Everything else routes to the
|
|
854
|
+
* harmless branch (rule 4) under its own name:
|
|
855
|
+
*
|
|
856
|
+
* - `clear` — read it, nothing is owed.
|
|
857
|
+
* - `unknown` — could not read it, or the project turned the gate off. Nothing
|
|
858
|
+
* is claimed and nothing refuses.
|
|
859
|
+
*
|
|
860
|
+
* WHAT WOULD FOOL THIS: a gate armed for a phase whose work has since been
|
|
861
|
+
* committed by hand still reads armed, so the refusal outlives the thing it
|
|
862
|
+
* was guarding. That is deliberate — the exit is one `skip` with a reason,
|
|
863
|
+
* which is precisely the decision this exists to put on the record — and it
|
|
864
|
+
* fails toward asking rather than toward letting a phase through unread.
|
|
865
|
+
*/
|
|
866
|
+
/**
|
|
867
|
+
* What the page is told about the gate. Pure. `null` when there is nothing to
|
|
868
|
+
* say, so a project that never armed one renders byte-identically to how it
|
|
869
|
+
* did before any of this existed — the same rule `phases` and `context` follow.
|
|
870
|
+
*
|
|
871
|
+
* The last SKIP travels with it, because that is the page's answer to the one
|
|
872
|
+
* question an untouched-looking review raises: was this read and moved past, or
|
|
873
|
+
* never read at all? A verdict already had an answer there; a skip did not.
|
|
874
|
+
*/
|
|
875
|
+
function gateForPage(gate) {
|
|
876
|
+
if (!gate) return null
|
|
877
|
+
const log = Array.isArray(gate.log) ? gate.log : []
|
|
878
|
+
const skips = log.filter((e) => e && e.by === 'skip')
|
|
879
|
+
const lastSkip = skips.length ? skips[skips.length - 1] : null
|
|
880
|
+
if (!gate.armed && !lastSkip) return null
|
|
881
|
+
return {
|
|
882
|
+
armed: gate.armed === true,
|
|
883
|
+
armedAt: gate.armedAt || null,
|
|
884
|
+
phase: gate.phase === undefined ? null : gate.phase,
|
|
885
|
+
...(lastSkip ? { lastSkip: { at: lastSkip.at || null, reason: lastSkip.reason || null } } : {}),
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function gateState({ gate, corrupt, present, required }) {
|
|
890
|
+
if (required === false) return { state: 'unknown', reason: 'review.required is false', gate }
|
|
891
|
+
if (corrupt) return { state: 'unknown', reason: 'the gate sidecar is not readable JSON', gate }
|
|
892
|
+
if (!present) return { state: 'clear', reason: 'no gate recorded', gate }
|
|
893
|
+
if (gate.version !== GATE_VERSION) {
|
|
894
|
+
return {
|
|
895
|
+
state: 'unknown',
|
|
896
|
+
reason: `gate version ${JSON.stringify(gate.version)} — this engine reads version ${GATE_VERSION}`,
|
|
897
|
+
gate,
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
|
|
901
|
+
return { state: 'armed', reason: 'a phase is awaiting a verdict', gate }
|
|
902
|
+
}
|
|
903
|
+
|
|
708
904
|
/**
|
|
709
905
|
* Validate a blob from the page, wholesale.
|
|
710
906
|
*
|
|
@@ -1289,9 +1485,20 @@ module.exports = {
|
|
|
1289
1485
|
mintPendingCode,
|
|
1290
1486
|
addPending,
|
|
1291
1487
|
claimPending,
|
|
1488
|
+
passesSince,
|
|
1292
1489
|
describePending,
|
|
1293
1490
|
pendingAge,
|
|
1294
1491
|
PENDING_CODE_LENGTH,
|
|
1492
|
+
GATE_VERSION,
|
|
1493
|
+
DISARMED_BY,
|
|
1494
|
+
reviewGatePath,
|
|
1495
|
+
emptyGate,
|
|
1496
|
+
readGate,
|
|
1497
|
+
writeGate,
|
|
1498
|
+
armGate,
|
|
1499
|
+
disarmGate,
|
|
1500
|
+
gateState,
|
|
1501
|
+
gateForPage,
|
|
1295
1502
|
mergeNotes,
|
|
1296
1503
|
applyResolutions,
|
|
1297
1504
|
applyNotes,
|
package/src/env/serve.js
CHANGED
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
* moment: true when it was taken, and overwritten by the next render. A served
|
|
9
9
|
* page cannot be out of date, because there is no artefact between the git
|
|
10
10
|
* objects and the response. That is the whole reason this exists, and it is why
|
|
11
|
-
*
|
|
12
|
-
* served path are two answers to the same question, and keeping
|
|
13
|
-
* independent is what stops one quietly becoming the other's cache.
|
|
11
|
+
* no HTML is read from or written to `.spec-env/reviews/` — the file path and
|
|
12
|
+
* the served path are two answers to the same question, and keeping the
|
|
13
|
+
* artefacts independent is what stops one quietly becoming the other's cache.
|
|
14
|
+
*
|
|
15
|
+
* THE SIDECARS ARE A DIFFERENT MATTER, and this once over-applied the rule
|
|
16
|
+
* above to them. Notes, the gate and the pending store are the REVIEW'S state,
|
|
17
|
+
* not the page's: they belong to whoever is reading, and a reader on a phone is
|
|
18
|
+
* reading this page. So they are read here (and, for a pass arriving, written)
|
|
19
|
+
* — which is what makes a refresh keep your accepts and the history line say
|
|
20
|
+
* what happened. What is never read here is a rendered page.
|
|
14
21
|
*
|
|
15
22
|
* Dependency-free, in the shape of `proxy.js`: pure functions for routing and
|
|
16
23
|
* the index, an injectable render callback, and a `require.main` entry point so
|
|
@@ -40,6 +47,8 @@ const {
|
|
|
40
47
|
readPending,
|
|
41
48
|
writePending,
|
|
42
49
|
addPending,
|
|
50
|
+
readNotes,
|
|
51
|
+
readGate,
|
|
43
52
|
} = require('./review.js')
|
|
44
53
|
|
|
45
54
|
/**
|
|
@@ -248,7 +257,20 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
|
248
257
|
mode = 'branch'
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
|
|
260
|
+
// THE REVIEW STATE IS THE READER'S, not the artefact's. It was once left out
|
|
261
|
+
// here on the reasoning that this path writes no file — but a reader on a
|
|
262
|
+
// phone is reading THIS page, and without the sidecar their own accepts
|
|
263
|
+
// vanish on every refresh and the history line never appears at all. The two
|
|
264
|
+
// surfaces answered differently about the same review, which is worse than
|
|
265
|
+
// either answer. Read-only: nothing on this path writes the sidecar.
|
|
266
|
+
const out = reviewOutPath(dir, spec.folder, null)
|
|
267
|
+
const notes = readNotes(out, spec.folder).notes
|
|
268
|
+
const gateRead = readGate(out, spec.folder)
|
|
269
|
+
// A corrupt gate contributes nothing rather than failing the render. The page
|
|
270
|
+
// is a convenience and the gate is not what it is for.
|
|
271
|
+
const gate = gateRead.corrupt ? null : gateRead.gate
|
|
272
|
+
|
|
273
|
+
let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
|
|
252
274
|
|
|
253
275
|
if (!branch && data.totals.files === 0) {
|
|
254
276
|
const fallbackBase = base()
|
|
@@ -261,6 +283,8 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
|
261
283
|
ref: mergeBase,
|
|
262
284
|
base: fallbackBase,
|
|
263
285
|
now,
|
|
286
|
+
notes,
|
|
287
|
+
gate,
|
|
264
288
|
fellBack: true,
|
|
265
289
|
})
|
|
266
290
|
if (wider.totals.files > 0) {
|
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
|
|
@@ -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
|
}
|
|
@@ -372,6 +390,40 @@ function installRule(dir, opts) {
|
|
|
372
390
|
}
|
|
373
391
|
}
|
|
374
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
|
+
|
|
375
427
|
function installFolders(dir) {
|
|
376
428
|
for (const folder of SPEC_FOLDERS) {
|
|
377
429
|
const abs = path.join(dir, 'specs', folder)
|
|
@@ -737,6 +789,7 @@ function reset(dir, { claudeMd = true } = {}) {
|
|
|
737
789
|
installSkills(dir, { force: true })
|
|
738
790
|
installCommands(dir, { force: true })
|
|
739
791
|
installRule(dir, { force: true })
|
|
792
|
+
installHooks(dir, { force: true })
|
|
740
793
|
installFolders(dir)
|
|
741
794
|
removeRetiredFiles(dir)
|
|
742
795
|
installCore(dir, { force: true })
|
|
@@ -836,6 +889,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
|
|
|
836
889
|
installSkills(dir, { force })
|
|
837
890
|
installCommands(dir, { force })
|
|
838
891
|
installRule(dir, { force })
|
|
892
|
+
installHooks(dir, { force })
|
|
839
893
|
installFolders(dir)
|
|
840
894
|
removeRetiredFiles(dir)
|
|
841
895
|
installCore(dir, { force })
|