@skitterbyte/skitterspec 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
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 hookEntry() {
90
+ function commandFor(script) {
91
+ return `node "\${CLAUDE_PROJECT_DIR}/${script}"`
92
+ }
93
+
94
+ function hookEntry(hook = HOOKS[0]) {
56
95
  return {
57
- matcher: MATCHER,
58
- hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: HOOK_TIMEOUT }],
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' && HOOK_SCRIPT_RE.test(h.command)) out.push(h)
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 the review-gate hook is registered in `dir`'s project settings.
90
- * Idempotent and non-destructive. Returns `{ changed, reason }`:
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 ensureReviewGateHook(dir) {
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: [hookEntry()] } })
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 hooks = isObject(parsed.hooks) ? parsed.hooks : {}
122
- const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
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
- // Registered already — but possibly under a path that no longer exists, since
125
- // the script's extension changed. Rewrite the path IN PLACE rather than
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
@@ -85,6 +85,98 @@ function summarizeReceipt(receipt) {
85
85
  )
86
86
  }
87
87
 
88
+ // --- is this spec live? ---------------------------------------------------
89
+
90
+ /**
91
+ * Whether a spec is currently live, in a form a page can carry. Pure.
92
+ *
93
+ * FOUR STATES, NOT TWO, and the fourth is what keeps this quiet in a healthy
94
+ * repo. `on` and `off` are the two a reader acts on; `held` is another spec
95
+ * holding the workbench, which is a refusal with a named way out; and
96
+ * `unavailable` is **cannot tell** — routed to silence rather than to a warning
97
+ * (`.claude/rules/negative-checks.md` rule 4). A project with no isolation and a
98
+ * spec with no worktree both land there, and both are perfectly healthy.
99
+ *
100
+ * THE BRANCH IS THE AUTHORITY, NOT THE RECEIPT, exactly as this file's header
101
+ * says: the receipt is metadata for `live status` and crash recovery. So an
102
+ * unreadable or absent receipt costs the *holder's name* and nothing else — the
103
+ * state still comes from which branch the primary checkout is on. Deriving the
104
+ * state from the receipt instead would report `off` for a checkout plainly
105
+ * sitting on a feature branch, which is the one answer that would let something
106
+ * act.
107
+ *
108
+ * WHAT WOULD FOOL THIS: `onBase` and `primaryBranch` are read from the primary
109
+ * checkout by the caller. A caller that passes a *worktree's* git reader would
110
+ * describe the worktree's HEAD and call it the workbench — so `ctx` is built by
111
+ * `assertPrimaryOnMain` against the repo root and nowhere else.
112
+ *
113
+ * ctx: { isolated, onBase, primaryBranch, receipt, worktreeExists, url }
114
+ * @returns {{state:'on'|'off'|'held'|'unavailable', holder:string|null, url:string|null, reason:string|null}}
115
+ */
116
+ function liveStateFor(spec, ctx) {
117
+ const c = ctx || {}
118
+ const unavailable = (reason) => ({ state: 'unavailable', holder: null, url: null, reason })
119
+
120
+ if (!spec || !spec.branch) return unavailable('no spec to ask about')
121
+ if (c.isolated === false) return unavailable('isolation is not configured')
122
+
123
+ // Not on base — something holds the workbench. Which something is the only
124
+ // question left, and the branch answers it without the receipt.
125
+ if (c.onBase === false) {
126
+ const branch = c.primaryBranch || null
127
+ if (branch && branch === spec.branch) {
128
+ return { state: 'on', holder: null, url: c.url || null, reason: null }
129
+ }
130
+ const holder = c.receipt && c.receipt.spec ? String(c.receipt.spec) : null
131
+ const on = branch || '(detached)'
132
+ return {
133
+ state: 'held',
134
+ holder,
135
+ url: null,
136
+ // Every way out, because this is the only explanation the reader gets —
137
+ // the same reasoning `planTake`'s guard 1 records for its own refusal.
138
+ reason: holder
139
+ ? `${holder} holds the workbench (branch ${on})`
140
+ : `the workbench is on ${on} — no receipt; switched by hand?`,
141
+ }
142
+ }
143
+
144
+ // `onBase` is deliberately three-valued: anything but a definite `true` here
145
+ // is a checkout we could not read, and that is not evidence the spec is off.
146
+ if (c.onBase !== true) return unavailable('could not read the primary checkout')
147
+ if (!c.worktreeExists) return unavailable('no worktree to put live')
148
+
149
+ return { state: 'off', holder: null, url: null, reason: null }
150
+ }
151
+
152
+ /**
153
+ * The one line the render prints for a live state, or null for `unavailable`.
154
+ * Pure.
155
+ *
156
+ * NULL IS THE POINT. `unavailable` prints nothing at all — not a dash, not an
157
+ * explanation — because every input that produces it is a healthy repo that
158
+ * simply has no live surface, and a line about one is an accusation.
159
+ */
160
+ function liveStateLine(live) {
161
+ if (!live || live.state === 'unavailable') return null
162
+ const label = ' live:'.padEnd(11)
163
+ // IT NAMES THE COMMAND, not the capability. This line read
164
+ // `off — the page can put it live`, which told the reader a page somewhere
165
+ // could do it and left them to find the verb — and the verb is the thing they
166
+ // actually need when they are in a terminal rather than on the page. It is
167
+ // `/spec-live` rather than `spec-env live take` because that command is what a
168
+ // person types: it is user-only by its own marking, and the model cannot run
169
+ // it either way.
170
+ if (live.state === 'on') {
171
+ return (
172
+ `${label}on${live.url ? ` — running at ${live.url}` : ''}` +
173
+ '; /spec-live main to restore main'
174
+ )
175
+ }
176
+ if (live.state === 'held') return `${label}held — ${live.reason}`
177
+ return `${label}off — /spec-live to put it live`
178
+ }
179
+
88
180
  // --- stateful detection (glob matching) -----------------------------------
89
181
 
90
182
  // Translate a restricted glob into an anchored RegExp: `**` matches across path
@@ -164,7 +256,27 @@ function planTake(spec, config, ctx) {
164
256
  branch,
165
257
  worktreePath: spec.worktreePath,
166
258
  }
167
- const block = (reason) => ({ ...result, blocked: true, reason })
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
+ })
168
280
 
169
281
  // 1. The lock: the primary checkout must be on base (free). Off-base → a spec
170
282
  // (or you) already holds the live instance.
@@ -201,6 +313,15 @@ function planTake(spec, config, ctx) {
201
313
  return block(
202
314
  `${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
203
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' },
204
325
  )
205
326
  }
206
327
  // 4. A hotfix is built on an old release tag; checking its branch out under the
@@ -378,4 +499,6 @@ module.exports = {
378
499
  planTake,
379
500
  planRelease,
380
501
  planAbort,
502
+ liveStateFor,
503
+ liveStateLine,
381
504
  }
@@ -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
+ }