create-claude-cabinet 0.25.3 → 0.26.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/README.md CHANGED
@@ -131,6 +131,32 @@ hooks — things that keep going wrong become things that can't go wrong.
131
131
  - **`/cc-upgrade`** — when Claude Cabinet publishes updates, this skill
132
132
  runs the installer for the mechanical parts and walks you through
133
133
  what changed conversationally. Intelligence is the merge strategy.
134
+ - **`/cc-feedback`** — file friction with CC itself mid-session
135
+ without waiting for debrief. When a skill, phase, or convention
136
+ causes pain, this captures the detail and queues it for upstream
137
+ delivery to the Claude Cabinet repo.
138
+
139
+ ### Verify (opt-in, off by default)
140
+
141
+ Walkthrough verification harness — Cucumber `.feature` files describing
142
+ user journeys, Playwright running them, and human-in-the-loop verdict
143
+ pauses (Pass / Issue / Skip / Needs-info) at checks that need subjective
144
+ judgment. Replaces flat AC checklists with re-runnable scenarios you can
145
+ read months later.
146
+
147
+ - **`/verify`** — run the suite
148
+ - **`/verify learn`** — bootstrap from a cold start. Claude scans
149
+ routes, memory, git, and the live UI; proposes scenarios; calibrates
150
+ with you; then generates `.feature` files and step stubs
151
+ - **`/verify update "I changed X"`** — keep scenarios in sync as the
152
+ product evolves
153
+ - **`/verify backfill <fid>`** — attach a Verify Plan to a pending
154
+ action's notes
155
+
156
+ Enable with `--modules verify` (existing installs merge, nothing else
157
+ disturbed). Runtime lives at `~/.claude-cabinet/verify/<version>/` and
158
+ ships an opinionated `cabinet-verify` npm package built from de[sic]ify's
159
+ e2e harness.
134
160
 
135
161
  ## Your Workflow
136
162
 
@@ -158,14 +184,29 @@ that override default behavior for any skill. Write content in a phase
158
184
  file to customize it, write `skip: true` to disable it, or leave it
159
185
  absent to use the default. No config files, no YAML, no DSL.
160
186
 
187
+ ## Adding Modules to an Existing Install
188
+
189
+ Some modules (like `verify` and `memory`) are opt-in. To add one
190
+ without touching anything else in your install:
191
+
192
+ ```
193
+ npx create-claude-cabinet --modules verify --yes
194
+ ```
195
+
196
+ The `--modules` flag **merges** with your existing install — it adds
197
+ the listed modules to what's already there, it doesn't replace your
198
+ module set. Safe to run on a mature project without losing
199
+ customization. You can pass multiple modules: `--modules verify,memory`.
200
+
161
201
  ## CLI Options
162
202
 
163
203
  ```
164
- npx create-claude-cabinet # Interactive walkthrough
165
- npx create-claude-cabinet my-project # Install in ./my-project/
166
- npx create-claude-cabinet --yes # Accept all defaults
167
- npx create-claude-cabinet --yes --no-db # All defaults, skip database
168
- npx create-claude-cabinet --dry-run # Preview without writing files
204
+ npx create-claude-cabinet # Interactive walkthrough
205
+ npx create-claude-cabinet my-project # Install in ./my-project/
206
+ npx create-claude-cabinet --yes # Accept all defaults
207
+ npx create-claude-cabinet --yes --no-db # All defaults, skip database
208
+ npx create-claude-cabinet --dry-run # Preview without writing files
209
+ npx create-claude-cabinet --modules verify --yes # Add an opt-in module (merges, doesn't replace)
169
210
  ```
170
211
 
171
212
  ## What Gets Installed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.25.3",
3
+ "version": "0.26.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -14,7 +14,6 @@ INPUT="$CLAUDE_TOOL_INPUT"
14
14
  FID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('fid',''))" 2>/dev/null)
15
15
 
16
16
  if [ -z "$FID" ]; then
17
- echo '{"decision":"allow"}'
18
17
  exit 0
19
18
  fi
20
19
 
@@ -43,4 +42,4 @@ if [ "$AC_VERIFIED" != "True" ]; then
43
42
  exit 0
44
43
  fi
45
44
 
46
- echo '{"decision":"allow"}'
45
+ exit 0
@@ -47,4 +47,4 @@ if ! echo "$NOTES" | grep -qiE '(## Surface|files:|dirs:)'; then
47
47
  exit 0
48
48
  fi
49
49
 
50
- echo '{"decision":"allow"}'
50
+ exit 0
@@ -14,13 +14,14 @@
14
14
  #
15
15
  # Hook contract:
16
16
  # Input: $CLAUDE_TOOL_INPUT has the tool use JSON with "file_path" field
17
- # Output: JSON on stdout with { "decision": "block"|"allow", "reason": "..." }
17
+ # Output: JSON on stdout with { "decision": "block", "reason": "..." }
18
+ # when blocking. Otherwise empty stdout + exit 0 (allow is the
19
+ # default; emitting "allow" violates the hook output schema).
18
20
 
19
21
  # Extract file_path from tool input
20
22
  FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('file_path',''))" 2>/dev/null)
21
23
 
22
24
  if [ -z "$FILE_PATH" ]; then
23
- echo '{"decision":"allow"}'
24
25
  exit 0
25
26
  fi
26
27
 
@@ -42,7 +43,6 @@ PROJECT_ROOT=$(find_project_root)
42
43
 
43
44
  if [ -z "$PROJECT_ROOT" ]; then
44
45
  # No .ccrc.json found — not a CC project, allow everything
45
- echo '{"decision":"allow"}'
46
46
  exit 0
47
47
  fi
48
48
 
@@ -53,7 +53,6 @@ if [[ "$FILE_PATH" = /* ]]; then
53
53
  REL_PATH="${FILE_PATH#$PROJECT_ROOT/}"
54
54
  # If the path didn't change, the file is outside the project
55
55
  if [ "$REL_PATH" = "$FILE_PATH" ]; then
56
- echo '{"decision":"allow"}'
57
56
  exit 0
58
57
  fi
59
58
  else
@@ -74,6 +73,5 @@ except:
74
73
 
75
74
  if [ "$IN_MANIFEST" = "yes" ]; then
76
75
  echo "{\"decision\":\"block\",\"reason\":\"Blocked: $REL_PATH is managed by Claude Cabinet. CC-managed files are upstream-owned — edits come through /cc-upgrade, not direct modification. Put project-specific content in briefing files or phase files instead.\"}"
77
- else
78
- echo '{"decision":"allow"}'
79
76
  fi
77
+ exit 0
@@ -9,13 +9,15 @@
9
9
  #
10
10
  # Hook contract:
11
11
  # Input: $CLAUDE_TOOL_INPUT has the tool use JSON with "command" field
12
- # Output: JSON on stdout with { "decision": "block"|"allow", "reason": "..." }
12
+ # Output: JSON on stdout with { "decision": "block", "reason": "..." }
13
+ # when blocking. Otherwise empty stdout + exit 0 (Claude Code
14
+ # treats no-output as allow; emitting "allow" violates the
15
+ # hook output schema).
13
16
 
14
17
  # Read the command from the tool input
15
18
  COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command',''))" 2>/dev/null)
16
19
 
17
20
  if [ -z "$COMMAND" ]; then
18
- echo '{"decision":"allow"}'
19
21
  exit 0
20
22
  fi
21
23
 
@@ -62,6 +64,5 @@ RESULT=$(check_command "$COMMAND")
62
64
 
63
65
  if [ -n "$RESULT" ]; then
64
66
  echo "$RESULT"
65
- else
66
- echo '{"decision":"allow"}'
67
67
  fi
68
+ exit 0
@@ -16,13 +16,14 @@
16
16
  #
17
17
  # Hook contract:
18
18
  # Input: $CLAUDE_TOOL_INPUT has the tool use JSON with "file_path" field
19
- # Output: JSON on stdout with { "decision": "block"|"allow", "reason": "..." }
19
+ # Output: JSON on stdout with { "decision": "block", "reason": "..." }
20
+ # when blocking. Otherwise empty stdout + exit 0 (allow is the
21
+ # default; emitting "allow" violates the hook output schema).
20
22
 
21
23
  # Extract file_path from tool input
22
24
  FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('file_path',''))" 2>/dev/null)
23
25
 
24
26
  if [ -z "$FILE_PATH" ]; then
25
- echo '{"decision":"allow"}'
26
27
  exit 0
27
28
  fi
28
29
 
@@ -30,21 +31,18 @@ fi
30
31
  # Note: case patterns with * don't cross / boundaries in some shells,
31
32
  # so we use [[ ]] substring matching for absolute path compatibility.
32
33
  if [[ "$FILE_PATH" != *"/.claude/memory/"* ]] && [[ "$FILE_PATH" != *"/.claude/projects/"*"/memory/"* ]]; then
33
- echo '{"decision":"allow"}'
34
34
  exit 0
35
35
  fi
36
36
 
37
37
  # Allow MEMORY.md index files (structural, not memory content)
38
38
  BASENAME=$(basename "$FILE_PATH")
39
39
  if [ "$BASENAME" = "MEMORY.md" ]; then
40
- echo '{"decision":"allow"}'
41
40
  exit 0
42
41
  fi
43
42
 
44
43
  # Allow pattern files (enforcement pipeline artifacts, not semantic memories)
45
44
  case "$FILE_PATH" in
46
45
  */memory/patterns/*)
47
- echo '{"decision":"allow"}'
48
46
  exit 0
49
47
  ;;
50
48
  esac
@@ -53,7 +51,6 @@ esac
53
51
  OMEGA_PYTHON="$HOME/.claude-cabinet/omega-venv/bin/python3"
54
52
  if [ ! -x "$OMEGA_PYTHON" ]; then
55
53
  # Omega not available — flat markdown IS the correct fallback
56
- echo '{"decision":"allow"}'
57
54
  exit 0
58
55
  fi
59
56
 
@@ -73,7 +70,6 @@ find_adapter() {
73
70
  ADAPTER=$(find_adapter)
74
71
  if [ -z "$ADAPTER" ]; then
75
72
  # No adapter found — flat markdown fallback is correct
76
- echo '{"decision":"allow"}'
77
73
  exit 0
78
74
  fi
79
75
 
@@ -10,7 +10,6 @@ INPUT="$CLAUDE_TOOL_INPUT"
10
10
  COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command',''))" 2>/dev/null)
11
11
 
12
12
  if [ -z "$COMMAND" ]; then
13
- echo '{"decision":"allow"}'
14
13
  exit 0
15
14
  fi
16
15
 
@@ -19,20 +18,18 @@ PHASE_FILE=".claude/skills/hooks/phases/work-tracker-guard.md"
19
18
  if [ -f "$PHASE_FILE" ]; then
20
19
  FIRST_LINE=$(head -1 "$PHASE_FILE")
21
20
  if [ "$FIRST_LINE" = "skip: true" ]; then
22
- echo '{"decision":"allow"}'
23
21
  exit 0
24
22
  fi
25
23
  fi
26
24
 
27
25
  # Check for SQL operations against actions table
28
26
  if echo "$COMMAND" | grep -qiE '(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+actions'; then
29
- # Override escape hatch
27
+ # Override escape hatch — allow is the default, just exit 0
30
28
  if echo "$COMMAND" | grep -q '\-\-force-raw-sql'; then
31
- echo '{"decision":"allow","reason":"Raw SQL override acknowledged. Quality gates bypassed."}'
32
29
  exit 0
33
30
  fi
34
31
  echo '{"decision":"block","reason":"Raw SQL against actions table detected. Use MCP tools instead: pib_create_action, pib_update_action, pib_complete_action, pib_get_action. These enforce quality gates. To override (almost certainly wrong): add --force-raw-sql to your command."}'
35
32
  exit 0
36
33
  fi
37
34
 
38
- echo '{"decision":"allow"}'
35
+ exit 0
@@ -1,21 +1,53 @@
1
1
  /**
2
- * Minimal triage server — serves the triage UI and holds findings/verdicts in memory.
2
+ * Minimal triage server — serves the triage UI and holds findings/verdicts.
3
3
  *
4
4
  * Claude POSTs findings, user triages in browser, Claude GETs verdicts.
5
+ * Each verdict is written through to disk as the user makes it, so page
6
+ * reloads or server restarts don't lose in-progress triage decisions.
5
7
  *
6
8
  * Usage: node triage-server.mjs [--port 3457]
7
9
  */
8
10
 
9
11
  import { createServer } from 'node:http';
10
- import { readFile } from 'node:fs/promises';
12
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
11
13
  import { fileURLToPath } from 'node:url';
12
14
  import { dirname, join } from 'node:path';
13
15
 
14
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
17
  const PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === '--port') || '3457');
18
+ const DRAFT_PATH = join(process.cwd(), '.claude', 'triage-draft.json');
16
19
 
17
20
  let currentFindings = [];
18
21
  let currentVerdicts = null;
22
+ let drafts = {}; // { findingId: { verdict, feedback } }
23
+
24
+ async function loadDrafts() {
25
+ try {
26
+ drafts = JSON.parse(await readFile(DRAFT_PATH, 'utf-8'));
27
+ } catch {
28
+ drafts = {};
29
+ }
30
+ }
31
+
32
+ async function saveDrafts() {
33
+ await mkdir(dirname(DRAFT_PATH), { recursive: true });
34
+ await writeFile(DRAFT_PATH, JSON.stringify(drafts, null, 2));
35
+ }
36
+
37
+ function dedupById(items) {
38
+ const seen = new Set();
39
+ const out = [];
40
+ for (const f of items) {
41
+ if (!f || !f.id || seen.has(f.id)) continue;
42
+ seen.add(f.id);
43
+ out.push(f);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function withDrafts(findings) {
49
+ return findings.map((f) => ({ ...f, draft: drafts[f.id] || null }));
50
+ }
19
51
 
20
52
  function json(res, data, status = 200) {
21
53
  res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
@@ -53,21 +85,47 @@ const server = createServer(async (req, res) => {
53
85
  for await (const chunk of req) body += chunk;
54
86
  try {
55
87
  const data = JSON.parse(body);
56
- currentFindings = data.findings || data;
57
- currentVerdicts = null; // Reset verdicts for new batch
58
- console.log(`Loaded ${currentFindings.length} findings`);
88
+ const raw = data.findings || data;
89
+ currentFindings = dedupById(Array.isArray(raw) ? raw : []);
90
+ currentVerdicts = null;
91
+ // Trim drafts to the new finding id set so a fresh batch doesn't
92
+ // surface stale entries, but a re-POST of the same batch preserves
93
+ // in-progress work.
94
+ const idSet = new Set(currentFindings.map((f) => f.id));
95
+ drafts = Object.fromEntries(Object.entries(drafts).filter(([id]) => idSet.has(id)));
96
+ await saveDrafts();
97
+ console.log(`Loaded ${currentFindings.length} findings (${Object.keys(drafts).length} drafts retained)`);
59
98
  return json(res, { ok: true, count: currentFindings.length });
60
99
  } catch (err) {
61
100
  return json(res, { error: 'Invalid JSON' }, 400);
62
101
  }
63
102
  }
64
103
 
65
- // GET /api/findings — UI fetches findings
104
+ // GET /api/findings — UI fetches findings (with any saved drafts attached)
66
105
  if (req.method === 'GET' && url.pathname === '/api/findings') {
67
- return json(res, { findings: currentFindings });
106
+ return json(res, { findings: withDrafts(currentFindings) });
107
+ }
108
+
109
+ // POST /api/verdict — UI writes a single verdict as the user makes it
110
+ if (req.method === 'POST' && url.pathname === '/api/verdict') {
111
+ let body = '';
112
+ for await (const chunk of req) body += chunk;
113
+ try {
114
+ const { id, verdict, feedback } = JSON.parse(body);
115
+ if (!id) return json(res, { error: 'Missing id' }, 400);
116
+ const existing = drafts[id] || { verdict: '', feedback: '' };
117
+ drafts[id] = {
118
+ verdict: verdict !== undefined ? verdict : existing.verdict,
119
+ feedback: feedback !== undefined ? feedback : existing.feedback,
120
+ };
121
+ await saveDrafts();
122
+ return json(res, { ok: true });
123
+ } catch (err) {
124
+ return json(res, { error: 'Invalid JSON' }, 400);
125
+ }
68
126
  }
69
127
 
70
- // POST /api/verdicts — UI submits verdicts
128
+ // POST /api/verdicts — UI submits final verdicts batch
71
129
  if (req.method === 'POST' && url.pathname === '/api/verdicts') {
72
130
  let body = '';
73
131
  for await (const chunk of req) body += chunk;
@@ -80,7 +138,7 @@ const server = createServer(async (req, res) => {
80
138
  }
81
139
  }
82
140
 
83
- // GET /api/verdicts — Claude reads verdicts
141
+ // GET /api/verdicts — Claude reads submitted verdicts
84
142
  if (req.method === 'GET' && url.pathname === '/api/verdicts') {
85
143
  if (!currentVerdicts) {
86
144
  return json(res, { submitted: false, message: 'No verdicts submitted yet' });
@@ -88,11 +146,23 @@ const server = createServer(async (req, res) => {
88
146
  return json(res, currentVerdicts);
89
147
  }
90
148
 
149
+ // DELETE /api/draft — clear in-progress draft state
150
+ if (req.method === 'DELETE' && url.pathname === '/api/draft') {
151
+ drafts = {};
152
+ try {
153
+ await unlink(DRAFT_PATH);
154
+ } catch {}
155
+ return json(res, { ok: true });
156
+ }
157
+
91
158
  // 404
92
159
  res.writeHead(404);
93
160
  res.end('Not found');
94
161
  });
95
162
 
163
+ await loadDrafts();
96
164
  server.listen(PORT, () => {
97
165
  console.log(`Triage server running at http://localhost:${PORT}`);
166
+ const draftCount = Object.keys(drafts).length;
167
+ if (draftCount) console.log(`Restored ${draftCount} in-progress draft verdict(s) from ${DRAFT_PATH}`);
98
168
  });
@@ -285,13 +285,42 @@
285
285
  let findings = [];
286
286
  const verdicts = {}; // { findingId: { verdict, feedback } }
287
287
  const memberNotes = {}; // { member: { comment, question } }
288
+ const feedbackDebounce = {}; // { findingId: timeoutId }
289
+
290
+ function dedupById(items) {
291
+ const seen = new Set();
292
+ const out = [];
293
+ for (const f of items) {
294
+ if (!f || !f.id || seen.has(f.id)) continue;
295
+ seen.add(f.id);
296
+ out.push(f);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ // POST a verdict write-through to the server (silent on failure
302
+ // — write-through is best-effort; submit remains the source of truth)
303
+ function persistVerdict(id) {
304
+ const v = verdicts[id] || { verdict: '', feedback: '' };
305
+ fetch('/api/verdict', {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ id, verdict: v.verdict, feedback: v.feedback }),
309
+ }).catch(() => {});
310
+ }
288
311
 
289
312
  // ── Public API for Claude (javascript_tool) ──
290
313
  window.loadFindings = function(data) {
291
- findings = Array.isArray(data) ? data : [];
292
- // Pre-populate from recommendations
314
+ findings = dedupById(Array.isArray(data) ? data : []);
315
+ // Hydrate from server-persisted drafts first; recommendations only
316
+ // fill in when there's no prior draft.
293
317
  findings.forEach(f => {
294
- if (f.recommendation && !verdicts[f.id]) {
318
+ if (f.draft && f.draft.verdict !== undefined) {
319
+ verdicts[f.id] = {
320
+ verdict: f.draft.verdict || '',
321
+ feedback: f.draft.feedback || '',
322
+ };
323
+ } else if (f.recommendation && !verdicts[f.id]) {
295
324
  verdicts[f.id] = { verdict: f.recommendation, feedback: '' };
296
325
  }
297
326
  });
@@ -476,6 +505,7 @@
476
505
  const v = e.target.dataset.v;
477
506
  if (!verdicts[id]) verdicts[id] = { verdict: '', feedback: '' };
478
507
  verdicts[id].verdict = v;
508
+ persistVerdict(id);
479
509
  render();
480
510
  }
481
511
  // Bulk buttons
@@ -485,6 +515,7 @@
485
515
  findings.filter(f => f['cabinet-member'] === member).forEach(f => {
486
516
  if (!verdicts[f.id]) verdicts[f.id] = { verdict: '', feedback: '' };
487
517
  verdicts[f.id].verdict = v;
518
+ persistVerdict(f.id);
488
519
  });
489
520
  render();
490
521
  }
@@ -504,6 +535,9 @@
504
535
  const id = e.target.dataset.id;
505
536
  if (!verdicts[id]) verdicts[id] = { verdict: '', feedback: '' };
506
537
  verdicts[id].feedback = e.target.value;
538
+ // Debounce write-through so we don't POST on every keystroke
539
+ clearTimeout(feedbackDebounce[id]);
540
+ feedbackDebounce[id] = setTimeout(() => persistVerdict(id), 400);
507
541
  }
508
542
  if (e.target.classList.contains('member-input')) {
509
543
  const p = e.target.dataset.member;
@@ -493,7 +493,8 @@ Read `phases/report.md` for how to present the debrief summary.
493
493
 
494
494
  Phases are either **core** (maintain system state) or **presentation**
495
495
  (surface information for the user). For lightweight session closes,
496
- skip presentation phases. Core phases always run.
496
+ skip presentation phases. **Core phases always run — Quick Mode is not
497
+ a license to skip them.**
497
498
 
498
499
  - **Core phases** (always run): inventory, close-work,
499
500
  cabinet-consultations, audit-pattern-capture, auto-maintenance,
@@ -505,6 +506,26 @@ skip presentation phases. Core phases always run.
505
506
  A project that wants a quick debrief variant skips the report and
506
507
  outputs a minimal summary instead.
507
508
 
509
+ ### What Quick Mode does NOT skip
510
+
511
+ **Cabinet-consultations (step 3) is core and MUST run — do NOT skip,
512
+ do NOT paraphrase, do NOT defer.** This is where record-keeper (and
513
+ any other debrief-mandated members) verifies documentation against
514
+ reality. Skipping it is the single most common Quick Mode failure
515
+ mode: the consultations *feel* like overhead because they spawn
516
+ agents, but they are the only mechanism that catches doc drift,
517
+ methodology gaps, and stale state. A debrief without
518
+ cabinet-consultations leaves the next orient reading stale docs.
519
+
520
+ **Audit-pattern-capture, methodology-capture, and upstream-feedback
521
+ are instruction phases — they ship with CC and are always required**,
522
+ in Quick Mode as much as in full debrief. Their per-session cost is
523
+ near zero when nothing fires.
524
+
525
+ If a session genuinely has no audit findings, no methodology work,
526
+ and no CC friction, those phases self-skip in seconds. That is not
527
+ the same as Claude choosing to skip them.
528
+
508
529
  ## Extending and Calibration
509
530
 
510
531
  See [calibration.md](calibration.md) for the phase-extension pattern
@@ -83,8 +83,9 @@ alongside any items surfaced by deferred-check, health-checks, etc.
83
83
 
84
84
  ## What this phase does NOT do
85
85
 
86
- - It does not modify action notes. The operator runs `/plan <fid>`
87
- to backfill, or chooses to accept the drift.
86
+ - It does not modify action notes. The operator runs
87
+ `/verify backfill <fid>` to backfill, or chooses to accept the
88
+ drift.
88
89
  - It does not file new actions or projects.
89
90
  - It does not block orient. Even with 5 backfill candidates, orient
90
91
  completes; the Attention Items accumulate.
@@ -30,6 +30,9 @@ related:
30
30
  - type: file
31
31
  path: .claude/skills/verify/phases/backfill.md
32
32
  role: "How to draft a ## Verify Plan section for a pending action"
33
+ - type: file
34
+ path: .claude/skills/verify/phases/recipes.md
35
+ role: "Testability gotchas (dnd-kit drag, dynamic file inputs, hash routing) and their workarounds"
33
36
  - type: file
34
37
  path: cabinet/_briefing.md
35
38
  role: "Project identity and configuration"
@@ -125,8 +128,15 @@ scenario change.
125
128
 
126
129
  1. Check if `e2e/` exists in the project root. If not, recommend
127
130
  `/verify learn` and exit.
128
- 2. Run `npm run verify` from the project's `e2e/` dir.
129
- 3. Surface the output. If failures or I-verdicts landed, suggest
131
+ 2. **Test-isolation nudge.** If `e2e/` exists but `e2e/start-test-stack.sh`
132
+ does not — and the project's dev stack is suspected to write to a
133
+ real DB (signal: `package.json` references a single `data/`,
134
+ `db/`, or similar shared persistence path) — surface a one-line
135
+ note before running: *"No isolated test stack detected. Scenarios
136
+ will run against your dev stack. Run `/verify learn` to generate
137
+ an isolation scaffold if your dev DB matters."* Do not block.
138
+ 3. Run `npm run verify` from the project's `e2e/` dir.
139
+ 4. Surface the output. If failures or I-verdicts landed, suggest
130
140
  `npm run report:last` to triage.
131
141
 
132
142
  This mode is intentionally thin — the harness is the value, not
@@ -155,8 +165,12 @@ The "learn" flow runs four phases:
155
165
  question at a time. Examples: "I see admin routes but only one admin
156
166
  user — real persona or fold into main?", "Should the fresh-user
157
167
  flow be its own scenario or part of admin?", "What's the dev stack
158
- URL for preflight?". Do NOT batch questions — one at a time per
159
- project convention.
168
+ URL for preflight?". Calibrate also includes a **test-isolation
169
+ probe** — if the project's dev stack writes to a real DB, the
170
+ skill captures the DB path, dev-server proxy config, and test
171
+ stack ports so install.sh can emit an `e2e/start-test-stack.sh`
172
+ scaffold. Do NOT batch questions — one at a time per project
173
+ convention.
160
174
 
161
175
  4. **Generate** (read `phases/generate.md`): write the `.feature`
162
176
  files using the template in `phases/scenario-template.md` plus
@@ -221,14 +235,25 @@ once the action runs. Backfill only adds the planning artifact so
221
235
  | `update.md` | Default: action fid / diff / free-text dispatch | How change descriptions map to edits |
222
236
  | `scenario-template.md` | Default: Gherkin with cost+role tags, NN.NN checkIds | Project-specific scenario shape |
223
237
  | `backfill.md` | Default: interview-driven Verify Plan section drafting | Project-specific backfill questions |
238
+ | `recipes.md` | Default: dnd-kit, dynamic file input, hash routing gotchas | Project-specific testability recipes |
224
239
 
225
240
  ## Principles
226
241
 
227
242
  - **One question at a time.** Calibrate phase NEVER batches questions
228
243
  (per CLAUDE.md global convention). Each answer shapes the next.
229
- - **≤5 scenarios on initial draft.** Force calibration before
230
- expansion. Adding scenarios later is cheap; removing scenarios
231
- the user didn't ask for is expensive (per process-therapist).
244
+ - **≤5 scenarios on initial draft (cabinet-qa cap).** Force calibration
245
+ before expansion. Adding scenarios later is cheap; removing scenarios
246
+ the user did not ask for is expensive (per process-therapist). The
247
+ cap is load-bearing; do not loosen it without an audit-grade reason.
248
+ - **Depth-first, not shallow-first.** A scenario that touches a
249
+ surface but verifies nothing is worse than no scenario at all —
250
+ it occupies a slot in the catalogue and creates a false sense of
251
+ coverage. The first lap through a scenario should produce real
252
+ assertions and human-verdict pauses for the parts that genuinely
253
+ need subjective judgment. If a step can not be exercised (a
254
+ testability gotcha — see `phases/recipes.md`), file a finding
255
+ against the consuming project and mark the step "skip until
256
+ testable" rather than emitting a no-op stub.
232
257
  - **cabinet-qa owns "what's worth a scenario".** /verify learn
233
258
  delegates that judgment via subagent; it doesn't re-derive it.
234
259
  - **The .feature file is the spec.** Anyone (user, future Claude,