@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.
- 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 +50 -9
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +135 -36
- package/assets/skills/spec-bug/SKILL.md +5 -0
- 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 +33 -1
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +47 -2
- 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 +702 -59
- 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 +92 -3
- 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/config.js
CHANGED
|
@@ -187,7 +187,16 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
187
187
|
spec: Object.freeze({ companionPaths: Object.freeze([]) }),
|
|
188
188
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
189
189
|
baseBranch: '',
|
|
190
|
-
|
|
190
|
+
// `mainIsLandingZone` defaults ON wherever isolation is configured, matching
|
|
191
|
+
// the review gate's precedent: the push toward not working on the base branch
|
|
192
|
+
// is the normal path, and stepping off it is the deliberate act. That does
|
|
193
|
+
// change behaviour for a project on upgrade, which is the honest cost — the
|
|
194
|
+
// refusal names both exits (`/no-spec` and `/allow-main`) and this key.
|
|
195
|
+
guards: Object.freeze({
|
|
196
|
+
refuseTeardownIfDirty: true,
|
|
197
|
+
refuseTeardownIfUnpushed: true,
|
|
198
|
+
mainIsLandingZone: true,
|
|
199
|
+
}),
|
|
191
200
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
192
201
|
// `spec-env down` does about a branch the USER published by hand — nothing
|
|
193
202
|
// publishes one at provisioning, so there is often no remote ref at all and
|
|
@@ -251,6 +260,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
251
260
|
allowRemote: false,
|
|
252
261
|
commitWith: '/commit',
|
|
253
262
|
required: true,
|
|
263
|
+
// External code reviewers whose findings render as CHECKS on the page.
|
|
264
|
+
// Empty by default and never written by `init`: configuring one sends the
|
|
265
|
+
// worktree's diff to whatever the command talks to, which is a decision
|
|
266
|
+
// only the project can make. Two entry shapes — `{use}` names a bundled
|
|
267
|
+
// adapter, `{name, command, format}` is bring-your-own.
|
|
268
|
+
reviewers: Object.freeze([]),
|
|
254
269
|
}),
|
|
255
270
|
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
256
271
|
// migration files; a branch that changes any of them is treated as stateful and
|
|
@@ -325,7 +340,7 @@ function defaults() {
|
|
|
325
340
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
326
341
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
327
342
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
328
|
-
review: { ...DEFAULT_CONFIG.review },
|
|
343
|
+
review: { ...DEFAULT_CONFIG.review, reviewers: [] },
|
|
329
344
|
live: { migrations: [] },
|
|
330
345
|
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
331
346
|
}
|
|
@@ -382,6 +397,89 @@ function normalizeFileList(parsed) {
|
|
|
382
397
|
return out
|
|
383
398
|
}
|
|
384
399
|
|
|
400
|
+
// How long a reviewer gets before it is killed, in seconds. A generous default:
|
|
401
|
+
// these tools take 30s-3min, and the render they precede is about to wait on a
|
|
402
|
+
// human for far longer.
|
|
403
|
+
const DEFAULT_REVIEWER_TIMEOUT = 180
|
|
404
|
+
|
|
405
|
+
// Output formats the runner can parse. `rdjsonl` is the native contract
|
|
406
|
+
// (reviewdog's interchange shape); a bundled adapter brings its own parser and
|
|
407
|
+
// declares no format at all.
|
|
408
|
+
const REVIEWER_FORMATS = ['rdjsonl']
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Read one `review.reviewers` entry. Returns the normalised entry, or `null`
|
|
412
|
+
* with a reason — the caller decides whether to keep it or report it.
|
|
413
|
+
*
|
|
414
|
+
* Two shapes, and they are told apart by which key is present rather than by
|
|
415
|
+
* guessing: `{use}` names a bundled adapter (the engine owns its command line
|
|
416
|
+
* and its parser), `{name, command}` is bring-your-own. An entry carrying both
|
|
417
|
+
* is refused BY NAME rather than resolved in some order — it is two
|
|
418
|
+
* instructions, and picking one would run a reviewer the author did not ask for.
|
|
419
|
+
*
|
|
420
|
+
* WHETHER A NAMED ADAPTER EXISTS IS NOT CHECKED HERE, deliberately: the adapter
|
|
421
|
+
* registry lives in the runner, and this module must not depend on it to answer
|
|
422
|
+
* a question about shape. The runner reports an unknown `use` as its own
|
|
423
|
+
* outcome.
|
|
424
|
+
*/
|
|
425
|
+
function readReviewer(raw) {
|
|
426
|
+
if (!isObject(raw)) return { entry: null, reason: 'not an object' }
|
|
427
|
+
const use = typeof raw.use === 'string' ? raw.use.trim() : ''
|
|
428
|
+
const command = typeof raw.command === 'string' ? raw.command.trim() : ''
|
|
429
|
+
if (use && command) return { entry: null, reason: 'carries both use and command — write one' }
|
|
430
|
+
// A number that is not finite and positive is not a timeout. Falling through
|
|
431
|
+
// to the default is safe in a way `0` would not be: a zero timeout kills every
|
|
432
|
+
// reviewer instantly and would read, on the page, as one that never ran.
|
|
433
|
+
const t = raw.timeout
|
|
434
|
+
const timeout = typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULT_REVIEWER_TIMEOUT
|
|
435
|
+
if (use) {
|
|
436
|
+
const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : use
|
|
437
|
+
return { entry: { name, use, command: null, format: null, timeout }, reason: null }
|
|
438
|
+
}
|
|
439
|
+
if (!command) return { entry: null, reason: 'has neither use nor command' }
|
|
440
|
+
const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : ''
|
|
441
|
+
if (!name) return { entry: null, reason: 'has a command but no name' }
|
|
442
|
+
// An unrecognised format is refused rather than defaulted to `rdjsonl`. The
|
|
443
|
+
// scalars above can fall through because their default is what the author
|
|
444
|
+
// most likely meant; here it is not — parsing one tool's output as another's
|
|
445
|
+
// shape yields zero findings and reports the reviewer as clean, which is the
|
|
446
|
+
// false clean this whole feature is built to avoid.
|
|
447
|
+
const fmt = raw.format === undefined || raw.format === null ? 'rdjsonl' : raw.format
|
|
448
|
+
if (!REVIEWER_FORMATS.includes(fmt)) {
|
|
449
|
+
return { entry: null, reason: `format ${JSON.stringify(raw.format)} is not one of ${REVIEWER_FORMATS.join(', ')}` }
|
|
450
|
+
}
|
|
451
|
+
return { entry: { name, use: null, command, format: fmt, timeout }, reason: null }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function normalizeReviewers(parsed) {
|
|
455
|
+
const out = []
|
|
456
|
+
for (const raw of parsed) {
|
|
457
|
+
const { entry } = readReviewer(raw)
|
|
458
|
+
if (entry) out.push(entry)
|
|
459
|
+
}
|
|
460
|
+
return out
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* List the `review.reviewers` entries the merge dropped, as
|
|
465
|
+
* `{ index, reason }`. Advisory, like `collectUnknownKeys` — nothing refuses.
|
|
466
|
+
*
|
|
467
|
+
* It exists because `collectUnknownKeys` structurally cannot cover this: it
|
|
468
|
+
* descends only where the DEFAULT is a plain object, and this default is an
|
|
469
|
+
* array. Without this, a reviewer entry with a typo in it is dropped in exactly
|
|
470
|
+
* the silence that check was added to end — and worse than an ignored key,
|
|
471
|
+
* because the page would then show no line for it at all.
|
|
472
|
+
*/
|
|
473
|
+
function collectBadReviewers(parsed) {
|
|
474
|
+
if (!isObject(parsed) || !isObject(parsed.review) || !Array.isArray(parsed.review.reviewers)) return []
|
|
475
|
+
const out = []
|
|
476
|
+
parsed.review.reviewers.forEach((raw, index) => {
|
|
477
|
+
const { entry, reason } = readReviewer(raw)
|
|
478
|
+
if (!entry) out.push({ index, reason })
|
|
479
|
+
})
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
|
|
385
483
|
/**
|
|
386
484
|
* Normalise a parsed `seedFiles` value into `{ mode, files }`. Accepts two forms:
|
|
387
485
|
* - the array shorthand `[".env", ...]` → { mode: 'symlink', files: [...] }
|
|
@@ -473,6 +571,7 @@ function mergeConfig(base, parsed) {
|
|
|
473
571
|
if (isObject(parsed.guards)) {
|
|
474
572
|
assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
|
|
475
573
|
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
|
574
|
+
assign(base.guards, parsed.guards, 'mainIsLandingZone', 'boolean')
|
|
476
575
|
}
|
|
477
576
|
|
|
478
577
|
// An unrecognised policy falls through to the default rather than erroring or
|
|
@@ -539,6 +638,15 @@ function mergeConfig(base, parsed) {
|
|
|
539
638
|
// a typo cannot quietly widen a bind or permit a publish.
|
|
540
639
|
assign(base.review, parsed.review, 'allowNetwork', 'boolean')
|
|
541
640
|
assign(base.review, parsed.review, 'allowRemote', 'boolean')
|
|
641
|
+
// An entry that is neither shape is DROPPED rather than refused, like every
|
|
642
|
+
// other value here — but unlike the scalars above there is no default for it
|
|
643
|
+
// to fall through to, so a dropped entry is a reviewer that silently never
|
|
644
|
+
// runs. `collectUnknownKeys` cannot see it either: the default is an array,
|
|
645
|
+
// and it deliberately does not walk into one. So the dropping is reported
|
|
646
|
+
// separately, by `collectBadReviewers` below.
|
|
647
|
+
if (Array.isArray(parsed.review.reviewers)) {
|
|
648
|
+
base.review.reviewers = normalizeReviewers(parsed.review.reviewers)
|
|
649
|
+
}
|
|
542
650
|
}
|
|
543
651
|
|
|
544
652
|
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
@@ -580,7 +688,7 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
580
688
|
try {
|
|
581
689
|
raw = readFileSync(file, 'utf-8')
|
|
582
690
|
} catch (error) {
|
|
583
|
-
if (error.code === 'ENOENT') return { config: base, present: false, unknown: [] }
|
|
691
|
+
if (error.code === 'ENOENT') return { config: base, present: false, unknown: [], badReviewers: [] }
|
|
584
692
|
throw error
|
|
585
693
|
}
|
|
586
694
|
|
|
@@ -591,7 +699,12 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
591
699
|
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
592
700
|
}
|
|
593
701
|
|
|
594
|
-
return {
|
|
702
|
+
return {
|
|
703
|
+
config: mergeConfig(base, parsed),
|
|
704
|
+
present: true,
|
|
705
|
+
unknown: collectUnknownKeys(parsed),
|
|
706
|
+
badReviewers: collectBadReviewers(parsed),
|
|
707
|
+
}
|
|
595
708
|
}
|
|
596
709
|
|
|
597
710
|
module.exports = {
|
|
@@ -603,7 +716,11 @@ module.exports = {
|
|
|
603
716
|
PORT_BASE,
|
|
604
717
|
PORT_SPAN,
|
|
605
718
|
collectUnknownKeys,
|
|
719
|
+
collectBadReviewers,
|
|
720
|
+
readReviewer,
|
|
606
721
|
mergeConfig,
|
|
607
722
|
DEFAULT_CONFIG,
|
|
723
|
+
DEFAULT_REVIEWER_TIMEOUT,
|
|
724
|
+
REVIEWER_FORMATS,
|
|
608
725
|
CONFIG_FILE,
|
|
609
726
|
}
|
package/src/env/hooks.js
CHANGED
|
@@ -39,6 +39,41 @@ const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(
|
|
|
39
39
|
const HOOK_TIMEOUT = 10
|
|
40
40
|
const MATCHER = 'Bash'
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Every hook this package registers, as data.
|
|
44
|
+
*
|
|
45
|
+
* ONE LIST, so a second hook cannot arrive as a second copy of the merge logic
|
|
46
|
+
* below — which is where every property this file has earned would have to be
|
|
47
|
+
* re-derived and one of them forgotten. Each entry supplies only what differs:
|
|
48
|
+
* the script's stem, the tools it matches, and its timeout.
|
|
49
|
+
*
|
|
50
|
+
* The `re` for each is built from its stem with the same boundary lookahead the
|
|
51
|
+
* review gate's has, so `main-guard-extra.js` — somebody else's hook — is not
|
|
52
|
+
* silently rewritten as ours.
|
|
53
|
+
*/
|
|
54
|
+
function hookStemRe(stem) {
|
|
55
|
+
const name = stem.split('/').pop()
|
|
56
|
+
return new RegExp(`[.]claude[/\\\\]hooks[/\\\\]${name}(?:[.][A-Za-z0-9]+)?(?![\\w.-])`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HOOKS = [
|
|
60
|
+
{
|
|
61
|
+
key: 'review-gate',
|
|
62
|
+
stem: HOOK_STEM,
|
|
63
|
+
matcher: MATCHER,
|
|
64
|
+
timeout: HOOK_TIMEOUT,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// Refuses an Edit/Write in the primary checkout while it is on the base
|
|
68
|
+
// branch. A different matcher from the review gate's, because it guards a
|
|
69
|
+
// different moment: the first write, before any work exists.
|
|
70
|
+
key: 'main-guard',
|
|
71
|
+
stem: '.claude/hooks/main-guard',
|
|
72
|
+
matcher: 'Edit|Write|NotebookEdit|MultiEdit',
|
|
73
|
+
timeout: HOOK_TIMEOUT,
|
|
74
|
+
},
|
|
75
|
+
].map((h) => ({ ...h, script: `${h.stem}.cjs`, re: hookStemRe(h.stem) }))
|
|
76
|
+
|
|
42
77
|
function isObject(value) {
|
|
43
78
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
79
|
}
|
|
@@ -52,10 +87,14 @@ function writeSettings(file, settings) {
|
|
|
52
87
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
|
|
53
88
|
}
|
|
54
89
|
|
|
55
|
-
function
|
|
90
|
+
function commandFor(script) {
|
|
91
|
+
return `node "\${CLAUDE_PROJECT_DIR}/${script}"`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hookEntry(hook = HOOKS[0]) {
|
|
56
95
|
return {
|
|
57
|
-
matcher:
|
|
58
|
-
hooks: [{ type: 'command', command:
|
|
96
|
+
matcher: hook.matcher,
|
|
97
|
+
hooks: [{ type: 'command', command: commandFor(hook.script), timeout: hook.timeout }],
|
|
59
98
|
}
|
|
60
99
|
}
|
|
61
100
|
|
|
@@ -67,34 +106,72 @@ function hookEntry() {
|
|
|
67
106
|
* registered our hook their way — re-adding a second copy beside theirs would
|
|
68
107
|
* run it twice and look like a bug in the gate.
|
|
69
108
|
*/
|
|
70
|
-
function alreadyRegistered(preToolUse) {
|
|
71
|
-
return registeredHooks(preToolUse).length > 0
|
|
109
|
+
function alreadyRegistered(preToolUse, hook = HOOKS[0]) {
|
|
110
|
+
return registeredHooks(preToolUse, hook).length > 0
|
|
72
111
|
}
|
|
73
112
|
|
|
74
113
|
// Every hook object under `PreToolUse` whose command names our script, whatever
|
|
75
114
|
// extension it names it under. Pure.
|
|
76
|
-
function registeredHooks(preToolUse) {
|
|
115
|
+
function registeredHooks(preToolUse, hook = HOOKS[0]) {
|
|
77
116
|
if (!Array.isArray(preToolUse)) return []
|
|
78
117
|
const out = []
|
|
79
118
|
for (const group of preToolUse) {
|
|
80
119
|
if (!isObject(group) || !Array.isArray(group.hooks)) continue
|
|
81
120
|
for (const h of group.hooks) {
|
|
82
|
-
if (isObject(h) && typeof h.command === 'string' &&
|
|
121
|
+
if (isObject(h) && typeof h.command === 'string' && hook.re.test(h.command)) out.push(h)
|
|
83
122
|
}
|
|
84
123
|
}
|
|
85
124
|
return out
|
|
86
125
|
}
|
|
87
126
|
|
|
88
127
|
/**
|
|
89
|
-
* Ensure
|
|
90
|
-
*
|
|
128
|
+
* Ensure ONE hook is registered in an already-parsed settings object.
|
|
129
|
+
* Mutates `parsed` in place when it migrates a stale path; returns
|
|
130
|
+
* `{ changed, reason }` with the same vocabulary as the wrapper below.
|
|
131
|
+
*
|
|
132
|
+
* Split out so a second hook could be added without a second copy of this
|
|
133
|
+
* logic — which is where every property this file has earned (preserve unknown
|
|
134
|
+
* keys, match on the script PATH, migrate in place, stay idempotent) would have
|
|
135
|
+
* had to be re-derived, and one of them forgotten.
|
|
136
|
+
*/
|
|
137
|
+
function ensureOneHook(parsed, hook) {
|
|
138
|
+
const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
|
|
139
|
+
const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
|
|
140
|
+
|
|
141
|
+
// Registered already — but possibly under a path that no longer exists, since
|
|
142
|
+
// the script's extension changed. Rewrite the path IN PLACE rather than
|
|
143
|
+
// replacing the command: an operator who wrapped our hook, added a flag or
|
|
144
|
+
// changed the interpreter has registered it their way, and the thing that
|
|
145
|
+
// moved is the file, not their command. Adding a fresh entry beside theirs
|
|
146
|
+
// would run the gate twice and read as a bug in the gate; leaving the old one
|
|
147
|
+
// alone would aim the harness at a file this same upgrade retires.
|
|
148
|
+
const registered = registeredHooks(preToolUse, hook)
|
|
149
|
+
if (registered.length) {
|
|
150
|
+
const stale = registered.filter((h) => !h.command.includes(hook.script))
|
|
151
|
+
if (!stale.length) return { changed: false, reason: 'present' }
|
|
152
|
+
for (const h of stale) h.command = h.command.replace(hook.re, hook.script)
|
|
153
|
+
return { changed: true, reason: 'migrated' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
parsed.hooks = { ...hooks, PreToolUse: [...preToolUse, hookEntry(hook)] }
|
|
157
|
+
return { changed: true, reason: 'added' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ensure every hook this package ships is registered in `dir`'s project
|
|
162
|
+
* settings. Idempotent and non-destructive. Returns `{ changed, reason }`:
|
|
91
163
|
* - `created` — no settings file; one was written
|
|
92
164
|
* - `added` — merged into an existing file
|
|
93
165
|
* - `migrated` — registered under a retired path; the path was rewritten
|
|
94
166
|
* - `present` — already registered (no write)
|
|
95
167
|
* - `malformed` — the file exists but is not parseable JSON (left untouched)
|
|
168
|
+
*
|
|
169
|
+
* WITH SEVERAL HOOKS THE REASON IS THE STRONGEST ONE that applied, in the order
|
|
170
|
+
* added > migrated > present. A run that added one hook and found the other
|
|
171
|
+
* already there did change the file, and reporting `present` for it would tell
|
|
172
|
+
* an operator nothing happened when something did.
|
|
96
173
|
*/
|
|
97
|
-
function
|
|
174
|
+
function ensureHooks(dir) {
|
|
98
175
|
const file = settingsPath(dir)
|
|
99
176
|
|
|
100
177
|
let raw
|
|
@@ -102,7 +179,7 @@ function ensureReviewGateHook(dir) {
|
|
|
102
179
|
raw = fs.readFileSync(file, 'utf-8')
|
|
103
180
|
} catch (error) {
|
|
104
181
|
if (error.code === 'ENOENT') {
|
|
105
|
-
writeSettings(file, { hooks: { PreToolUse:
|
|
182
|
+
writeSettings(file, { hooks: { PreToolUse: HOOKS.map((h) => hookEntry(h)) } })
|
|
106
183
|
return { changed: true, reason: 'created' }
|
|
107
184
|
}
|
|
108
185
|
throw error
|
|
@@ -118,38 +195,30 @@ function ensureReviewGateHook(dir) {
|
|
|
118
195
|
}
|
|
119
196
|
if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
|
|
120
197
|
|
|
121
|
-
const
|
|
122
|
-
const
|
|
198
|
+
const reasons = HOOKS.map((h) => ensureOneHook(parsed, h))
|
|
199
|
+
const changed = reasons.some((r) => r.changed)
|
|
200
|
+
if (changed) writeSettings(file, parsed)
|
|
123
201
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// replacing the command: an operator who wrapped our hook, added a flag or
|
|
127
|
-
// changed the interpreter has registered it their way, and the thing that
|
|
128
|
-
// moved is the file, not their command. Adding a fresh entry beside theirs
|
|
129
|
-
// would run the gate twice and read as a bug in the gate; leaving the old one
|
|
130
|
-
// alone would aim the harness at a file this same upgrade retires.
|
|
131
|
-
const registered = registeredHooks(preToolUse)
|
|
132
|
-
if (registered.length) {
|
|
133
|
-
const stale = registered.filter((h) => !h.command.includes(HOOK_SCRIPT))
|
|
134
|
-
if (!stale.length) return { changed: false, reason: 'present' }
|
|
135
|
-
for (const h of stale) h.command = h.command.replace(HOOK_SCRIPT_RE, HOOK_SCRIPT)
|
|
136
|
-
writeSettings(file, parsed)
|
|
137
|
-
return { changed: true, reason: 'migrated' }
|
|
202
|
+
for (const rank of ['added', 'migrated', 'present']) {
|
|
203
|
+
if (reasons.some((r) => r.reason === rank)) return { changed, reason: rank }
|
|
138
204
|
}
|
|
139
|
-
|
|
140
|
-
writeSettings(file, {
|
|
141
|
-
...parsed,
|
|
142
|
-
hooks: { ...hooks, PreToolUse: [...preToolUse, hookEntry()] },
|
|
143
|
-
})
|
|
144
|
-
return { changed: true, reason: 'added' }
|
|
205
|
+
return { changed, reason: 'present' }
|
|
145
206
|
}
|
|
146
207
|
|
|
208
|
+
// The name this shipped under when there was one hook. Kept as an alias rather
|
|
209
|
+
// than renamed away, because it is what an installed `init.js` from an older
|
|
210
|
+
// version calls — and a rename that breaks an upgrade path is not a rename, it
|
|
211
|
+
// is a removal.
|
|
212
|
+
const ensureReviewGateHook = ensureHooks
|
|
213
|
+
|
|
147
214
|
module.exports = {
|
|
215
|
+
ensureHooks,
|
|
148
216
|
ensureReviewGateHook,
|
|
149
217
|
alreadyRegistered,
|
|
150
218
|
registeredHooks,
|
|
151
219
|
hookEntry,
|
|
152
220
|
settingsPath,
|
|
221
|
+
HOOKS,
|
|
153
222
|
HOOK_STEM,
|
|
154
223
|
HOOK_SCRIPT,
|
|
155
224
|
HOOK_SCRIPT_RE,
|
package/src/env/live.js
CHANGED
|
@@ -256,7 +256,27 @@ function planTake(spec, config, ctx) {
|
|
|
256
256
|
branch,
|
|
257
257
|
worktreePath: spec.worktreePath,
|
|
258
258
|
}
|
|
259
|
-
|
|
259
|
+
/**
|
|
260
|
+
* A refusal, and — only where one exists — the way out of it.
|
|
261
|
+
*
|
|
262
|
+
* AN OFFER IS DECLARED HERE OR NOWHERE. A caller that raises a picker from a
|
|
263
|
+
* refusal is reading this field, never re-deciding from the reason text: the
|
|
264
|
+
* one boundary that matters is that work which is not the operator's must
|
|
265
|
+
* never be offered up (a workbench another spec holds is theirs to free), and
|
|
266
|
+
* a rule in prose is not what should be holding that line.
|
|
267
|
+
*
|
|
268
|
+
* ABSENT STAYS ABSENT. A refusal with no way out carries no `offer` key at
|
|
269
|
+
* all, so every existing consumer sees exactly the shape it saw before.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} reason what is wrong, unchanged
|
|
272
|
+
* @param {object} [offer] { kind: 'satisfy'|'bypass', label, command }
|
|
273
|
+
*/
|
|
274
|
+
const block = (reason, offer) => ({
|
|
275
|
+
...result,
|
|
276
|
+
blocked: true,
|
|
277
|
+
reason,
|
|
278
|
+
...(offer ? { offer } : {}),
|
|
279
|
+
})
|
|
260
280
|
|
|
261
281
|
// 1. The lock: the primary checkout must be on base (free). Off-base → a spec
|
|
262
282
|
// (or you) already holds the live instance.
|
|
@@ -293,6 +313,15 @@ function planTake(spec, config, ctx) {
|
|
|
293
313
|
return block(
|
|
294
314
|
`${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
|
|
295
315
|
`${spec.worktreePath} first (the rebase cannot run over them)`,
|
|
316
|
+
// SATISFYING THE GUARD, not stepping past it. Committing the phase is the
|
|
317
|
+
// precondition `live take` is asking for, so doing it weakens nothing and
|
|
318
|
+
// needs no record — which is what separates this from a `bypass`.
|
|
319
|
+
//
|
|
320
|
+
// Offered for the WORKTREE's tree and never for the primary checkout's
|
|
321
|
+
// (check 2 above): that one holds whatever the operator happens to have
|
|
322
|
+
// open, not this spec's phase work, and committing it under this spec's
|
|
323
|
+
// ticket is the mis-stamping `commit-trailers.md` exists to prevent.
|
|
324
|
+
{ kind: 'satisfy', label: 'Commit first, then go live', command: '/commit' },
|
|
296
325
|
)
|
|
297
326
|
}
|
|
298
327
|
// 4. A hotfix is built on an old release tag; checking its branch out under the
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The main guard — is this write about to land on the base branch?
|
|
5
|
+
*
|
|
6
|
+
* `main` is where work **lands**, not where it happens. Two routes reach it
|
|
7
|
+
* otherwise, and both cost someone else something: a dirty base branch that
|
|
8
|
+
* every in-flight spec has to replay over and no release can be cut through,
|
|
9
|
+
* and — worse — work that renders no review page at all, because nothing in
|
|
10
|
+
* this workflow renders one for the primary checkout.
|
|
11
|
+
*
|
|
12
|
+
* Both routes now have somewhere to go: `/spec` authors in its own `--docs`
|
|
13
|
+
* worktree, and `/no-spec` gives mechanical work a branch of its own. So the
|
|
14
|
+
* guard is a push toward a path that exists rather than a refusal with no exit.
|
|
15
|
+
*
|
|
16
|
+
* THE ACCUSATION IS POSITIVE, AND ALL FOUR PARTS MUST HOLD. It is
|
|
17
|
+
* `.claude/rules/negative-checks.md` rule 1 applied to a check that stops
|
|
18
|
+
* someone working: isolation is configured, the guard is not switched off, this
|
|
19
|
+
* directory IS the primary checkout, and HEAD IS the base branch. Anything the
|
|
20
|
+
* engine cannot establish — no repo, no config, an unresolvable base, a
|
|
21
|
+
* worktree, an unreadable allow file — allows the write and says nothing.
|
|
22
|
+
* Being wrong the permissive way costs one unguarded edit on a rule the skills
|
|
23
|
+
* also carry; being wrong the other way costs someone their work with no idea
|
|
24
|
+
* why.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs')
|
|
28
|
+
const path = require('node:path')
|
|
29
|
+
|
|
30
|
+
const { assertPrimaryOnMain, resolvePrimaryCheckout } = require('./resolve.js')
|
|
31
|
+
|
|
32
|
+
// Where the allow lives. Gitignored, machine-local and beside the registry —
|
|
33
|
+
// this is one person's decision about one session, never the project's policy.
|
|
34
|
+
const ALLOW_FILE = 'main-allow.json'
|
|
35
|
+
|
|
36
|
+
function allowPath(rootDir, config) {
|
|
37
|
+
return path.resolve(rootDir, path.dirname(config.registry), ALLOW_FILE)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read the recorded allow. Pure but for the read.
|
|
42
|
+
*
|
|
43
|
+
* MISSING AND MALFORMED BOTH READ AS "NO ALLOW", which is the *strict* answer
|
|
44
|
+
* here rather than the lenient one — and that is the right way round only
|
|
45
|
+
* because the strict answer is also the safe one. An unreadable allow file
|
|
46
|
+
* makes the guard fire, and firing costs a refusal naming two exits; silently
|
|
47
|
+
* treating it as permission would let a stale file switch the guard off
|
|
48
|
+
* forever.
|
|
49
|
+
*/
|
|
50
|
+
function readAllow(rootDir, config) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(allowPath(rootDir, config), 'utf-8'))
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
54
|
+
return parsed
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeAllow(rootDir, config, value) {
|
|
61
|
+
const file = allowPath(rootDir, config)
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearAllow(rootDir, config) {
|
|
67
|
+
try {
|
|
68
|
+
fs.unlinkSync(allowPath(rootDir, config))
|
|
69
|
+
return true
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Does `allow` permit a write from `sessionId`? Pure.
|
|
77
|
+
*
|
|
78
|
+
* TWO KINDS, AND THEY MUST NOT LOOK ALIKE. A **session** allow names the
|
|
79
|
+
* session that asked for it, so it lapses the moment that conversation ends —
|
|
80
|
+
* which is what "temporary" has to mean for something nobody will remember to
|
|
81
|
+
* switch back. A **repo** allow is the degraded form for a harness that exposes
|
|
82
|
+
* no session id; it outlives the session and is cleared only by hand, so
|
|
83
|
+
* `mainGuardStatus` says which kind is in force rather than reporting a bare
|
|
84
|
+
* "allowed".
|
|
85
|
+
*
|
|
86
|
+
* A session allow with no session id to compare against does NOT apply. The
|
|
87
|
+
* alternative — treating "cannot tell whose session this is" as a match — would
|
|
88
|
+
* turn every session allow into a repo one the moment a caller forgot to pass
|
|
89
|
+
* the id.
|
|
90
|
+
*/
|
|
91
|
+
function allowApplies(allow, sessionId) {
|
|
92
|
+
if (!allow) return false
|
|
93
|
+
if (allow.scope === 'repo') return true
|
|
94
|
+
if (allow.scope === 'session') return Boolean(sessionId) && allow.session === sessionId
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Should a write in `dir` be refused? Pure — every fact arrives in `ctx`.
|
|
100
|
+
*
|
|
101
|
+
* ctx: { configured, enabled, isPrimary, onBase, allow, sessionId }
|
|
102
|
+
*
|
|
103
|
+
* Returns `{ refuse, reason }`. `reason` is for the operator and names both
|
|
104
|
+
* exits; `refuse: false` always carries a null reason, because a guard that
|
|
105
|
+
* explains why it is NOT firing is the narration this codebase bans.
|
|
106
|
+
*
|
|
107
|
+
* WHAT WOULD FOOL THIS: nothing here inspects the *file* being written. A write
|
|
108
|
+
* to `specs/`, to `.claude/`, to a scratch file — all refused alike, and that is
|
|
109
|
+
* the decision rather than an oversight. An allowlist was considered and is
|
|
110
|
+
* empty by construction: with `/spec` authoring into its own worktree and
|
|
111
|
+
* `/no-spec` catching ad-hoc work, no legitimate writer to the base branch is
|
|
112
|
+
* left. `/spec-init` is the apparent exception and is not one — it bootstraps a
|
|
113
|
+
* repo that has no `env.config.json` yet, so `configured` is false and the
|
|
114
|
+
* guard never reaches this function.
|
|
115
|
+
*/
|
|
116
|
+
function judgeMainWrite(ctx) {
|
|
117
|
+
const { configured, enabled, isPrimary, onBase, allow, sessionId } = ctx || {}
|
|
118
|
+
|
|
119
|
+
// Every one of these is a CANNOT-TELL or a NOT-APPLICABLE, and each routes to
|
|
120
|
+
// the harmless branch on its own rather than through a combined condition —
|
|
121
|
+
// so a later reader can delete one without silently widening the others.
|
|
122
|
+
if (!configured) return { refuse: false, reason: null }
|
|
123
|
+
if (enabled === false) return { refuse: false, reason: null }
|
|
124
|
+
if (!isPrimary) return { refuse: false, reason: null }
|
|
125
|
+
if (!onBase) return { refuse: false, reason: null }
|
|
126
|
+
if (allowApplies(allow, sessionId)) return { refuse: false, reason: null }
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
refuse: true,
|
|
130
|
+
reason:
|
|
131
|
+
'the primary checkout is on the base branch, and the base branch is a landing zone — ' +
|
|
132
|
+
'work lands there, it does not happen there.\n\n' +
|
|
133
|
+
'Move the work instead of writing here:\n' +
|
|
134
|
+
' /no-spec <name> a one-off with no spec — its own branch, worktree and review page\n' +
|
|
135
|
+
' /spec-start <name> a spec that is ready to be built\n' +
|
|
136
|
+
' /spec a change that wants grilling first\n\n' +
|
|
137
|
+
'If this really does belong on the base branch, the USER types /allow-main — ' +
|
|
138
|
+
'it is theirs to run, not yours.',
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Gather the facts and judge. The only IO in the module's hot path.
|
|
144
|
+
*
|
|
145
|
+
* `git` is an injected reader, as everywhere in this package: the caller owns
|
|
146
|
+
* the child-process boundary, which is what keeps `judgeMainWrite` testable
|
|
147
|
+
* without one.
|
|
148
|
+
*/
|
|
149
|
+
function checkMainWrite(dir, config, git, opts = {}) {
|
|
150
|
+
// `present` from `loadEnvConfig` — the caller has it and this module must not
|
|
151
|
+
// re-read the file to find out. A defaults-only config is indistinguishable
|
|
152
|
+
// from a configured one that happens to match the defaults, so asking the
|
|
153
|
+
// config object would answer the wrong question.
|
|
154
|
+
const configured = opts.present === true
|
|
155
|
+
const enabled = !(config && config.guards && config.guards.mainIsLandingZone === false)
|
|
156
|
+
|
|
157
|
+
// IS THIS THE PRIMARY CHECKOUT? A worktree is somebody's spec branch and none
|
|
158
|
+
// of this guard's business, so the question is asked positively rather than by
|
|
159
|
+
// looking for the absence of a worktree marker.
|
|
160
|
+
let isPrimary = false
|
|
161
|
+
let primary = null
|
|
162
|
+
try {
|
|
163
|
+
primary = resolvePrimaryCheckout(dir, git)
|
|
164
|
+
isPrimary = Boolean(primary) && path.resolve(primary) === path.resolve(dir)
|
|
165
|
+
} catch {
|
|
166
|
+
// Not a git repo, or git unreadable. Cannot tell → allow.
|
|
167
|
+
return { refuse: false, reason: null, branch: null, baseBranch: null }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let onBase = false
|
|
171
|
+
let branch = null
|
|
172
|
+
let baseBranch = null
|
|
173
|
+
try {
|
|
174
|
+
const state = assertPrimaryOnMain(config, git)
|
|
175
|
+
onBase = state.onBase
|
|
176
|
+
branch = state.branch
|
|
177
|
+
baseBranch = state.baseBranch
|
|
178
|
+
} catch {
|
|
179
|
+
return { refuse: false, reason: null, branch: null, baseBranch: null }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const root = isPrimary ? dir : primary || dir
|
|
183
|
+
const verdict = judgeMainWrite({
|
|
184
|
+
configured,
|
|
185
|
+
enabled,
|
|
186
|
+
isPrimary,
|
|
187
|
+
onBase,
|
|
188
|
+
allow: readAllow(root, config),
|
|
189
|
+
sessionId: opts.sessionId || null,
|
|
190
|
+
})
|
|
191
|
+
return { ...verdict, branch, baseBranch }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Record an allow. `sessionId` present → a session allow; absent → a repo one.
|
|
196
|
+
* Returns what was written, so the caller can report which kind it is.
|
|
197
|
+
*/
|
|
198
|
+
function recordAllow(rootDir, config, { sessionId = null, reason = null, at = null } = {}) {
|
|
199
|
+
const value = sessionId
|
|
200
|
+
? { scope: 'session', session: sessionId, reason, at }
|
|
201
|
+
: { scope: 'repo', reason, at }
|
|
202
|
+
writeAllow(rootDir, config, value)
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What is in force, as data. Four states, and the caller reports all four —
|
|
208
|
+
* `off` (the project switched the guard off), `unconfigured`, `allowed` (with
|
|
209
|
+
* its scope and reason), or `guarded`.
|
|
210
|
+
*/
|
|
211
|
+
function mainGuardStatus(rootDir, config, opts = {}) {
|
|
212
|
+
if (opts.present !== true) return { state: 'unconfigured' }
|
|
213
|
+
if (config && config.guards && config.guards.mainIsLandingZone === false) {
|
|
214
|
+
return { state: 'off' }
|
|
215
|
+
}
|
|
216
|
+
const allow = readAllow(rootDir, config)
|
|
217
|
+
if (allowApplies(allow, opts.sessionId || null)) {
|
|
218
|
+
return { state: 'allowed', scope: allow.scope, reason: allow.reason || null, at: allow.at || null }
|
|
219
|
+
}
|
|
220
|
+
// A session allow belonging to ANOTHER session is reported rather than hidden:
|
|
221
|
+
// the operator asked for one somewhere, and a bare "guarded" would send them
|
|
222
|
+
// hunting for why their /allow-main did nothing.
|
|
223
|
+
if (allow && allow.scope === 'session') {
|
|
224
|
+
return { state: 'guarded', otherSession: true }
|
|
225
|
+
}
|
|
226
|
+
return { state: 'guarded' }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
ALLOW_FILE,
|
|
231
|
+
allowPath,
|
|
232
|
+
readAllow,
|
|
233
|
+
writeAllow,
|
|
234
|
+
clearAllow,
|
|
235
|
+
allowApplies,
|
|
236
|
+
judgeMainWrite,
|
|
237
|
+
checkMainWrite,
|
|
238
|
+
recordAllow,
|
|
239
|
+
mainGuardStatus,
|
|
240
|
+
}
|