@skitterbyte/skitterspec-linear 13.0.0 → 15.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,26 @@ 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
+ BUTTON_SETS,
80
+ DEFAULT_BUTTON_SET,
81
+ reviewGatePath,
82
+ readGate,
83
+ writeGate,
84
+ armGate,
85
+ disarmGate,
86
+ gateState,
77
87
  } = require('./env/review.js')
78
88
  const { planUp, planCheckoutUp } = require('./env/provision.js')
79
89
  const { classifyDirtyTree } = require('./env/classify.js')
90
+ const { isGitCommit } = require('./env/commitcmd.js')
80
91
  const { planDown, planDownCheckout } = require('./env/teardown.js')
81
92
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
82
93
  const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
@@ -1624,11 +1635,217 @@ function verdictSaid(v) {
1624
1635
  // thing that will happen to the repo and the reader should see it coming.
1625
1636
  if (v.effective === 'commit') return `committing with ${v.commitWith}`
1626
1637
  if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
1638
+ // Names what it does NOT do, because the reader of a mid-run page has just
1639
+ // pressed a green button and must not read it as a commit.
1640
+ if (v.effective === 'continue') return 'read — carrying on, nothing committed'
1627
1641
  if (v.effective === 'changes') return 'changes requested'
1628
1642
  return 'discuss first'
1629
1643
  }
1630
1644
 
1645
+ /**
1646
+ * Resolve the spec and its page path for a gate verb, or say why not.
1647
+ *
1648
+ * Shared by `arm`, `gate` and `skip` so all three answer about the same
1649
+ * sidecar the render writes beside the page — `--out` moves them together, and
1650
+ * a gate keyed to a different path than its page is a gate nobody can clear.
1651
+ *
1652
+ * It never throws. Resolution failure is a cannot-tell for these verbs, not an
1653
+ * error: `gate --check` is called by a commit hook, and a hook that fails on a
1654
+ * repo it could not resolve would block every commit in it.
1655
+ */
1656
+ function gateTarget(dir, config, specArg) {
1657
+ try {
1658
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1659
+ return { spec, out: reviewOutPath(dir, spec.folder), reason: null }
1660
+ } catch (err) {
1661
+ return { spec: null, out: null, reason: err.message }
1662
+ }
1663
+ }
1664
+
1665
+ // `review arm` — a phase ended, and its diff is now owed a verdict.
1666
+ function specEnvReviewArm(dir, config, specArg, flags) {
1667
+ const target = gateTarget(dir, config, specArg)
1668
+ if (!target.spec) {
1669
+ // Arming is a best-effort half of a phase ending; the phase is still built.
1670
+ process.stdout.write(`spec-env review arm: cannot tell which spec — ${target.reason}\n`)
1671
+ return
1672
+ }
1673
+ const read = readGate(target.out, target.spec.folder)
1674
+ if (read.corrupt) {
1675
+ // Same rule as every other sidecar: never write over a file we could not
1676
+ // read. Here that also means never claiming to have armed something.
1677
+ process.stdout.write(
1678
+ `spec-env review arm: ${reviewGatePath(target.out)} is not readable JSON — ` +
1679
+ 'move it aside rather than losing the history it holds.\n',
1680
+ )
1681
+ return
1682
+ }
1683
+ const phase = flags.phase === undefined ? null : flags.phase
1684
+ const before = read.gate
1685
+ const gate = armGate(before, { at: new Date().toISOString(), phase })
1686
+ writeGate(target.out, gate)
1687
+ const again = before.armed && gate.armedAt === before.armedAt
1688
+ if (flags.json) {
1689
+ process.stdout.write(JSON.stringify({ spec: target.spec.folder, armed: true, armedAt: gate.armedAt, phase: gate.phase, alreadyArmed: again }, null, 2) + '\n')
1690
+ return
1691
+ }
1692
+ process.stdout.write(
1693
+ `spec-env review arm: ${target.spec.folder} is awaiting a verdict` +
1694
+ `${gate.phase ? ` (phase ${gate.phase})` : ''}` +
1695
+ `${again ? ' — already was, since ' + String(gate.armedAt).slice(0, 19) : ''}\n`,
1696
+ )
1697
+ }
1698
+
1699
+ /**
1700
+ * `review gate` — is anything owed?
1701
+ *
1702
+ * `--check` is the one call a commit hook makes, and it exits non-zero ONLY on
1703
+ * `armed`: a positive signal, read from a present and parseable sidecar. Every
1704
+ * other state — cleared, unreadable, versioned past this engine, switched off
1705
+ * — exits 0 and says which, because a check that accuses on an absence accuses
1706
+ * healthy repos (`.claude/rules/negative-checks.md`).
1707
+ */
1708
+ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1709
+ // `--for-command` is the hook's half: it asks about a command line rather
1710
+ // than about the repo, and a command that is not a commit is simply not this
1711
+ // check's business. Answered FIRST and in silence, because the overwhelming
1712
+ // majority of tool calls land here and every one of them must cost nothing
1713
+ // and say nothing.
1714
+ if (flags.forCommand !== undefined && !isGitCommit(flags.forCommand)) return
1715
+
1716
+ const target = gateTarget(dir, config, specArg)
1717
+ let judged = target.spec
1718
+ ? gateState({ ...readGate(target.out, target.spec.folder), required: config.review.required })
1719
+ : { state: 'unknown', reason: target.reason, gate: null }
1720
+
1721
+ // ASKED ABOUT A COMMAND, the question is narrower than "is anything owed in
1722
+ // this repo": it is "does the commit happening HERE owe a verdict". The bare
1723
+ // resolution answers with the sole provisioned spec wherever you stand, which
1724
+ // is right for a person typing the verb and wrong for this — it denied a
1725
+ // commit on the base branch because some other spec was mid-review, which is
1726
+ // exactly how a backlog spec authored from the primary checkout (the thing
1727
+ // `commit-trailers.md` asks for) would be blocked by unrelated work.
1728
+ //
1729
+ // So it wants a POSITIVE signal (`.claude/rules/negative-checks.md` rule 1):
1730
+ // this commit is running inside that spec's own worktree. Anything else —
1731
+ // the primary checkout, another spec's tree, a path that cannot be resolved —
1732
+ // is a cannot-tell, and cannot-tell allows.
1733
+ if (flags.forCommand !== undefined && judged.state === 'armed') {
1734
+ const inside = (child, parent) => {
1735
+ try {
1736
+ const rel = path.relative(fs.realpathSync(parent), fs.realpathSync(child))
1737
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
1738
+ } catch {
1739
+ return false
1740
+ }
1741
+ }
1742
+ if (!inside(invokedFrom, target.spec.worktreePath)) {
1743
+ judged = {
1744
+ state: 'unknown',
1745
+ reason: `this command is not running inside ${target.spec.folder}'s worktree`,
1746
+ gate: judged.gate,
1747
+ }
1748
+ }
1749
+ }
1750
+
1751
+ if (flags.json) {
1752
+ process.stdout.write(
1753
+ JSON.stringify(
1754
+ {
1755
+ spec: target.spec ? target.spec.folder : null,
1756
+ state: judged.state,
1757
+ reason: judged.reason,
1758
+ armedAt: judged.gate ? judged.gate.armedAt : null,
1759
+ phase: judged.gate ? judged.gate.phase : null,
1760
+ required: config.review.required,
1761
+ log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
1762
+ },
1763
+ null,
1764
+ 2,
1765
+ ) + '\n',
1766
+ )
1767
+ } else if (judged.state === 'armed') {
1768
+ const g = judged.gate
1769
+ process.stdout.write(
1770
+ `spec-env review gate: ${target.spec.folder} is awaiting a verdict` +
1771
+ `${g.phase ? ` (phase ${g.phase})` : ''}` +
1772
+ `${g.armedAt ? ` since ${String(g.armedAt).slice(0, 19)}` : ''}\n` +
1773
+ ' read the page and send a verdict, or record why you are moving on:\n' +
1774
+ ' skitterspec spec-env review skip "<reason>"\n',
1775
+ )
1776
+ } else if (judged.state === 'clear') {
1777
+ process.stdout.write(`spec-env review gate: ${target.spec.folder} owes nothing — ${judged.reason}\n`)
1778
+ } else {
1779
+ process.stdout.write(
1780
+ `spec-env review gate: cannot tell — ${judged.reason}.\n` +
1781
+ ' nothing is being claimed, and nothing is blocked.\n',
1782
+ )
1783
+ }
1784
+
1785
+ // The exit status is the whole interface for a hook, so it is set from the
1786
+ // one state that is evidence and never from the two that are not.
1787
+ if (flags.check && judged.state === 'armed') process.exitCode = 1
1788
+ }
1789
+
1790
+ // `review skip` — move on without a verdict, on the record.
1791
+ function specEnvReviewSkip(dir, config, reason, flags) {
1792
+ const said = String(reason || '').trim()
1793
+ if (!said) {
1794
+ // The reason IS the feature. A skip with no reason is the silence this
1795
+ // whole gate exists to replace, so it is refused rather than defaulted.
1796
+ process.stdout.write(
1797
+ 'spec-env review skip: needs a reason — skitterspec spec-env review skip "<why>"\n',
1798
+ )
1799
+ process.exitCode = 1
1800
+ return
1801
+ }
1802
+ const target = gateTarget(dir, config, null)
1803
+ if (!target.spec) {
1804
+ process.stdout.write(`spec-env review skip: cannot tell which spec — ${target.reason}\n`)
1805
+ process.exitCode = 1
1806
+ return
1807
+ }
1808
+ const read = readGate(target.out, target.spec.folder)
1809
+ if (read.corrupt) {
1810
+ process.stdout.write(
1811
+ `spec-env review skip: ${reviewGatePath(target.out)} is not readable JSON — ` +
1812
+ 'move it aside rather than losing the history it holds.\n',
1813
+ )
1814
+ process.exitCode = 1
1815
+ return
1816
+ }
1817
+ const result = disarmGate(read.gate, { at: new Date().toISOString(), by: 'skip', reason: said })
1818
+ if (!result.logged) {
1819
+ // Nothing was owed, so nothing is recorded: a log entry here would claim a
1820
+ // decision was taken about an obligation that did not exist.
1821
+ process.stdout.write(`spec-env review skip: ${target.spec.folder} owes nothing — nothing to skip\n`)
1822
+ return
1823
+ }
1824
+ writeGate(target.out, result.gate)
1825
+ if (flags.json) {
1826
+ process.stdout.write(JSON.stringify({ spec: target.spec.folder, skipped: true, reason: said }, null, 2) + '\n')
1827
+ return
1828
+ }
1829
+ process.stdout.write(
1830
+ `spec-env review skip: ${target.spec.folder} moved on without a verdict\n reason: ${said}\n`,
1831
+ )
1832
+ }
1833
+
1631
1834
  async function specEnvReview(dir, config, specArg, flags) {
1835
+ // REFUSED BY NAME, never coerced to the default. A typo'd button set silently
1836
+ // rendering the committing page is the same failure the verdict validator
1837
+ // refuses for the same reason: a caller asking for the mid-run page and
1838
+ // getting the committing one would offer a reader a commit on unfinished
1839
+ // work, and nothing would have said so.
1840
+ if (flags.buttons !== null && !BUTTON_SETS.includes(flags.buttons)) {
1841
+ process.stdout.write(
1842
+ `spec-env review: --buttons ${JSON.stringify(flags.buttons)} is not one of ` +
1843
+ `${BUTTON_SETS.join(', ')} — nothing rendered.\n`,
1844
+ )
1845
+ return
1846
+ }
1847
+ const buttons = flags.buttons || DEFAULT_BUTTON_SET
1848
+
1632
1849
  // An unknown name throws here rather than falling back to the branch: a review
1633
1850
  // of the wrong spec looks exactly like a review of the right one.
1634
1851
  const spec = resolveSpecWithWorktree(dir, config, specArg)
@@ -1690,11 +1907,51 @@ async function specEnvReview(dir, config, specArg, flags) {
1690
1907
  let sentVerdict = null
1691
1908
  let claimed = null
1692
1909
 
1910
+ // `--claim-since <iso>` resolves to a code and then joins the ordinary claim
1911
+ // path below. THE ENGINE PICKS SO THE AGENT DOES NOT: an agent left to find
1912
+ // "the pass that just arrived" reads the store and chooses, and the rule that
1913
+ // it must not choose becomes a request. Here the window is the only input, and
1914
+ // an answer of anything but exactly one pass acts on nothing.
1915
+ let claimCode = flags.claim
1916
+ if (!claimCode && flags.claimSince) {
1917
+ const heldRead = readPending(out, spec.folder)
1918
+ if (heldRead.corrupt) {
1919
+ process.stdout.write(
1920
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
1921
+ 'move it aside rather than losing the passes it holds.\n',
1922
+ )
1923
+ return
1924
+ }
1925
+ const window = passesSince(heldRead.pending, flags.claimSince)
1926
+ if (!window.usable) {
1927
+ process.stdout.write(
1928
+ `spec-env review: --claim-since ${flags.claimSince} is not a timestamp — nothing claimed\n`,
1929
+ )
1930
+ return
1931
+ }
1932
+ if (window.codes.length === 0) {
1933
+ process.stdout.write(
1934
+ `spec-env review: no pass has arrived since ${flags.claimSince} — nothing claimed\n`,
1935
+ )
1936
+ return
1937
+ }
1938
+ if (window.codes.length > 1) {
1939
+ // Names the count, never the codes — the same silence a wrong `--claim`
1940
+ // keeps, for the same reason. The operator has them; the page prints them.
1941
+ process.stdout.write(
1942
+ `spec-env review: ${window.codes.length} passes arrived in that window — ` +
1943
+ 'claim one by its code rather than guessing between them.\n',
1944
+ )
1945
+ return
1946
+ }
1947
+ claimCode = window.codes[0]
1948
+ }
1949
+
1693
1950
  // A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
1694
1951
  // pass out of the holding area and hands it to exactly the same merge a
1695
1952
  // pasted blob goes through, so nothing downstream can tell — or behave
1696
1953
  // differently — by how the pass arrived.
1697
- if (flags.claim) {
1954
+ if (claimCode) {
1698
1955
  const heldRead = readPending(out, spec.folder)
1699
1956
  if (heldRead.corrupt) {
1700
1957
  // Same rule as the notes sidecar: a file we cannot parse is not "nothing
@@ -1705,7 +1962,7 @@ async function specEnvReview(dir, config, specArg, flags) {
1705
1962
  )
1706
1963
  return
1707
1964
  }
1708
- const result = claimPending(heldRead.pending, String(flags.claim).trim())
1965
+ const result = claimPending(heldRead.pending, String(claimCode).trim())
1709
1966
  if (!result.pass) {
1710
1967
  // NO FALLBACK, EVER. Not "the only one", not "the most recent" — either
1711
1968
  // would let a pass nobody read out reach the review, which is the whole
@@ -1869,6 +2126,29 @@ async function specEnvReview(dir, config, specArg, flags) {
1869
2126
  // read the config itself — one answer, from the engine that owns it.
1870
2127
  commitWith: config.review.commitWith,
1871
2128
  }
2129
+
2130
+ // A COMMITTING verdict is what the gate was waiting for, so it clears it —
2131
+ // and only it. `changes` leaves the gate armed deliberately: the work
2132
+ // happens, the page re-renders, and the next verdict is the exit. `discuss`
2133
+ // likewise, including a REFUSED commit, which did not happen and must not
2134
+ // clear an obligation on the strength of having been asked for.
2135
+ if (judged.honoured && COMMITTING.includes(judged.effective)) {
2136
+ const gateRead = readGate(out, spec.folder)
2137
+ if (!gateRead.corrupt) {
2138
+ const result = disarmGate(gateRead.gate, {
2139
+ at: new Date().toISOString(),
2140
+ by: 'verdict',
2141
+ reason: judged.effective,
2142
+ })
2143
+ if (result.logged) {
2144
+ writeGate(out, result.gate)
2145
+ verdictReport.gateCleared = true
2146
+ }
2147
+ }
2148
+ // A corrupt gate is left exactly as it is. It already reads as
2149
+ // cannot-tell everywhere, so it refuses nothing — there is no obligation
2150
+ // to clear, and writing over it would lose the log it holds.
2151
+ }
1872
2152
  }
1873
2153
 
1874
2154
  // What the last decision PRODUCED — written after the thing it asked for has
@@ -1898,8 +2178,14 @@ async function specEnvReview(dir, config, specArg, flags) {
1898
2178
  // request rather than a discipline.
1899
2179
  const waiting = describePending(readPending(out, spec.folder).pending)
1900
2180
 
2181
+ // Read AFTER the verdict half above, so a claim that just cleared the gate
2182
+ // renders as cleared rather than as still owing. Corrupt contributes nothing:
2183
+ // the page is a convenience and the gate is not what it is for.
2184
+ const gateNow = readGate(out, spec.folder)
2185
+ const gate = gateNow.corrupt ? null : gateNow.gate
2186
+
1901
2187
  const now = new Date().toISOString()
1902
- let data = collectReview({ spec, git, mode, ref, base, now, notes })
2188
+ let data = collectReview({ spec, git, mode, ref, base, now, notes, gate, buttons })
1903
2189
 
1904
2190
  // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
1905
2191
  // ends in: the page is rendered before the commit, the commit happens
@@ -1931,6 +2217,8 @@ async function specEnvReview(dir, config, specArg, flags) {
1931
2217
  base: fallbackBase,
1932
2218
  now,
1933
2219
  notes,
2220
+ gate,
2221
+ buttons,
1934
2222
  fellBack: true,
1935
2223
  })
1936
2224
  if (wider.totals.files > 0) {
@@ -2023,6 +2311,10 @@ async function specEnvReview(dir, config, specArg, flags) {
2023
2311
  urlFile,
2024
2312
  url,
2025
2313
  notesFile: reviewNotesPath(out),
2314
+ // Absent stays absent, exactly as it is in the page payload: the
2315
+ // committing set is what a caller that did not ask always got, so
2316
+ // reporting it would make every existing consumer see a new key.
2317
+ ...(data.buttons ? { buttons: data.buttons } : {}),
2026
2318
  reviewed: Boolean(data.review),
2027
2319
  totals: data.totals,
2028
2320
  notes: data.notes,
@@ -3176,6 +3468,7 @@ async function specEnv(rest) {
3176
3468
  outcome: null,
3177
3469
  claim: null,
3178
3470
  drop: null,
3471
+ buttons: null,
3179
3472
  json: false,
3180
3473
  }
3181
3474
  for (let i = 0; i < args.length; i++) {
@@ -3190,14 +3483,19 @@ async function specEnv(rest) {
3190
3483
  else if (args[i] === '--port') flags.port = args[++i]
3191
3484
  else if (args[i] === '--host') flags.host = args[++i]
3192
3485
  else if (args[i] === '--publish-copy') flags.publishCopy = true
3486
+ else if (args[i] === '--buttons') flags.buttons = args[++i]
3193
3487
  else if (args[i] === '--out') flags.out = args[++i]
3194
3488
  else if (args[i] === '--review') flags.review = args[++i]
3195
3489
  else if (args[i] === '--notes') flags.notes = args[++i]
3196
3490
  else if (args[i] === '--resolve') flags.resolve = args[++i]
3197
3491
  else if (args[i] === '--outcome') flags.outcome = args[++i]
3198
3492
  else if (args[i] === '--claim') flags.claim = args[++i]
3493
+ else if (args[i] === '--claim-since') flags.claimSince = args[++i]
3199
3494
  else if (args[i] === '--drop') flags.drop = args[++i]
3200
3495
  else if (args[i] === '--json') flags.json = true
3496
+ else if (args[i] === '--check') flags.check = true
3497
+ else if (args[i] === '--for-command') flags.forCommand = args[++i]
3498
+ else if (args[i] === '--phase') flags.phase = args[++i]
3201
3499
  else if (args[i] === '--record-primary') flags.recordPrimary = true
3202
3500
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
3203
3501
  else positional.push(args[i])
@@ -3259,6 +3557,24 @@ async function specEnv(rest) {
3259
3557
  await specEnvReviewServe(dir, config, flags)
3260
3558
  break
3261
3559
  }
3560
+ // The gate verbs sit here for the same reason `serve` does: they answer
3561
+ // about the page this command renders, keyed to the same path, and a
3562
+ // sibling verb would have to re-derive every bit of that.
3563
+ if (positional[0] === 'arm') {
3564
+ specEnvReviewArm(dir, config, positional[1], flags)
3565
+ break
3566
+ }
3567
+ if (positional[0] === 'gate') {
3568
+ specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
3569
+ break
3570
+ }
3571
+ if (positional[0] === 'skip') {
3572
+ // The one positional is the REASON, not a spec: the two are
3573
+ // indistinguishable as free text, and the spec is the one thing this
3574
+ // engine can already resolve from where you are standing.
3575
+ specEnvReviewSkip(dir, config, positional[1], flags)
3576
+ break
3577
+ }
3262
3578
  await specEnvReview(dir, config, positional[0], flags)
3263
3579
  break
3264
3580
  case 'live':
@@ -3266,8 +3582,14 @@ async function specEnv(rest) {
3266
3582
  break
3267
3583
  default:
3268
3584
  process.stdout.write(
3269
- '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' +
3585
+ '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>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n' +
3270
3586
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
3587
+ ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
3588
+ ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
3589
+ ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
3590
+ ' review skip "<reason>" move on without one, on the record\n' +
3591
+ ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
3592
+ ' review [spec] --buttons midrun the page offers Continue, not a commit\n' +
3271
3593
  ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3272
3594
  ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
3273
3595
  ' 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)) {