@skitterbyte/skitterspec-linear 12.0.0 → 14.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.
Files changed (58) hide show
  1. package/MIGRATION.md +296 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +7 -3
  8. package/assets/core/env.config.md +90 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/hooks/review-gate.js +141 -0
  11. package/assets/review/page.html +1787 -0
  12. package/assets/rules/spec-planning.md +250 -10
  13. package/assets/rules/spec-reports.md +321 -0
  14. package/assets/skills/spec/SKILL.md +33 -5
  15. package/assets/skills/spec-bug/SKILL.md +172 -9
  16. package/assets/skills/spec-cancel/SKILL.md +98 -21
  17. package/assets/skills/spec-claim/SKILL.md +114 -0
  18. package/assets/skills/spec-complete/SKILL.md +94 -25
  19. package/assets/skills/spec-diff/SKILL.md +678 -0
  20. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  21. package/assets/skills/spec-init/SKILL.md +56 -7
  22. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  23. package/assets/skills/spec-list/SKILL.md +218 -0
  24. package/assets/skills/spec-next/SKILL.md +419 -7
  25. package/assets/skills/spec-push/SKILL.md +32 -8
  26. package/assets/skills/spec-review/SKILL.md +40 -5
  27. package/assets/skills/spec-reviewed/SKILL.md +258 -0
  28. package/assets/skills/spec-start/SKILL.md +386 -106
  29. package/assets/skills/spec-status/SKILL.md +24 -2
  30. package/assets/skills/spec-sync/SKILL.md +40 -4
  31. package/assets/skills/spec-to-main/SKILL.md +28 -6
  32. package/package.json +11 -7
  33. package/src/cli.js +1808 -89
  34. package/src/env/building.js +143 -0
  35. package/src/env/commitcmd.js +108 -0
  36. package/src/env/config.js +58 -9
  37. package/src/env/hooks.js +117 -0
  38. package/src/env/provision.js +54 -15
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +295 -9
  42. package/src/env/review.js +1536 -0
  43. package/src/env/serve.js +573 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/init.js +150 -1
  46. package/src/vendor/linear/api.js +104 -1
  47. package/src/vendor/linear/cli-sync.js +854 -17
  48. package/src/vendor/linear/config.js +8 -0
  49. package/src/vendor/linear/credentials.js +94 -0
  50. package/src/vendor/linear/doctor.js +35 -0
  51. package/src/vendor/linear/identity.js +105 -0
  52. package/src/vendor/linear/mcp.js +26 -0
  53. package/src/vendor/sync-core/index.js +6 -2
  54. package/src/vendor/sync-core/src/compare.js +49 -3
  55. package/src/vendor/sync-core/src/normalize.js +30 -0
  56. package/src/vendor/sync-core/src/push.js +11 -1
  57. package/src/vendor/sync-core/src/write.js +38 -0
  58. 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 the branch `/spec-start` pushed: "prompt" (default)
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
  }
@@ -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
+ }
@@ -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`), the rendered `.env` contents, and the expanded opener
9
- * but performs no side effects itself. The caller (the CLI) reads/allocates the
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, expandOpenCommand } = require('./render.js')
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
- if (foreign.length) {
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 ${owned.map((p) => `"${p}"`).join(' ')}`,
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, openCommand, commands, seedCommands,
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 and no opener: the primary checkout already has its
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'