@skitterbyte/skitterspec-linear 13.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.
package/src/cli.js CHANGED
@@ -68,15 +68,24 @@ const {
68
68
  readPending,
69
69
  writePending,
70
70
  claimPending,
71
+ passesSince,
71
72
  describePending,
72
73
  pendingAge,
73
74
  reviewPendingPath,
74
75
  validateResolutions,
75
76
  mergeNotes,
76
77
  applyResolutions,
78
+ COMMITTING,
79
+ reviewGatePath,
80
+ readGate,
81
+ writeGate,
82
+ armGate,
83
+ disarmGate,
84
+ gateState,
77
85
  } = require('./env/review.js')
78
86
  const { planUp, planCheckoutUp } = require('./env/provision.js')
79
87
  const { classifyDirtyTree } = require('./env/classify.js')
88
+ const { isGitCommit } = require('./env/commitcmd.js')
80
89
  const { planDown, planDownCheckout } = require('./env/teardown.js')
81
90
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
82
91
  const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
@@ -1628,6 +1637,195 @@ function verdictSaid(v) {
1628
1637
  return 'discuss first'
1629
1638
  }
1630
1639
 
1640
+ /**
1641
+ * Resolve the spec and its page path for a gate verb, or say why not.
1642
+ *
1643
+ * Shared by `arm`, `gate` and `skip` so all three answer about the same
1644
+ * sidecar the render writes beside the page — `--out` moves them together, and
1645
+ * a gate keyed to a different path than its page is a gate nobody can clear.
1646
+ *
1647
+ * It never throws. Resolution failure is a cannot-tell for these verbs, not an
1648
+ * error: `gate --check` is called by a commit hook, and a hook that fails on a
1649
+ * repo it could not resolve would block every commit in it.
1650
+ */
1651
+ function gateTarget(dir, config, specArg) {
1652
+ try {
1653
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1654
+ return { spec, out: reviewOutPath(dir, spec.folder), reason: null }
1655
+ } catch (err) {
1656
+ return { spec: null, out: null, reason: err.message }
1657
+ }
1658
+ }
1659
+
1660
+ // `review arm` — a phase ended, and its diff is now owed a verdict.
1661
+ function specEnvReviewArm(dir, config, specArg, flags) {
1662
+ const target = gateTarget(dir, config, specArg)
1663
+ if (!target.spec) {
1664
+ // Arming is a best-effort half of a phase ending; the phase is still built.
1665
+ process.stdout.write(`spec-env review arm: cannot tell which spec — ${target.reason}\n`)
1666
+ return
1667
+ }
1668
+ const read = readGate(target.out, target.spec.folder)
1669
+ if (read.corrupt) {
1670
+ // Same rule as every other sidecar: never write over a file we could not
1671
+ // read. Here that also means never claiming to have armed something.
1672
+ process.stdout.write(
1673
+ `spec-env review arm: ${reviewGatePath(target.out)} is not readable JSON — ` +
1674
+ 'move it aside rather than losing the history it holds.\n',
1675
+ )
1676
+ return
1677
+ }
1678
+ const phase = flags.phase === undefined ? null : flags.phase
1679
+ const before = read.gate
1680
+ const gate = armGate(before, { at: new Date().toISOString(), phase })
1681
+ writeGate(target.out, gate)
1682
+ const again = before.armed && gate.armedAt === before.armedAt
1683
+ if (flags.json) {
1684
+ process.stdout.write(JSON.stringify({ spec: target.spec.folder, armed: true, armedAt: gate.armedAt, phase: gate.phase, alreadyArmed: again }, null, 2) + '\n')
1685
+ return
1686
+ }
1687
+ process.stdout.write(
1688
+ `spec-env review arm: ${target.spec.folder} is awaiting a verdict` +
1689
+ `${gate.phase ? ` (phase ${gate.phase})` : ''}` +
1690
+ `${again ? ' — already was, since ' + String(gate.armedAt).slice(0, 19) : ''}\n`,
1691
+ )
1692
+ }
1693
+
1694
+ /**
1695
+ * `review gate` — is anything owed?
1696
+ *
1697
+ * `--check` is the one call a commit hook makes, and it exits non-zero ONLY on
1698
+ * `armed`: a positive signal, read from a present and parseable sidecar. Every
1699
+ * other state — cleared, unreadable, versioned past this engine, switched off
1700
+ * — exits 0 and says which, because a check that accuses on an absence accuses
1701
+ * healthy repos (`.claude/rules/negative-checks.md`).
1702
+ */
1703
+ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1704
+ // `--for-command` is the hook's half: it asks about a command line rather
1705
+ // than about the repo, and a command that is not a commit is simply not this
1706
+ // check's business. Answered FIRST and in silence, because the overwhelming
1707
+ // majority of tool calls land here and every one of them must cost nothing
1708
+ // and say nothing.
1709
+ if (flags.forCommand !== undefined && !isGitCommit(flags.forCommand)) return
1710
+
1711
+ const target = gateTarget(dir, config, specArg)
1712
+ let judged = target.spec
1713
+ ? gateState({ ...readGate(target.out, target.spec.folder), required: config.review.required })
1714
+ : { state: 'unknown', reason: target.reason, gate: null }
1715
+
1716
+ // ASKED ABOUT A COMMAND, the question is narrower than "is anything owed in
1717
+ // this repo": it is "does the commit happening HERE owe a verdict". The bare
1718
+ // resolution answers with the sole provisioned spec wherever you stand, which
1719
+ // is right for a person typing the verb and wrong for this — it denied a
1720
+ // commit on the base branch because some other spec was mid-review, which is
1721
+ // exactly how a backlog spec authored from the primary checkout (the thing
1722
+ // `commit-trailers.md` asks for) would be blocked by unrelated work.
1723
+ //
1724
+ // So it wants a POSITIVE signal (`.claude/rules/negative-checks.md` rule 1):
1725
+ // this commit is running inside that spec's own worktree. Anything else —
1726
+ // the primary checkout, another spec's tree, a path that cannot be resolved —
1727
+ // is a cannot-tell, and cannot-tell allows.
1728
+ if (flags.forCommand !== undefined && judged.state === 'armed') {
1729
+ const inside = (child, parent) => {
1730
+ try {
1731
+ const rel = path.relative(fs.realpathSync(parent), fs.realpathSync(child))
1732
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
1733
+ } catch {
1734
+ return false
1735
+ }
1736
+ }
1737
+ if (!inside(invokedFrom, target.spec.worktreePath)) {
1738
+ judged = {
1739
+ state: 'unknown',
1740
+ reason: `this command is not running inside ${target.spec.folder}'s worktree`,
1741
+ gate: judged.gate,
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ if (flags.json) {
1747
+ process.stdout.write(
1748
+ JSON.stringify(
1749
+ {
1750
+ spec: target.spec ? target.spec.folder : null,
1751
+ state: judged.state,
1752
+ reason: judged.reason,
1753
+ armedAt: judged.gate ? judged.gate.armedAt : null,
1754
+ phase: judged.gate ? judged.gate.phase : null,
1755
+ required: config.review.required,
1756
+ log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
1757
+ },
1758
+ null,
1759
+ 2,
1760
+ ) + '\n',
1761
+ )
1762
+ } else if (judged.state === 'armed') {
1763
+ const g = judged.gate
1764
+ process.stdout.write(
1765
+ `spec-env review gate: ${target.spec.folder} is awaiting a verdict` +
1766
+ `${g.phase ? ` (phase ${g.phase})` : ''}` +
1767
+ `${g.armedAt ? ` since ${String(g.armedAt).slice(0, 19)}` : ''}\n` +
1768
+ ' read the page and send a verdict, or record why you are moving on:\n' +
1769
+ ' skitterspec spec-env review skip "<reason>"\n',
1770
+ )
1771
+ } else if (judged.state === 'clear') {
1772
+ process.stdout.write(`spec-env review gate: ${target.spec.folder} owes nothing — ${judged.reason}\n`)
1773
+ } else {
1774
+ process.stdout.write(
1775
+ `spec-env review gate: cannot tell — ${judged.reason}.\n` +
1776
+ ' nothing is being claimed, and nothing is blocked.\n',
1777
+ )
1778
+ }
1779
+
1780
+ // The exit status is the whole interface for a hook, so it is set from the
1781
+ // one state that is evidence and never from the two that are not.
1782
+ if (flags.check && judged.state === 'armed') process.exitCode = 1
1783
+ }
1784
+
1785
+ // `review skip` — move on without a verdict, on the record.
1786
+ function specEnvReviewSkip(dir, config, reason, flags) {
1787
+ const said = String(reason || '').trim()
1788
+ if (!said) {
1789
+ // The reason IS the feature. A skip with no reason is the silence this
1790
+ // whole gate exists to replace, so it is refused rather than defaulted.
1791
+ process.stdout.write(
1792
+ 'spec-env review skip: needs a reason — skitterspec spec-env review skip "<why>"\n',
1793
+ )
1794
+ process.exitCode = 1
1795
+ return
1796
+ }
1797
+ const target = gateTarget(dir, config, null)
1798
+ if (!target.spec) {
1799
+ process.stdout.write(`spec-env review skip: cannot tell which spec — ${target.reason}\n`)
1800
+ process.exitCode = 1
1801
+ return
1802
+ }
1803
+ const read = readGate(target.out, target.spec.folder)
1804
+ if (read.corrupt) {
1805
+ process.stdout.write(
1806
+ `spec-env review skip: ${reviewGatePath(target.out)} is not readable JSON — ` +
1807
+ 'move it aside rather than losing the history it holds.\n',
1808
+ )
1809
+ process.exitCode = 1
1810
+ return
1811
+ }
1812
+ const result = disarmGate(read.gate, { at: new Date().toISOString(), by: 'skip', reason: said })
1813
+ if (!result.logged) {
1814
+ // Nothing was owed, so nothing is recorded: a log entry here would claim a
1815
+ // decision was taken about an obligation that did not exist.
1816
+ process.stdout.write(`spec-env review skip: ${target.spec.folder} owes nothing — nothing to skip\n`)
1817
+ return
1818
+ }
1819
+ writeGate(target.out, result.gate)
1820
+ if (flags.json) {
1821
+ process.stdout.write(JSON.stringify({ spec: target.spec.folder, skipped: true, reason: said }, null, 2) + '\n')
1822
+ return
1823
+ }
1824
+ process.stdout.write(
1825
+ `spec-env review skip: ${target.spec.folder} moved on without a verdict\n reason: ${said}\n`,
1826
+ )
1827
+ }
1828
+
1631
1829
  async function specEnvReview(dir, config, specArg, flags) {
1632
1830
  // An unknown name throws here rather than falling back to the branch: a review
1633
1831
  // of the wrong spec looks exactly like a review of the right one.
@@ -1690,11 +1888,51 @@ async function specEnvReview(dir, config, specArg, flags) {
1690
1888
  let sentVerdict = null
1691
1889
  let claimed = null
1692
1890
 
1891
+ // `--claim-since <iso>` resolves to a code and then joins the ordinary claim
1892
+ // path below. THE ENGINE PICKS SO THE AGENT DOES NOT: an agent left to find
1893
+ // "the pass that just arrived" reads the store and chooses, and the rule that
1894
+ // it must not choose becomes a request. Here the window is the only input, and
1895
+ // an answer of anything but exactly one pass acts on nothing.
1896
+ let claimCode = flags.claim
1897
+ if (!claimCode && flags.claimSince) {
1898
+ const heldRead = readPending(out, spec.folder)
1899
+ if (heldRead.corrupt) {
1900
+ process.stdout.write(
1901
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
1902
+ 'move it aside rather than losing the passes it holds.\n',
1903
+ )
1904
+ return
1905
+ }
1906
+ const window = passesSince(heldRead.pending, flags.claimSince)
1907
+ if (!window.usable) {
1908
+ process.stdout.write(
1909
+ `spec-env review: --claim-since ${flags.claimSince} is not a timestamp — nothing claimed\n`,
1910
+ )
1911
+ return
1912
+ }
1913
+ if (window.codes.length === 0) {
1914
+ process.stdout.write(
1915
+ `spec-env review: no pass has arrived since ${flags.claimSince} — nothing claimed\n`,
1916
+ )
1917
+ return
1918
+ }
1919
+ if (window.codes.length > 1) {
1920
+ // Names the count, never the codes — the same silence a wrong `--claim`
1921
+ // keeps, for the same reason. The operator has them; the page prints them.
1922
+ process.stdout.write(
1923
+ `spec-env review: ${window.codes.length} passes arrived in that window — ` +
1924
+ 'claim one by its code rather than guessing between them.\n',
1925
+ )
1926
+ return
1927
+ }
1928
+ claimCode = window.codes[0]
1929
+ }
1930
+
1693
1931
  // A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
1694
1932
  // pass out of the holding area and hands it to exactly the same merge a
1695
1933
  // pasted blob goes through, so nothing downstream can tell — or behave
1696
1934
  // differently — by how the pass arrived.
1697
- if (flags.claim) {
1935
+ if (claimCode) {
1698
1936
  const heldRead = readPending(out, spec.folder)
1699
1937
  if (heldRead.corrupt) {
1700
1938
  // Same rule as the notes sidecar: a file we cannot parse is not "nothing
@@ -1705,7 +1943,7 @@ async function specEnvReview(dir, config, specArg, flags) {
1705
1943
  )
1706
1944
  return
1707
1945
  }
1708
- const result = claimPending(heldRead.pending, String(flags.claim).trim())
1946
+ const result = claimPending(heldRead.pending, String(claimCode).trim())
1709
1947
  if (!result.pass) {
1710
1948
  // NO FALLBACK, EVER. Not "the only one", not "the most recent" — either
1711
1949
  // would let a pass nobody read out reach the review, which is the whole
@@ -1869,6 +2107,29 @@ async function specEnvReview(dir, config, specArg, flags) {
1869
2107
  // read the config itself — one answer, from the engine that owns it.
1870
2108
  commitWith: config.review.commitWith,
1871
2109
  }
2110
+
2111
+ // A COMMITTING verdict is what the gate was waiting for, so it clears it —
2112
+ // and only it. `changes` leaves the gate armed deliberately: the work
2113
+ // happens, the page re-renders, and the next verdict is the exit. `discuss`
2114
+ // likewise, including a REFUSED commit, which did not happen and must not
2115
+ // clear an obligation on the strength of having been asked for.
2116
+ if (judged.honoured && COMMITTING.includes(judged.effective)) {
2117
+ const gateRead = readGate(out, spec.folder)
2118
+ if (!gateRead.corrupt) {
2119
+ const result = disarmGate(gateRead.gate, {
2120
+ at: new Date().toISOString(),
2121
+ by: 'verdict',
2122
+ reason: judged.effective,
2123
+ })
2124
+ if (result.logged) {
2125
+ writeGate(out, result.gate)
2126
+ verdictReport.gateCleared = true
2127
+ }
2128
+ }
2129
+ // A corrupt gate is left exactly as it is. It already reads as
2130
+ // cannot-tell everywhere, so it refuses nothing — there is no obligation
2131
+ // to clear, and writing over it would lose the log it holds.
2132
+ }
1872
2133
  }
1873
2134
 
1874
2135
  // What the last decision PRODUCED — written after the thing it asked for has
@@ -1898,8 +2159,14 @@ async function specEnvReview(dir, config, specArg, flags) {
1898
2159
  // request rather than a discipline.
1899
2160
  const waiting = describePending(readPending(out, spec.folder).pending)
1900
2161
 
2162
+ // Read AFTER the verdict half above, so a claim that just cleared the gate
2163
+ // renders as cleared rather than as still owing. Corrupt contributes nothing:
2164
+ // the page is a convenience and the gate is not what it is for.
2165
+ const gateNow = readGate(out, spec.folder)
2166
+ const gate = gateNow.corrupt ? null : gateNow.gate
2167
+
1901
2168
  const now = new Date().toISOString()
1902
- let data = collectReview({ spec, git, mode, ref, base, now, notes })
2169
+ let data = collectReview({ spec, git, mode, ref, base, now, notes, gate })
1903
2170
 
1904
2171
  // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
1905
2172
  // ends in: the page is rendered before the commit, the commit happens
@@ -1931,6 +2198,7 @@ async function specEnvReview(dir, config, specArg, flags) {
1931
2198
  base: fallbackBase,
1932
2199
  now,
1933
2200
  notes,
2201
+ gate,
1934
2202
  fellBack: true,
1935
2203
  })
1936
2204
  if (wider.totals.files > 0) {
@@ -3196,8 +3464,12 @@ async function specEnv(rest) {
3196
3464
  else if (args[i] === '--resolve') flags.resolve = args[++i]
3197
3465
  else if (args[i] === '--outcome') flags.outcome = args[++i]
3198
3466
  else if (args[i] === '--claim') flags.claim = args[++i]
3467
+ else if (args[i] === '--claim-since') flags.claimSince = args[++i]
3199
3468
  else if (args[i] === '--drop') flags.drop = args[++i]
3200
3469
  else if (args[i] === '--json') flags.json = true
3470
+ else if (args[i] === '--check') flags.check = true
3471
+ else if (args[i] === '--for-command') flags.forCommand = args[++i]
3472
+ else if (args[i] === '--phase') flags.phase = args[++i]
3201
3473
  else if (args[i] === '--record-primary') flags.recordPrimary = true
3202
3474
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
3203
3475
  else positional.push(args[i])
@@ -3259,6 +3531,24 @@ async function specEnv(rest) {
3259
3531
  await specEnvReviewServe(dir, config, flags)
3260
3532
  break
3261
3533
  }
3534
+ // The gate verbs sit here for the same reason `serve` does: they answer
3535
+ // about the page this command renders, keyed to the same path, and a
3536
+ // sibling verb would have to re-derive every bit of that.
3537
+ if (positional[0] === 'arm') {
3538
+ specEnvReviewArm(dir, config, positional[1], flags)
3539
+ break
3540
+ }
3541
+ if (positional[0] === 'gate') {
3542
+ specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
3543
+ break
3544
+ }
3545
+ if (positional[0] === 'skip') {
3546
+ // The one positional is the REASON, not a spec: the two are
3547
+ // indistinguishable as free text, and the spec is the one thing this
3548
+ // engine can already resolve from where you are standing.
3549
+ specEnvReviewSkip(dir, config, positional[1], flags)
3550
+ break
3551
+ }
3262
3552
  await specEnvReview(dir, config, positional[0], flags)
3263
3553
  break
3264
3554
  case 'live':
@@ -3268,6 +3558,11 @@ async function specEnv(rest) {
3268
3558
  process.stdout.write(
3269
3559
  'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--json] [--record-primary] [--assert-primary-clean]\n' +
3270
3560
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
3561
+ ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
3562
+ ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
3563
+ ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
3564
+ ' review skip "<reason>" move on without one, on the record\n' +
3565
+ ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
3271
3566
  ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3272
3567
  ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
3273
3568
  ' A bare `live` takes that spec when the workbench is free, and prints the\n' +
@@ -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
@@ -126,7 +126,19 @@ const DEFAULT_CONFIG = Object.freeze({
126
126
  // a verdict that records itself and does nothing. A review is the guard in
127
127
  // front of an action; recording an approval for SOMEONE ELSE to act on is a
128
128
  // different mechanism, not a value of this key.
129
- review: Object.freeze({ reader: 'detect', servePort: 7777, serveOnRemote: true, commitWith: '/commit' }),
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
+ }),
130
142
  // Live overlay (`spec-env live`). `migrations` is a list of globs marking
131
143
  // migration files; a branch that changes any of them is treated as stateful and
132
144
  // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
@@ -338,6 +350,10 @@ function mergeConfig(base, parsed) {
338
350
  // here. There is nothing it could mean instead: the hand-off has no off
339
351
  // switch, so a blank value is a typo rather than an instruction.
340
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')
341
357
  }
342
358
 
343
359
  if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
@@ -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
+ }