@skitterbyte/skitterspec 19.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/env/review.js CHANGED
@@ -231,7 +231,7 @@ function resolveReader(config, env = {}) {
231
231
  * clean, not because the caller asked for it — see the fallback in
232
232
  * `specEnvReview` (`cli.js`).
233
233
  */
234
- function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, fellBack = false }) {
234
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false }) {
235
235
  const files = []
236
236
  for (const f of trackedFiles(git, ref)) {
237
237
  const { patch, whole } = patchFor(git, ref, f, false)
@@ -298,6 +298,9 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
298
298
  // Absent stays absent, exactly as `phases` does: a spec this cannot read
299
299
  // adds no key and the page renders its header-less self.
300
300
  ...(context ? { context } : {}),
301
+ // Same rule again: a gate that was never armed and never skipped adds no
302
+ // key at all.
303
+ ...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
301
304
  // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
302
305
  // happen per request — so a page rendered by a stale process looks entirely
303
306
  // right: the counts move, `generatedAt` moves, the diff is correct. Only the
@@ -705,6 +708,199 @@ function claimPending(pending, code) {
705
708
  return { pass, pending: { ...pending, passes: rest }, count: rest.length }
706
709
  }
707
710
 
711
+ /**
712
+ * Which pass arrived inside a WAIT WINDOW. Pure — it selects, never claims.
713
+ *
714
+ * THE WINDOW IS THE SCOPE, and it is what replaces "a person typed the
715
+ * command" when a watch wakes the session instead. A skill that asked to be
716
+ * woken when the store changed knows one thing nothing else does: the moment
717
+ * it started waiting. A pass sent after that, while it was waiting, is the
718
+ * pass it was waiting for — and a pass that was already there is a stranger's,
719
+ * or an older sitting's, and must not be swept up by a wait that was not about
720
+ * it.
721
+ *
722
+ * Three answers, and only one of them acts:
723
+ *
724
+ * - exactly one in the window → that is the pass, named by its code.
725
+ * - none → nothing to claim. Ordinary: the watch can fire on a write that was
726
+ * not a pass at all.
727
+ * - more than one → REFUSE and name the count, never pick. Two people, or two
728
+ * sittings, landed in one window; choosing between them is exactly the guess
729
+ * `claimPending` refuses to make, and the operator has the codes.
730
+ *
731
+ * A pass whose `at` cannot be parsed is never in the window. That is the
732
+ * cannot-tell case routed to inaction (`.claude/rules/negative-checks.md` rule
733
+ * 4): including it would auto-claim a pass whose age — the one tell a stranger
734
+ * has — could not be established.
735
+ */
736
+ function passesSince(pending, since) {
737
+ const from = Date.parse(since)
738
+ if (!Number.isFinite(from)) return { codes: [], usable: false }
739
+ const codes = (pending.passes || [])
740
+ .filter((p) => {
741
+ const at = Date.parse(p.at)
742
+ return Number.isFinite(at) && at >= from
743
+ })
744
+ .map((p) => p.code)
745
+ return { codes, usable: true }
746
+ }
747
+
748
+ /* ==========================================================================
749
+ * The gate — a standing obligation to review, not a message in flight
750
+ *
751
+ * A pending pass is something someone SENT. The gate is something the repo
752
+ * OWES: a phase ended, its page was rendered, and nobody has said what they
753
+ * concluded yet. Kept in its own sidecar for exactly that reason — claiming a
754
+ * pass consumes the pass, while only a COMMITTING verdict or a recorded skip
755
+ * consumes the gate, and one file holding both states would have to encode
756
+ * that difference anyway.
757
+ *
758
+ * It is the one thing in this engine that refuses. The marks still gate
759
+ * nothing and nothing counts them — what this asserts is narrower: a phase
760
+ * that ended is not finished until a person said something about it.
761
+ * ========================================================================== */
762
+
763
+ const GATE_VERSION = 1
764
+
765
+ // How a disarm happened. `verdict` is a review that reached a committing
766
+ // conclusion; `skip` is the operator saying, on the record, that they are
767
+ // moving on without one.
768
+ const DISARMED_BY = ['verdict', 'skip']
769
+
770
+ function reviewGatePath(outPath) {
771
+ return outPath.replace(/\.html$/, '') + '.gate.json'
772
+ }
773
+
774
+ function emptyGate(specFolder) {
775
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
776
+ }
777
+
778
+ /**
779
+ * Read the gate sidecar. Never throws, and reports `corrupt` rather than
780
+ * hiding it — the same three states `readNotes` answers in, for the same
781
+ * reason. Here the third state is load-bearing in the other direction: a gate
782
+ * we cannot parse must never READ AS ARMED, because that would refuse a commit
783
+ * on the strength of a file nobody can interpret.
784
+ */
785
+ function readGate(outPath, specFolder) {
786
+ let raw
787
+ try {
788
+ raw = fs.readFileSync(reviewGatePath(outPath), 'utf8')
789
+ } catch {
790
+ // Absent is the ordinary state — a project that never ends a phase through
791
+ // the review path has no gate file at all.
792
+ return { gate: emptyGate(specFolder), corrupt: false, present: false }
793
+ }
794
+ try {
795
+ const parsed = JSON.parse(raw)
796
+ return {
797
+ gate: {
798
+ version: parsed.version,
799
+ spec: parsed.spec || specFolder,
800
+ armed: parsed.armed === true,
801
+ armedAt: parsed.armedAt || null,
802
+ phase: parsed.phase === undefined ? null : parsed.phase,
803
+ log: Array.isArray(parsed.log) ? parsed.log : [],
804
+ },
805
+ corrupt: false,
806
+ present: true,
807
+ }
808
+ } catch {
809
+ return { gate: emptyGate(specFolder), corrupt: true, present: true }
810
+ }
811
+ }
812
+
813
+ function writeGate(outPath, gate) {
814
+ const p = reviewGatePath(outPath)
815
+ fs.mkdirSync(path.dirname(p), { recursive: true })
816
+ fs.writeFileSync(p, JSON.stringify(gate, null, 2) + '\n')
817
+ return p
818
+ }
819
+
820
+ /**
821
+ * Arm the gate. Pure.
822
+ *
823
+ * IDEMPOTENT ON PURPOSE. Re-rendering a page for a phase already awaiting a
824
+ * verdict must not move `armedAt` — the timestamp answers "how long has this
825
+ * been waiting", and a render is not an event that resets that. A gate armed
826
+ * for a DIFFERENT phase is re-armed, because that is a new obligation.
827
+ */
828
+ function armGate(gate, { at, phase = null }) {
829
+ if (gate.armed && gate.phase === phase) return gate
830
+ return { ...gate, armed: true, armedAt: at, phase }
831
+ }
832
+
833
+ /**
834
+ * Disarm it, and say how. Pure.
835
+ *
836
+ * The log is append-only and nothing reads it back to decide anything — it is
837
+ * the record that a decision was taken, which is the whole value of a skip
838
+ * over a silence. Disarming an already-clear gate logs nothing: there was no
839
+ * obligation, so there is no outcome to record.
840
+ */
841
+ function disarmGate(gate, { at, by, reason = null }) {
842
+ if (!gate.armed) return { gate, logged: false }
843
+ const log = Array.isArray(gate.log) ? gate.log.slice() : []
844
+ log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
845
+ return { gate: { ...gate, armed: false, armedAt: null, phase: null, log }, logged: true }
846
+ }
847
+
848
+ /**
849
+ * What the gate says, in three states. Pure.
850
+ *
851
+ * `armed` is the only one that refuses, and it is reached only by a POSITIVE
852
+ * signal: a sidecar that is present, parseable, and says so
853
+ * (`.claude/rules/negative-checks.md` rule 1). Everything else routes to the
854
+ * harmless branch (rule 4) under its own name:
855
+ *
856
+ * - `clear` — read it, nothing is owed.
857
+ * - `unknown` — could not read it, or the project turned the gate off. Nothing
858
+ * is claimed and nothing refuses.
859
+ *
860
+ * WHAT WOULD FOOL THIS: a gate armed for a phase whose work has since been
861
+ * committed by hand still reads armed, so the refusal outlives the thing it
862
+ * was guarding. That is deliberate — the exit is one `skip` with a reason,
863
+ * which is precisely the decision this exists to put on the record — and it
864
+ * fails toward asking rather than toward letting a phase through unread.
865
+ */
866
+ /**
867
+ * What the page is told about the gate. Pure. `null` when there is nothing to
868
+ * say, so a project that never armed one renders byte-identically to how it
869
+ * did before any of this existed — the same rule `phases` and `context` follow.
870
+ *
871
+ * The last SKIP travels with it, because that is the page's answer to the one
872
+ * question an untouched-looking review raises: was this read and moved past, or
873
+ * never read at all? A verdict already had an answer there; a skip did not.
874
+ */
875
+ function gateForPage(gate) {
876
+ if (!gate) return null
877
+ const log = Array.isArray(gate.log) ? gate.log : []
878
+ const skips = log.filter((e) => e && e.by === 'skip')
879
+ const lastSkip = skips.length ? skips[skips.length - 1] : null
880
+ if (!gate.armed && !lastSkip) return null
881
+ return {
882
+ armed: gate.armed === true,
883
+ armedAt: gate.armedAt || null,
884
+ phase: gate.phase === undefined ? null : gate.phase,
885
+ ...(lastSkip ? { lastSkip: { at: lastSkip.at || null, reason: lastSkip.reason || null } } : {}),
886
+ }
887
+ }
888
+
889
+ function gateState({ gate, corrupt, present, required }) {
890
+ if (required === false) return { state: 'unknown', reason: 'review.required is false', gate }
891
+ if (corrupt) return { state: 'unknown', reason: 'the gate sidecar is not readable JSON', gate }
892
+ if (!present) return { state: 'clear', reason: 'no gate recorded', gate }
893
+ if (gate.version !== GATE_VERSION) {
894
+ return {
895
+ state: 'unknown',
896
+ reason: `gate version ${JSON.stringify(gate.version)} — this engine reads version ${GATE_VERSION}`,
897
+ gate,
898
+ }
899
+ }
900
+ if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
901
+ return { state: 'armed', reason: 'a phase is awaiting a verdict', gate }
902
+ }
903
+
708
904
  /**
709
905
  * Validate a blob from the page, wholesale.
710
906
  *
@@ -1289,9 +1485,20 @@ module.exports = {
1289
1485
  mintPendingCode,
1290
1486
  addPending,
1291
1487
  claimPending,
1488
+ passesSince,
1292
1489
  describePending,
1293
1490
  pendingAge,
1294
1491
  PENDING_CODE_LENGTH,
1492
+ GATE_VERSION,
1493
+ DISARMED_BY,
1494
+ reviewGatePath,
1495
+ emptyGate,
1496
+ readGate,
1497
+ writeGate,
1498
+ armGate,
1499
+ disarmGate,
1500
+ gateState,
1501
+ gateForPage,
1295
1502
  mergeNotes,
1296
1503
  applyResolutions,
1297
1504
  applyNotes,
package/src/env/serve.js CHANGED
@@ -8,9 +8,16 @@
8
8
  * moment: true when it was taken, and overwritten by the next render. A served
9
9
  * page cannot be out of date, because there is no artefact between the git
10
10
  * objects and the response. That is the whole reason this exists, and it is why
11
- * nothing here reads or writes `.spec-env/reviews/` — the file path and the
12
- * served path are two answers to the same question, and keeping them
13
- * independent is what stops one quietly becoming the other's cache.
11
+ * no HTML is read from or written to `.spec-env/reviews/` — the file path and
12
+ * the served path are two answers to the same question, and keeping the
13
+ * artefacts independent is what stops one quietly becoming the other's cache.
14
+ *
15
+ * THE SIDECARS ARE A DIFFERENT MATTER, and this once over-applied the rule
16
+ * above to them. Notes, the gate and the pending store are the REVIEW'S state,
17
+ * not the page's: they belong to whoever is reading, and a reader on a phone is
18
+ * reading this page. So they are read here (and, for a pass arriving, written)
19
+ * — which is what makes a refresh keep your accepts and the history line say
20
+ * what happened. What is never read here is a rendered page.
14
21
  *
15
22
  * Dependency-free, in the shape of `proxy.js`: pure functions for routing and
16
23
  * the index, an injectable render callback, and a `require.main` entry point so
@@ -40,6 +47,8 @@ const {
40
47
  readPending,
41
48
  writePending,
42
49
  addPending,
50
+ readNotes,
51
+ readGate,
43
52
  } = require('./review.js')
44
53
 
45
54
  /**
@@ -248,7 +257,20 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
248
257
  mode = 'branch'
249
258
  }
250
259
 
251
- let data = collectReview({ spec, git, mode, ref, base: baseName, now })
260
+ // THE REVIEW STATE IS THE READER'S, not the artefact's. It was once left out
261
+ // here on the reasoning that this path writes no file — but a reader on a
262
+ // phone is reading THIS page, and without the sidecar their own accepts
263
+ // vanish on every refresh and the history line never appears at all. The two
264
+ // surfaces answered differently about the same review, which is worse than
265
+ // either answer. Read-only: nothing on this path writes the sidecar.
266
+ const out = reviewOutPath(dir, spec.folder, null)
267
+ const notes = readNotes(out, spec.folder).notes
268
+ const gateRead = readGate(out, spec.folder)
269
+ // A corrupt gate contributes nothing rather than failing the render. The page
270
+ // is a convenience and the gate is not what it is for.
271
+ const gate = gateRead.corrupt ? null : gateRead.gate
272
+
273
+ let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
252
274
 
253
275
  if (!branch && data.totals.files === 0) {
254
276
  const fallbackBase = base()
@@ -261,6 +283,8 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
261
283
  ref: mergeBase,
262
284
  base: fallbackBase,
263
285
  now,
286
+ notes,
287
+ gate,
264
288
  fellBack: true,
265
289
  })
266
290
  if (wider.totals.files > 0) {
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path')
5
5
  const crypto = require('crypto')
6
6
 
7
7
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
8
+ const { ensureReviewGateHook } = require('./env/hooks.js')
8
9
  const { repoInfo, expandTokens } = require('./env/resolve.js')
9
10
 
10
11
  const ASSETS = path.join(__dirname, '..', 'assets')
@@ -38,6 +39,20 @@ function listCommands() {
38
39
  }
39
40
  }
40
41
 
42
+ // Hook scripts shipped as `assets/hooks/*.js`, installed to `.claude/hooks/`.
43
+ // Discovered from the bundled tree like everything else, so a distribution
44
+ // installs precisely what it ships and a hook can be retired by deleting it.
45
+ function listHooks() {
46
+ try {
47
+ return fs
48
+ .readdirSync(path.join(ASSETS, 'hooks'))
49
+ .filter((f) => f.endsWith('.js'))
50
+ .sort()
51
+ } catch {
52
+ return [] // a distribution may ship no hooks
53
+ }
54
+ }
55
+
41
56
  function listRules() {
42
57
  return fs
43
58
  .readdirSync(path.join(ASSETS, 'rules'))
@@ -61,6 +76,8 @@ const COMMANDS = listCommands()
61
76
 
62
77
  const RULES = listRules()
63
78
 
79
+ const HOOKS = listHooks()
80
+
64
81
  const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
65
82
 
66
83
  // Opt-in config templates, scaffolded into specs/.core/ (the base ships the
@@ -209,6 +226,7 @@ function managedTargets(dir) {
209
226
  for (const name of COMMANDS)
210
227
  add(path.join('commands', name), path.join(dir, '.claude', 'commands', name), renderCommand)
211
228
  for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
229
+ for (const name of HOOKS) add(path.join('hooks', name), path.join(dir, '.claude', 'hooks', name))
212
230
  for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
213
231
  return out
214
232
  }
@@ -372,6 +390,40 @@ function installRule(dir, opts) {
372
390
  }
373
391
  }
374
392
 
393
+ // Register the review-gate hook in the project's committed settings, so a
394
+ // phase that owes a verdict is enforced one level below the skills. Best-effort
395
+ // in exactly the way `trustWorktreeRoot` is: a settings file we cannot parse is
396
+ // reported and left alone, never rewritten, and never fatal — the hook is an
397
+ // extra layer, and the engine and `/spec-next` hold the gate without it.
398
+ function registerReviewGateHook(dir) {
399
+ const label = '.claude/settings.json (review-gate hook)'
400
+ let res
401
+ try {
402
+ res = ensureReviewGateHook(dir)
403
+ } catch {
404
+ report.warnings.push('could not write .claude/settings.json — review-gate hook not registered')
405
+ return
406
+ }
407
+ if (res.reason === 'malformed') {
408
+ report.warnings.push(
409
+ '.claude/settings.json is not valid JSON — did not register the review-gate hook',
410
+ )
411
+ } else if (res.reason === 'created') {
412
+ report.created.push(label)
413
+ } else if (res.reason === 'added') {
414
+ report.updated.push(label)
415
+ } else {
416
+ report.skipped.push('.claude/settings.json (review-gate hook already registered)')
417
+ }
418
+ }
419
+
420
+ function installHooks(dir, opts) {
421
+ for (const name of HOOKS) {
422
+ copyAsset(dir, path.join('hooks', name), path.join(dir, '.claude', 'hooks', name), opts)
423
+ }
424
+ registerReviewGateHook(dir)
425
+ }
426
+
375
427
  function installFolders(dir) {
376
428
  for (const folder of SPEC_FOLDERS) {
377
429
  const abs = path.join(dir, 'specs', folder)
@@ -737,6 +789,7 @@ function reset(dir, { claudeMd = true } = {}) {
737
789
  installSkills(dir, { force: true })
738
790
  installCommands(dir, { force: true })
739
791
  installRule(dir, { force: true })
792
+ installHooks(dir, { force: true })
740
793
  installFolders(dir)
741
794
  removeRetiredFiles(dir)
742
795
  installCore(dir, { force: true })
@@ -836,6 +889,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
836
889
  installSkills(dir, { force })
837
890
  installCommands(dir, { force })
838
891
  installRule(dir, { force })
892
+ installHooks(dir, { force })
839
893
  installFolders(dir)
840
894
  removeRetiredFiles(dir)
841
895
  installCore(dir, { force })