@skitterbyte/skitterspec 17.0.0 → 18.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/gating.js ADDED
@@ -0,0 +1,155 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Release gating — the loader, the header reader, and the check.
5
+ *
6
+ * The feature records ONE decision per spec: does this ship behind a feature
7
+ * flag, or land live? Skitterspec never learns how a project does flags; it asks
8
+ * the question, cites the project's own documentation, and reads back what was
9
+ * written. Strictly opt-in: with `specs/.core/gating.config.json` absent, every
10
+ * function here reports "not configured" and nothing else changes.
11
+ *
12
+ * Mirrors `src/env/config.js` (frozen defaults, merge known keys only, never
13
+ * throws on absence) so the two opt-in configs behave alike.
14
+ */
15
+
16
+ const fs = require('node:fs')
17
+ const path = require('node:path')
18
+
19
+ const CONFIG_FILE = path.join('specs', '.core', 'gating.config.json')
20
+
21
+ // Only these two buckets are ever read.
22
+ //
23
+ // A SPEC WRITTEN BEFORE GATING WAS ADOPTED HAS NO HEADER AND IS NOT BROKEN. That
24
+ // is the blind spot this check would otherwise walk into: "no Gating: line" is
25
+ // evidence of an unanswered question only for a spec that could have been asked,
26
+ // and every finished or abandoned spec predates the question by definition. They
27
+ // are excluded STRUCTURALLY rather than by a filter someone must remember — a
28
+ // completed spec is not in range, so no future edit can make it fire. Pre-existing
29
+ // specs still in flight ARE reported, deliberately: they are live work, the
30
+ // question genuinely still applies, and the report never blocks anything.
31
+ const ACTIVE_BUCKETS = ['backlog', 'in-progress']
32
+
33
+ const DEFAULT_CONFIG = Object.freeze({
34
+ guidance: '',
35
+ default: 'none: <reason>',
36
+ })
37
+
38
+ /**
39
+ * Load `specs/.core/gating.config.json`. Returns `{ config, present }`;
40
+ * `present:false` means the project has not adopted gating, which is read as
41
+ * "this project does not use feature flags" — never as an error.
42
+ */
43
+ function loadGatingConfig(dir = process.cwd()) {
44
+ const base = { ...DEFAULT_CONFIG }
45
+ let raw
46
+ try {
47
+ raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return { config: base, present: false }
50
+ throw error
51
+ }
52
+ let parsed
53
+ try {
54
+ parsed = JSON.parse(raw)
55
+ } catch (error) {
56
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
57
+ }
58
+ if (parsed && typeof parsed === 'object') {
59
+ if (typeof parsed.guidance === 'string') base.guidance = parsed.guidance.trim()
60
+ if (typeof parsed.default === 'string' && parsed.default.trim()) {
61
+ base.default = parsed.default.trim()
62
+ }
63
+ }
64
+ return { config: base, present: true }
65
+ }
66
+
67
+ /**
68
+ * Read a spec's `> **Gating:** …` blockquote field from `00-overview.md`.
69
+ *
70
+ * Returns `{ raw, kind }` with four kinds, because there are four states and
71
+ * collapsing them is what made the omission invisible in the first place:
72
+ *
73
+ * flag a flag name — ships behind it
74
+ * none `none: <reason>` — deliberately not flagged, and why
75
+ * invalid present but says nothing: empty, or a bare `none` with no reason
76
+ * missing no field at all
77
+ *
78
+ * `invalid` and `missing` are kept apart on purpose. A bare `none` is someone
79
+ * answering without deciding; a missing line is nobody having been asked. They
80
+ * want different words.
81
+ */
82
+ function readGatingField(specPath) {
83
+ const overview = path.join(specPath, '00-overview.md')
84
+ let raw
85
+ try {
86
+ raw = fs.readFileSync(overview, 'utf-8')
87
+ } catch {
88
+ return { raw: null, kind: 'missing' }
89
+ }
90
+ const m = /^>\s*\*\*Gating:\*\*\s*(.*)$/m.exec(raw)
91
+ if (!m) return { raw: null, kind: 'missing' }
92
+ const value = m[1].trim().replace(/^["'`]|["'`]$/g, '')
93
+ if (!value) return { raw: value, kind: 'invalid' }
94
+ const bare = /^none\b/i.test(value)
95
+ if (bare) {
96
+ // `none` alone, or `none:` with nothing after it, is a shrug rather than a
97
+ // decision — the reason half is the whole point of recording it.
98
+ const reason = value.replace(/^none\b:?/i, '').trim()
99
+ return { raw: value, kind: reason ? 'none' : 'invalid' }
100
+ }
101
+ return { raw: value, kind: 'flag' }
102
+ }
103
+
104
+ // Active specs on disk, as `{ folder, bucket, path }`. A bucket that does not
105
+ // exist is simply empty — git does not store empty directories, so a missing
106
+ // `specs/backlog/` is the ordinary state of a project with nothing queued.
107
+ function activeSpecs(dir) {
108
+ const out = []
109
+ for (const bucket of ACTIVE_BUCKETS) {
110
+ const root = path.join(dir, 'specs', bucket)
111
+ let entries
112
+ try {
113
+ entries = fs.readdirSync(root, { withFileTypes: true })
114
+ } catch {
115
+ continue
116
+ }
117
+ for (const e of entries) {
118
+ if (e.isDirectory()) out.push({ folder: e.name, bucket, path: path.join(root, e.name) })
119
+ }
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Check specs for a recorded gating decision.
126
+ *
127
+ * Advisory by construction: it returns findings and says nothing about what the
128
+ * caller should do. Nothing here exits, throws on a finding, or blocks.
129
+ *
130
+ * @returns {{configured: boolean, findings: Array<{folder, bucket, kind, raw}>,
131
+ * checked: number, guidance: string}}
132
+ */
133
+ function checkGating(dir, specs) {
134
+ const { config, present } = loadGatingConfig(dir)
135
+ if (!present) return { configured: false, findings: [], checked: 0, guidance: '' }
136
+ const targets = specs && specs.length ? specs : activeSpecs(dir)
137
+ const findings = []
138
+ for (const spec of targets) {
139
+ const { kind, raw } = readGatingField(spec.path)
140
+ if (kind === 'missing' || kind === 'invalid') {
141
+ findings.push({ folder: spec.folder, bucket: spec.bucket, kind, raw })
142
+ }
143
+ }
144
+ return { configured: true, findings, checked: targets.length, guidance: config.guidance }
145
+ }
146
+
147
+ module.exports = {
148
+ CONFIG_FILE,
149
+ DEFAULT_CONFIG,
150
+ ACTIVE_BUCKETS,
151
+ loadGatingConfig,
152
+ readGatingField,
153
+ activeSpecs,
154
+ checkGating,
155
+ }
package/src/init.js CHANGED
@@ -444,6 +444,24 @@ function installIsolation(dir, { enabled, workspaceMode }, opts) {
444
444
  trustWorktreeRoot(dir)
445
445
  }
446
446
 
447
+ // Activate opt-in release gating: write specs/.core/gating.config.json from the
448
+ // example asset, so /spec and friends start asking whether a change ships behind
449
+ // a feature flag and recording the answer.
450
+ //
451
+ // Only called when the operator opts in, and NEVER on `update` — for the same
452
+ // reason as isolation: adopting a policy is a deliberate choice, not something a
453
+ // re-sync flips on. Idempotent; copyAsset never clobbers an existing config
454
+ // without --force, so an operator's edited `guidance` survives a re-init.
455
+ function installGating(dir, { enabled }, opts) {
456
+ if (!enabled) return
457
+ copyAsset(
458
+ dir,
459
+ path.join('core', 'gating.config.json.example'),
460
+ path.join(dir, 'specs', '.core', 'gating.config.json'),
461
+ opts,
462
+ )
463
+ }
464
+
447
465
  // Seed the absolute worktree root into .claude/settings.local.json (gitignored)
448
466
  // so the operator enabling isolation isn't prompted on every edit into a
449
467
  // freshly-provisioned worktree. Best-effort: an unreadable config or malformed
@@ -682,6 +700,13 @@ function printReport(dir, mode, { diff = false } = {}) {
682
700
  ' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
683
701
  : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
684
702
  ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
703
+ const gatingOn = fs.existsSync(path.join(dir, 'specs', '.core', 'gating.config.json'))
704
+ const gatingNote = gatingOn
705
+ ? 'Release gating is ON: /spec asks whether a change ships behind a feature' +
706
+ ' flag and records the answer on the spec (skitterspec gating check reports' +
707
+ ' any that have none).\n'
708
+ : 'Release gating is opt-in: re-run with --gating (or copy' +
709
+ ' specs/.core/gating.config.json.example → gating.config.json) to enable it.\n'
685
710
  // A provider superset ships its own `spec-<provider>-setup` skill; the base
686
711
  // ships none. Discovering it from what was actually installed keeps this file
687
712
  // tracker-free — it never has to know which tracker (if any) is in the box.
@@ -707,6 +732,7 @@ function printReport(dir, mode, { diff = false } = {}) {
707
732
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
708
733
  " project's stack, then run /spec.\n" +
709
734
  isolationNote +
735
+ gatingNote +
710
736
  trackerNote,
711
737
  )
712
738
  }
@@ -714,7 +740,7 @@ function printReport(dir, mode, { diff = false } = {}) {
714
740
  // `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
715
741
  // unrelated to the config's own `mode` key — which arrives as `workspaceMode`
716
742
  // precisely so the two cannot be confused at a call site.
717
- async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
743
+ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gating }) {
718
744
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
719
745
  resetReport()
720
746
 
@@ -726,6 +752,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
726
752
  installCore(dir, { force })
727
753
  // Adopting isolation writes the live env.config.json — init only, never update.
728
754
  if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
755
+ if (mode !== 'update') installGating(dir, { enabled: gating }, { force })
729
756
  if (claudeMd) installClaudeMd(dir, { mode })
730
757
 
731
758
  // Record what we wrote (and migrate a pre-manifest repo) so a later resync can
package/src/prompts.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * is `'worktree'` otherwise (the value the config defaults to anyway).
12
12
  */
13
13
 
14
- async function promptSetup({ isolationSeed = false } = {}) {
14
+ async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
15
15
  const prompts = require('prompts')
16
16
 
17
17
  let cancelled = false
@@ -48,6 +48,14 @@ async function promptSetup({ isolationSeed = false } = {}) {
48
48
  },
49
49
  ],
50
50
  },
51
+ {
52
+ // Orthogonal to isolation, so it is asked unconditionally rather than
53
+ // nested under it — a project can adopt either, both, or neither.
54
+ type: 'confirm',
55
+ name: 'gating',
56
+ message: 'Record a release-gating decision on each spec — flag, or land live?',
57
+ initial: gatingSeed,
58
+ },
51
59
  ]
52
60
 
53
61
  const ans = await prompts(questions, { onCancel })
@@ -59,6 +67,7 @@ async function promptSetup({ isolationSeed = false } = {}) {
59
67
  return {
60
68
  isolation: Boolean(ans.isolation),
61
69
  mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
70
+ gating: Boolean(ans.gating),
62
71
  }
63
72
  }
64
73