@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The primary-checkout leak guard.
|
|
5
|
+
*
|
|
6
|
+
* A phase can be built in a worktree from a session standing somewhere else —
|
|
7
|
+
* every write an absolute path, every command `cd`-prefixed. That works;
|
|
8
|
+
* `spec-env review` already reads a worktree exactly that way. What it cannot do
|
|
9
|
+
* is *prove* it worked: one relative path and the edit lands in the primary
|
|
10
|
+
* checkout, on the base branch, silently.
|
|
11
|
+
*
|
|
12
|
+
* So record what the primary checkout looked like before the build
|
|
13
|
+
* (`--record-primary`) and compare after it (`--assert-primary-clean`).
|
|
14
|
+
*
|
|
15
|
+
* The baseline is the whole point. `negative-checks.md` rule 1 asks for a
|
|
16
|
+
* positive signal rather than an absence, and "these were the dirty paths at a
|
|
17
|
+
* known moment" is one; a bare "the primary must be clean" check would accuse
|
|
18
|
+
* anyone who left an unrelated edit open in another window. Rule 4 supplies the
|
|
19
|
+
* third state: when the baseline is missing, or is for another spec, the check
|
|
20
|
+
* cannot tell — and cannot-tell reports and exits 0 rather than accusing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
|
|
25
|
+
// The state dir sits beside the registry file, e.g. `.spec-env` — the same
|
|
26
|
+
// convention `dev.js` uses for logs and pids.
|
|
27
|
+
function stateDir(config) {
|
|
28
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the baseline lives. Gitignored with the rest of `.spec-env/`. */
|
|
32
|
+
function baselinePath(dir, config) {
|
|
33
|
+
return path.join(dir, stateDir(config), 'building.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge one or more newline-separated git path listings into a sorted, deduped
|
|
38
|
+
* array. Blank lines are dropped; nothing is parsed out of the line.
|
|
39
|
+
*
|
|
40
|
+
* DELIBERATELY NOT `git status --porcelain`, for two reasons, both found by
|
|
41
|
+
* running this against a real worktree:
|
|
42
|
+
*
|
|
43
|
+
* 1. Porcelain puts the path behind two fixed status columns and a space, and
|
|
44
|
+
* the repo's `gitReader` TRIMS its stdout — so the first line loses its
|
|
45
|
+
* leading space and a fixed `slice(3)` eats the first character of the
|
|
46
|
+
* first path. A guard that renames the file it is accusing you of is worse
|
|
47
|
+
* than no guard.
|
|
48
|
+
* 2. Porcelain reports stat-dirty entries: a file whose mtime moved but whose
|
|
49
|
+
* content is identical to HEAD. One was observed here, reported as ` M`
|
|
50
|
+
* with an empty `git diff`. Accusing someone of leaking a file they never
|
|
51
|
+
* changed is exactly the false accusation `negative-checks.md` exists to
|
|
52
|
+
* prevent.
|
|
53
|
+
*
|
|
54
|
+
* `git diff --name-only HEAD` compares CONTENT, and
|
|
55
|
+
* `git ls-files --others --exclude-standard` lists genuinely new files. Between
|
|
56
|
+
* them they answer "what did this tree gain?" without a column to miscount.
|
|
57
|
+
*/
|
|
58
|
+
function mergePaths(...listings) {
|
|
59
|
+
const out = new Set()
|
|
60
|
+
for (const listing of listings) {
|
|
61
|
+
for (const line of String(listing || '').split('\n')) {
|
|
62
|
+
const p = line.replace(/\r$/, '').trim()
|
|
63
|
+
if (p !== '') out.add(p)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...out].sort()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Are these two paths the same tree? */
|
|
70
|
+
function sameTree(a, b) {
|
|
71
|
+
return path.resolve(a) === path.resolve(b)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The record written by `--record-primary`. Pure. */
|
|
75
|
+
function buildBaseline({ spec, worktreePath, primary, paths }) {
|
|
76
|
+
return { spec, worktreePath, primary, paths: [...paths].sort() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare a recorded baseline against the primary checkout's current state.
|
|
81
|
+
*
|
|
82
|
+
* Three verdicts, never two (`negative-checks.md` rule 4):
|
|
83
|
+
* - `clean` — nothing new appeared; say nothing and exit 0.
|
|
84
|
+
* - `leaked` — these paths appeared since; name them and exit non-zero.
|
|
85
|
+
* - `unknown` — the check cannot tell; say why and exit 0.
|
|
86
|
+
*
|
|
87
|
+
* Pure: every input is supplied, nothing is read from disk or git.
|
|
88
|
+
*
|
|
89
|
+
* WHAT WOULD FOOL THIS CHECK, deliberately unhandled:
|
|
90
|
+
* - Work the build COMMITTED in the primary checkout. A commit empties the
|
|
91
|
+
* porcelain, so a leak that was tidied away looks identical to no leak. The
|
|
92
|
+
* guard is aimed at the actual failure mode — writes going astray mid-build,
|
|
93
|
+
* while nothing has been committed yet — and runs before the phase commit
|
|
94
|
+
* for that reason.
|
|
95
|
+
* - A write to a GITIGNORED path in the primary (`.spec-env/` itself, build
|
|
96
|
+
* output, `node_modules`). `git status` cannot see it, by design, and the
|
|
97
|
+
* baseline file lives in exactly such a path so that recording it is not
|
|
98
|
+
* itself a change the next comparison trips over.
|
|
99
|
+
* - A path dirty at record time and then edited FURTHER by the build. It is in
|
|
100
|
+
* the baseline, so it stays silent. That is the deliberate trade: silence
|
|
101
|
+
* there costs a missed edit to a file someone was already working on, and
|
|
102
|
+
* the opposite default accuses every healthy concurrent edit.
|
|
103
|
+
*/
|
|
104
|
+
function compare(baseline, currentPaths, { spec, worktreePath, primary }) {
|
|
105
|
+
if (sameTree(primary, worktreePath)) {
|
|
106
|
+
return {
|
|
107
|
+
verdict: 'unknown',
|
|
108
|
+
paths: [],
|
|
109
|
+
reason: 'the worktree IS the primary checkout — there is no second tree to leak into',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!baseline) {
|
|
113
|
+
return { verdict: 'unknown', paths: [], reason: 'no baseline recorded' }
|
|
114
|
+
}
|
|
115
|
+
if (baseline.spec !== spec) {
|
|
116
|
+
return {
|
|
117
|
+
verdict: 'unknown',
|
|
118
|
+
paths: [],
|
|
119
|
+
reason: `the baseline was recorded for ${baseline.spec}, not ${spec}`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!sameTree(baseline.worktreePath, worktreePath)) {
|
|
123
|
+
return {
|
|
124
|
+
verdict: 'unknown',
|
|
125
|
+
paths: [],
|
|
126
|
+
reason: 'the baseline records a different worktree for this spec',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const before = new Set(baseline.paths || [])
|
|
130
|
+
const appeared = [...currentPaths].filter((p) => !before.has(p)).sort()
|
|
131
|
+
return appeared.length === 0
|
|
132
|
+
? { verdict: 'clean', paths: [], reason: null }
|
|
133
|
+
: { verdict: 'leaked', paths: appeared, reason: null }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
stateDir,
|
|
138
|
+
baselinePath,
|
|
139
|
+
mergePaths,
|
|
140
|
+
sameTree,
|
|
141
|
+
buildBaseline,
|
|
142
|
+
compare,
|
|
143
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Is this shell command a `git commit`?
|
|
5
|
+
*
|
|
6
|
+
* Asked by the review-gate hook, which is handed the command line a tool is
|
|
7
|
+
* about to run and has to decide whether the gate is even relevant. It lives
|
|
8
|
+
* here rather than inside the hook script for one reason: **a check that blocks
|
|
9
|
+
* a commit is an accusation** (`.claude/rules/negative-checks.md`), and an
|
|
10
|
+
* accusation that cannot be unit-tested will be wrong in ways nobody finds. The
|
|
11
|
+
* hook is a stdin shim over `spec-env review gate --check --for-command`; this
|
|
12
|
+
* is the part with the judgement in it.
|
|
13
|
+
*
|
|
14
|
+
* It answers TRUE only on a positive reading — a `git` invocation whose first
|
|
15
|
+
* non-option argument is `commit`. Everything it cannot parse confidently reads
|
|
16
|
+
* FALSE, because the cost of the two mistakes is not symmetric: a false
|
|
17
|
+
* negative lets one commit through a gate the skills also enforce, while a
|
|
18
|
+
* false positive blocks a command that has nothing to do with reviewing and
|
|
19
|
+
* leaves the operator with no idea why.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Shell metacharacters that end one command and begin another. `|` covers `||`
|
|
23
|
+
// too, and `&` covers `&&`; over-splitting is harmless here because each
|
|
24
|
+
// fragment is judged on its own.
|
|
25
|
+
const SEPARATORS = /[;&|\n]+/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remove every quoted span. Pure.
|
|
29
|
+
*
|
|
30
|
+
* THE POINT IS WHAT THIS PREVENTS, twice over. `echo "deploy && git commit"`
|
|
31
|
+
* must not read as a commit — splitting a raw string on `&&` would manufacture
|
|
32
|
+
* a fragment out of someone's prose. And `git commit -m "fix the git log"`
|
|
33
|
+
* must still read as one: emptying the quotes leaves the real argv intact,
|
|
34
|
+
* because a verb is never inside quotes.
|
|
35
|
+
*
|
|
36
|
+
* An UNTERMINATED quote empties the rest of the line, so a half-written command
|
|
37
|
+
* reads as nothing rather than as something — the cannot-tell case going to the
|
|
38
|
+
* harmless branch, again.
|
|
39
|
+
*/
|
|
40
|
+
function stripQuoted(command) {
|
|
41
|
+
let out = ''
|
|
42
|
+
let quote = null
|
|
43
|
+
for (let i = 0; i < command.length; i++) {
|
|
44
|
+
const c = command[i]
|
|
45
|
+
if (quote) {
|
|
46
|
+
if (c === '\\' && quote === '"') {
|
|
47
|
+
i++
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (c === quote) quote = null
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (c === '"' || c === "'") {
|
|
54
|
+
quote = c
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
out += c
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// git's own options, before the subcommand. Those taking a separate value have
|
|
63
|
+
// to be skipped WITH their value, or `git -C /tmp commit` reads its verb as the
|
|
64
|
+
// path. The `=` forms carry their value already.
|
|
65
|
+
const GIT_OPTS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Does this one fragment invoke `git commit`? Pure.
|
|
69
|
+
*
|
|
70
|
+
* `env`, `sudo`, `time` and the like are NOT unwrapped: a wrapper is not the
|
|
71
|
+
* common case and guessing at one is how a false positive gets built. The
|
|
72
|
+
* binary may be a path (`/usr/bin/git`), because that is ordinary.
|
|
73
|
+
*/
|
|
74
|
+
function fragmentCommits(fragment) {
|
|
75
|
+
const tokens = fragment.trim().split(/\s+/).filter(Boolean)
|
|
76
|
+
const at = tokens.findIndex((t) => t === 'git' || /(^|\/)git$/.test(t))
|
|
77
|
+
if (at === -1) return false
|
|
78
|
+
|
|
79
|
+
for (let i = at + 1; i < tokens.length; i++) {
|
|
80
|
+
const t = tokens[i]
|
|
81
|
+
if (GIT_OPTS_WITH_VALUE.has(t)) {
|
|
82
|
+
i++
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (t.startsWith('-')) continue
|
|
86
|
+
// The first non-option token is the subcommand, whatever it is. Only one
|
|
87
|
+
// word is a commit.
|
|
88
|
+
return t === 'commit'
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does this command line run `git commit` anywhere in it? Pure.
|
|
95
|
+
*
|
|
96
|
+
* WHAT WOULD FOOL THIS, named here so the next reader does not have to
|
|
97
|
+
* rediscover it: a commit hidden inside a quoted script (`sh -c 'git commit'`),
|
|
98
|
+
* behind an alias, or built by string interpolation reads as FALSE. All three
|
|
99
|
+
* are deliberate — they are the unknown case, and the unknown case does not
|
|
100
|
+
* accuse. The gate is still enforced by `/spec-next`, which does not depend on
|
|
101
|
+
* reading anybody's shell.
|
|
102
|
+
*/
|
|
103
|
+
function isGitCommit(command) {
|
|
104
|
+
if (typeof command !== 'string' || !command.trim()) return false
|
|
105
|
+
return stripQuoted(command).split(SEPARATORS).some(fragmentCommits)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { isGitCommit, stripQuoted, fragmentCommits }
|
package/src/env/config.js
CHANGED
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
28
28
|
* // servers started on the spec's port block (empty = none)
|
|
29
29
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
30
|
-
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
31
30
|
* registry: ".spec-env/registry.json",
|
|
32
31
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
33
32
|
* spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
|
|
@@ -86,7 +85,6 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
86
85
|
// Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
|
|
87
86
|
// exposes one connected spec's frontPort processes on the canonical ports.
|
|
88
87
|
proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
|
|
89
|
-
open: Object.freeze({ command: '' }),
|
|
90
88
|
registry: '.spec-env/registry.json',
|
|
91
89
|
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
92
90
|
// when a tracker provider is linked, {identifier}; `identifierField` names the
|
|
@@ -104,11 +102,43 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
104
102
|
baseBranch: '',
|
|
105
103
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
106
104
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
107
|
-
// `spec-env down` does about
|
|
105
|
+
// `spec-env down` does about a branch the USER published by hand — nothing
|
|
106
|
+
// publishes one at provisioning, so there is often no remote ref at all and
|
|
107
|
+
// the plan then says nothing: "prompt" (default)
|
|
108
108
|
// plans the delete in its own confirm-first section for the skill to ask about,
|
|
109
109
|
// "never" omits it, "always" folds it into the run-blind command list. Only ever
|
|
110
110
|
// planned for a LANDED branch — see teardown.js.
|
|
111
111
|
teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
|
|
112
|
+
|
|
113
|
+
// `reader` decides how a diff's location is worded, and — via
|
|
114
|
+
// `serveOnRemote` — whether the engine stands the local server up so a remote
|
|
115
|
+
// reader gets a link that opens. It never decides to PUBLISH: publishing
|
|
116
|
+
// leaves a page this tooling cannot remove, so it stays an explicit ask.
|
|
117
|
+
// `detect` sniffs; `local`/`remote` are the operator's own answer and are
|
|
118
|
+
// believed without sniffing, because they know where they are reading and no
|
|
119
|
+
// signal can outrank that.
|
|
120
|
+
// `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
|
|
121
|
+
// ships with skittership, a different package — so it may not be installed,
|
|
122
|
+
// and skitterspec must never vendor a copy of it.
|
|
123
|
+
//
|
|
124
|
+
// There is no off switch, and that is deliberate: `"none"` existed and was
|
|
125
|
+
// removed, because it produced the one thing a review page must not have —
|
|
126
|
+
// a verdict that records itself and does nothing. A review is the guard in
|
|
127
|
+
// front of an action; recording an approval for SOMEONE ELSE to act on is a
|
|
128
|
+
// different mechanism, not a value of this key.
|
|
129
|
+
//
|
|
130
|
+
// `required` decides whether a phase that ended owes a verdict before its
|
|
131
|
+
// work can be committed or the next phase built. It defaults TRUE: the push
|
|
132
|
+
// toward reading the diff is the point, and a project that would rather not
|
|
133
|
+
// be pushed says so once. It is the only key anything reads to decide
|
|
134
|
+
// whether the gate refuses, so turning it off turns off the hook with it.
|
|
135
|
+
review: Object.freeze({
|
|
136
|
+
reader: 'detect',
|
|
137
|
+
servePort: 7777,
|
|
138
|
+
serveOnRemote: true,
|
|
139
|
+
commitWith: '/commit',
|
|
140
|
+
required: true,
|
|
141
|
+
}),
|
|
112
142
|
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
113
143
|
// migration files; a branch that changes any of them is treated as stateful and
|
|
114
144
|
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
@@ -135,13 +165,13 @@ function defaults() {
|
|
|
135
165
|
setup: [],
|
|
136
166
|
dev: [],
|
|
137
167
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
138
|
-
open: { ...DEFAULT_CONFIG.open },
|
|
139
168
|
registry: DEFAULT_CONFIG.registry,
|
|
140
169
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
141
170
|
spec: { companionPaths: [] },
|
|
142
171
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
143
172
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
144
173
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
174
|
+
review: { ...DEFAULT_CONFIG.review },
|
|
145
175
|
live: { migrations: [] },
|
|
146
176
|
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
147
177
|
}
|
|
@@ -269,11 +299,6 @@ function mergeConfig(base, parsed) {
|
|
|
269
299
|
assign(base.proxy, parsed.proxy, 'host', 'string')
|
|
270
300
|
}
|
|
271
301
|
|
|
272
|
-
if (isObject(parsed.open)) {
|
|
273
|
-
// command may be intentionally empty (no auto-open)
|
|
274
|
-
assign(base.open, parsed.open, 'command', 'string?')
|
|
275
|
-
}
|
|
276
|
-
|
|
277
302
|
if (isObject(parsed.branch)) {
|
|
278
303
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
279
304
|
assign(base.branch, parsed.branch, 'identifierField', 'string')
|
|
@@ -307,6 +332,30 @@ function mergeConfig(base, parsed) {
|
|
|
307
332
|
}
|
|
308
333
|
}
|
|
309
334
|
|
|
335
|
+
// An unrecognised reader falls through to `detect`, which is the state that
|
|
336
|
+
// claims least: it can answer "unknown", and unknown is wired to today's
|
|
337
|
+
// behaviour. A typo must never become a confident `local`, because a confident
|
|
338
|
+
// `local` is exactly the dead `file://` link this key exists to prevent.
|
|
339
|
+
if (isObject(parsed.review)) {
|
|
340
|
+
const reader = parsed.review.reader
|
|
341
|
+
if (reader === 'local' || reader === 'remote' || reader === 'detect') {
|
|
342
|
+
base.review.reader = reader
|
|
343
|
+
}
|
|
344
|
+
assign(base.review, parsed.review, 'servePort', 'number')
|
|
345
|
+
// Opting OUT is the only thing this key can do — a non-boolean leaves the
|
|
346
|
+
// default in place rather than being read as a refusal, so a typo cannot
|
|
347
|
+
// quietly restore the dead `file://` link on a remote reader.
|
|
348
|
+
assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
|
|
349
|
+
// An empty string leaves `/commit` standing, like every other string key
|
|
350
|
+
// here. There is nothing it could mean instead: the hand-off has no off
|
|
351
|
+
// switch, so a blank value is a typo rather than an instruction.
|
|
352
|
+
assign(base.review, parsed.review, 'commitWith', 'string')
|
|
353
|
+
// Same shape as `serveOnRemote`, and for a sharper reason: a non-boolean
|
|
354
|
+
// leaves the gate ON. Turning off a check that refuses must be something
|
|
355
|
+
// someone WROTE, never something a typo achieved on their behalf.
|
|
356
|
+
assign(base.review, parsed.review, 'required', 'boolean')
|
|
357
|
+
}
|
|
358
|
+
|
|
310
359
|
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
311
360
|
base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
|
|
312
361
|
}
|
package/src/env/hooks.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register the review-gate hook in the project's Claude Code settings.
|
|
5
|
+
*
|
|
6
|
+
* `.claude/settings.json` rather than `settings.local.json`, and the difference
|
|
7
|
+
* is the point: the trusted-worktree entry is an absolute path and therefore
|
|
8
|
+
* one machine's business, while this is the project's policy — a phase that
|
|
9
|
+
* ended owes a verdict — and it should reach everyone who clones the repo. The
|
|
10
|
+
* command is written with `${CLAUDE_PROJECT_DIR}` so it stays true wherever the
|
|
11
|
+
* checkout lives, worktrees included.
|
|
12
|
+
*
|
|
13
|
+
* Conservative in the same way `trust.js` is: every existing key is preserved,
|
|
14
|
+
* a file it cannot parse is left exactly as it is, and re-running changes
|
|
15
|
+
* nothing once the entry is there. Callers own the reporting.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
|
|
21
|
+
const HOOK_SCRIPT = '.claude/hooks/review-gate.js'
|
|
22
|
+
const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
|
|
23
|
+
// Seconds. The engine call behind this is one git-free read of a small JSON
|
|
24
|
+
// file, so anything approaching this is a wedge rather than slow work — and a
|
|
25
|
+
// hook that times out fails OPEN, which is the answer we want for a wedge.
|
|
26
|
+
const HOOK_TIMEOUT = 10
|
|
27
|
+
const MATCHER = 'Bash'
|
|
28
|
+
|
|
29
|
+
function isObject(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function settingsPath(dir) {
|
|
34
|
+
return path.join(dir, '.claude', 'settings.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeSettings(file, settings) {
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
39
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hookEntry() {
|
|
43
|
+
return {
|
|
44
|
+
matcher: MATCHER,
|
|
45
|
+
hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: HOOK_TIMEOUT }],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is our hook already registered under `PreToolUse`? Pure.
|
|
51
|
+
*
|
|
52
|
+
* Matched on the SCRIPT PATH, not on the whole command string. An operator who
|
|
53
|
+
* added a timeout, wrapped the invocation, or changed the interpreter has
|
|
54
|
+
* registered our hook their way — re-adding a second copy beside theirs would
|
|
55
|
+
* run it twice and look like a bug in the gate.
|
|
56
|
+
*/
|
|
57
|
+
function alreadyRegistered(preToolUse) {
|
|
58
|
+
if (!Array.isArray(preToolUse)) return false
|
|
59
|
+
return preToolUse.some(
|
|
60
|
+
(group) =>
|
|
61
|
+
isObject(group) &&
|
|
62
|
+
Array.isArray(group.hooks) &&
|
|
63
|
+
group.hooks.some((h) => isObject(h) && typeof h.command === 'string' && h.command.includes(HOOK_SCRIPT)),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ensure the review-gate hook is registered in `dir`'s project settings.
|
|
69
|
+
* Idempotent and non-destructive. Returns `{ changed, reason }`:
|
|
70
|
+
* - `created` — no settings file; one was written
|
|
71
|
+
* - `added` — merged into an existing file
|
|
72
|
+
* - `present` — already registered (no write)
|
|
73
|
+
* - `malformed` — the file exists but is not parseable JSON (left untouched)
|
|
74
|
+
*/
|
|
75
|
+
function ensureReviewGateHook(dir) {
|
|
76
|
+
const file = settingsPath(dir)
|
|
77
|
+
|
|
78
|
+
let raw
|
|
79
|
+
try {
|
|
80
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
writeSettings(file, { hooks: { PreToolUse: [hookEntry()] } })
|
|
84
|
+
return { changed: true, reason: 'created' }
|
|
85
|
+
}
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let parsed
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(raw)
|
|
92
|
+
} catch {
|
|
93
|
+
// Never rewrite a settings file we could not read. It is the operator's
|
|
94
|
+
// config and everything else in it would be lost.
|
|
95
|
+
return { changed: false, reason: 'malformed' }
|
|
96
|
+
}
|
|
97
|
+
if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
|
|
98
|
+
|
|
99
|
+
const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
|
|
100
|
+
const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
|
|
101
|
+
if (alreadyRegistered(preToolUse)) return { changed: false, reason: 'present' }
|
|
102
|
+
|
|
103
|
+
writeSettings(file, {
|
|
104
|
+
...parsed,
|
|
105
|
+
hooks: { ...hooks, PreToolUse: [...preToolUse, hookEntry()] },
|
|
106
|
+
})
|
|
107
|
+
return { changed: true, reason: 'added' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
ensureReviewGateHook,
|
|
112
|
+
alreadyRegistered,
|
|
113
|
+
hookEntry,
|
|
114
|
+
settingsPath,
|
|
115
|
+
HOOK_SCRIPT,
|
|
116
|
+
HOOK_COMMAND,
|
|
117
|
+
}
|
package/src/env/provision.js
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Given a resolved spec and its allocated slot, `planUp` returns the exact
|
|
7
7
|
* side-effecting commands the `/spec-env` skill runs (`git worktree add`,
|
|
8
|
-
* `docker compose up`)
|
|
9
|
-
*
|
|
8
|
+
* `docker compose up`) and the rendered `.env` contents — but performs no side
|
|
9
|
+
* effects itself. The caller (the CLI) reads/allocates the
|
|
10
10
|
* registry and passes the slot; this stays deterministic and unit-testable with
|
|
11
11
|
* no live git/docker.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { classifyDirtyTree } = require('./classify.js')
|
|
15
15
|
const { portOffset } = require('./registry.js')
|
|
16
|
-
const { renderEnvFile
|
|
16
|
+
const { renderEnvFile } = require('./render.js')
|
|
17
17
|
const { expandTokens } = require('./resolve.js')
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -114,11 +114,20 @@ function listPaths(paths) {
|
|
|
114
114
|
* tell (an unreadable git, or a hotfix, which forks from a tag predating its own
|
|
115
115
|
* spec), and routes to carrying on, never to refusing.
|
|
116
116
|
*
|
|
117
|
+
* FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
|
|
118
|
+
* uncommitted work refuses in checkout mode, where `git switch -c` would carry
|
|
119
|
+
* it onto the new branch, and does not in worktree mode, where nothing carries
|
|
120
|
+
* anywhere. Refusing there fired on the commonest tree this workflow produces —
|
|
121
|
+
* author spec B while spec A is still uncommitted, then start B — and prevented
|
|
122
|
+
* nothing, since the only write to the primary checkout is a pathspec-limited
|
|
123
|
+
* commit of this spec's own paths. It is reported instead, on `foreign`.
|
|
124
|
+
*
|
|
117
125
|
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
118
|
-
* @returns {{blocked: boolean, reason: string|null, commands: string[]
|
|
126
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[],
|
|
127
|
+
* owned: string[], foreign: string[]}}
|
|
119
128
|
*/
|
|
120
129
|
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
121
|
-
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
|
|
130
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
|
|
122
131
|
const c = ctx || {}
|
|
123
132
|
|
|
124
133
|
if (!Array.isArray(c.dirtyPaths)) {
|
|
@@ -140,15 +149,30 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
140
149
|
|
|
141
150
|
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
142
151
|
|
|
143
|
-
|
|
152
|
+
// FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
|
|
153
|
+
// mode and nowhere else. `git switch -c` carries the working tree onto the new
|
|
154
|
+
// branch, silently, so there it is someone else's work being moved without
|
|
155
|
+
// them asking. `git worktree add` carries nothing and forks from a commit, so
|
|
156
|
+
// in worktree mode the same tree is simply none of this run's business.
|
|
157
|
+
//
|
|
158
|
+
// WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
|
|
159
|
+
// run does to the primary checkout is the spec commit below, and it is safe to
|
|
160
|
+
// leave a colleague's files sitting beside it only because that commit is
|
|
161
|
+
// pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
|
|
162
|
+
// cannot reach a path it does not own — not even one another session has
|
|
163
|
+
// already staged into the shared index. Drop the `--` and this stops being a
|
|
164
|
+
// false guard and starts being a missing one.
|
|
165
|
+
if (foreign.length && carriesChanges) {
|
|
144
166
|
return {
|
|
145
167
|
blocked: true,
|
|
146
168
|
reason:
|
|
147
169
|
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
148
|
-
'commit or stash them first' +
|
|
149
|
-
(carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
|
|
170
|
+
'commit or stash them first (switching would carry them onto the new branch)' +
|
|
150
171
|
`: ${listPaths(foreign)}`,
|
|
151
172
|
commands: [],
|
|
173
|
+
owned: [],
|
|
174
|
+
verb: null,
|
|
175
|
+
foreign,
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
|
|
@@ -165,20 +189,34 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
165
189
|
? c.specUntracked
|
|
166
190
|
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
167
191
|
const verb = isNew ? 'add' : 'update'
|
|
192
|
+
// BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
|
|
193
|
+
// `add` is what makes a path git has never seen committable at all — every
|
|
194
|
+
// brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
|
|
195
|
+
// what lands: a checkout has one `.git/index` and every session standing in
|
|
196
|
+
// it shares that index, so a bare `git commit` takes whatever another
|
|
197
|
+
// session has already staged, however exactly this one named its own paths.
|
|
198
|
+
// Dropping the `--` would leave the naming above as decoration.
|
|
199
|
+
const paths = owned.map((p) => `"${p}"`).join(' ')
|
|
168
200
|
return {
|
|
169
201
|
blocked: false,
|
|
170
202
|
reason: null,
|
|
171
203
|
owned,
|
|
172
204
|
verb,
|
|
205
|
+
foreign,
|
|
173
206
|
commands: [
|
|
174
|
-
`git add
|
|
175
|
-
`git commit -m "chore(spec): ${verb} ${spec.folder}"`,
|
|
207
|
+
`git add -- ${paths}`,
|
|
208
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
|
|
176
209
|
],
|
|
177
210
|
}
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
181
214
|
// worktree forks without it.
|
|
215
|
+
// Nothing of this spec's is uncommitted, but somebody else's may be — say so
|
|
216
|
+
// rather than returning the bare `ok`, or the caller has no way to report what
|
|
217
|
+
// it left alone.
|
|
218
|
+
if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
|
|
219
|
+
|
|
182
220
|
if (c.specOnFork === false) {
|
|
183
221
|
return {
|
|
184
222
|
blocked: true,
|
|
@@ -201,7 +239,7 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
201
239
|
* existed in the registry (re-run → attach, don't clobber).
|
|
202
240
|
* @param {object} config normalised env config.
|
|
203
241
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
204
|
-
* envContents,
|
|
242
|
+
* envContents, commands, seedCommands,
|
|
205
243
|
* setupCommands, attached }
|
|
206
244
|
*/
|
|
207
245
|
function planUp(spec, alloc, config, ctx) {
|
|
@@ -228,7 +266,6 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
228
266
|
portOffset: offset === null ? '' : String(offset),
|
|
229
267
|
}
|
|
230
268
|
|
|
231
|
-
const openCommand = expandOpenCommand(config.open.command, tokens)
|
|
232
269
|
|
|
233
270
|
// File seeding runs *in the worktree* after `git worktree add`, before the
|
|
234
271
|
// setup commands (which may depend on the seeded .env). Each entry becomes an
|
|
@@ -276,13 +313,16 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
276
313
|
blocked: gate.blocked,
|
|
277
314
|
reason: gate.reason,
|
|
278
315
|
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
316
|
+
// What this run deliberately did not touch. Carried out rather than dropped
|
|
317
|
+
// because "provisioned, and left four of your files alone" is a different
|
|
318
|
+
// report from "provisioned", and the caller cannot reconstruct it.
|
|
319
|
+
untouched: gate.foreign || [],
|
|
279
320
|
worktreePath: spec.worktreePath,
|
|
280
321
|
branch: spec.branch,
|
|
281
322
|
projectName: spec.projectName,
|
|
282
323
|
slot: wantsDocker ? slot : null,
|
|
283
324
|
portOffset: offset,
|
|
284
325
|
envContents,
|
|
285
|
-
openCommand,
|
|
286
326
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
287
327
|
seedCommands: gate.blocked ? [] : seedCommands,
|
|
288
328
|
setupCommands: gate.blocked ? [] : setupCommands,
|
|
@@ -306,8 +346,7 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
306
346
|
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
307
347
|
* refusal — it is the re-run, and the answer is "already attached".
|
|
308
348
|
*
|
|
309
|
-
* There is no bootstrap
|
|
310
|
-
* dependencies, and no new session is being opened.
|
|
349
|
+
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
311
350
|
*/
|
|
312
351
|
function planCheckoutUp(spec, ctx, config) {
|
|
313
352
|
const base = ctx.base || (config && config.baseBranch) || 'main'
|