@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/MIGRATION.md +170 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +17 -3
- package/assets/hooks/review-gate.cjs +141 -0
- package/assets/review/page.html +366 -30
- package/assets/rules/spec-planning.md +65 -13
- package/assets/rules/spec-reports.md +136 -16
- package/assets/skills/spec-bug/SKILL.md +38 -23
- package/assets/skills/spec-diff/SKILL.md +171 -8
- package/assets/skills/spec-hotfix/SKILL.md +31 -20
- package/assets/skills/spec-init/SKILL.md +34 -0
- package/assets/skills/spec-next/SKILL.md +167 -26
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +326 -4
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +157 -0
- package/src/env/review.js +249 -3
- package/src/env/serve.js +28 -4
- package/src/init.js +78 -0
- package/src/vendor/sync-core/src/retarget.js +1 -1
- package/src/vendor/sync-core/src/task-block.js +2 -2
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 (
|
|
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(
|
|
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
|
-
|
|
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)) {
|