create-claude-cabinet 0.25.3 → 0.25.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
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;
@@ -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.