create-claude-cabinet 0.6.1 → 0.6.3
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 +79 -28
- package/lib/cli.js +3 -2
- package/lib/copy.js +3 -1
- package/lib/metadata.js +6 -2
- package/package.json +1 -1
- package/templates/cabinet/output-contract.md +3 -3
- package/templates/scripts/finding-schema.json +4 -4
- package/templates/scripts/load-triage-history.js +5 -5
- package/templates/scripts/merge-findings.js +6 -6
- package/templates/scripts/pib-db-schema.sql +1 -1
- package/templates/scripts/pib-db.js +6 -6
- package/templates/scripts/triage-ui.html +52 -51
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ curl -fsSL https://raw.githubusercontent.com/orenmagid/claude-cabinet/main/insta
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
That's it. If you don't have git or Node.js, it installs them.
|
|
33
|
-
No choices to make —
|
|
33
|
+
No choices to make — you get everything.
|
|
34
34
|
|
|
35
35
|
Then open [Claude Code](https://claude.ai/code) in the same folder and
|
|
36
36
|
say `/onboard`. It'll interview you about your project and set everything
|
|
@@ -41,8 +41,8 @@ step-by-step walkthrough.
|
|
|
41
41
|
|
|
42
42
|
### For developers
|
|
43
43
|
|
|
44
|
-
If you have Node.js installed and want
|
|
45
|
-
|
|
44
|
+
If you have Node.js installed and want to choose which modules to
|
|
45
|
+
install, or want the lean option (skips work tracking and compliance):
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
npx create-claude-cabinet
|
|
@@ -55,42 +55,93 @@ it's done, open Claude Code and run `/onboard`.
|
|
|
55
55
|
## What You Get
|
|
56
56
|
|
|
57
57
|
### The Session Loop (always installed)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
|
|
59
|
+
This is the foundation. You run these commands — they don't happen
|
|
60
|
+
automatically.
|
|
61
|
+
|
|
62
|
+
- **`/orient`** — open every session with this. Claude reads project
|
|
63
|
+
state, checks health, surfaces what needs attention, and briefs you
|
|
64
|
+
so you never start blind. Think of it as the morning briefing before
|
|
65
|
+
the cabinet gets to work.
|
|
66
|
+
- **`/debrief`** — close every session with this. Claude marks work
|
|
67
|
+
done, records lessons, updates state, and prepares the briefing for
|
|
68
|
+
next time. Without debrief, the next orient starts with stale
|
|
69
|
+
information. The loop is what gives Claude memory across sessions.
|
|
70
|
+
|
|
71
|
+
**The habit matters.** Orient and debrief take 30 seconds each. Skip
|
|
72
|
+
them and sessions start from zero — Claude forgets what happened,
|
|
73
|
+
repeats mistakes, and you spend the first 10 minutes re-explaining
|
|
74
|
+
context. Keep the loop and each session picks up where the last one
|
|
75
|
+
left off.
|
|
76
|
+
|
|
77
|
+
### The Cabinet (included in lean)
|
|
78
|
+
|
|
79
|
+
20 expert cabinet members who each own a domain and stay in their lane.
|
|
80
|
+
**Speed-freak** watches performance. **Boundary-man** catches edge cases.
|
|
81
|
+
**Record-keeper** flags when docs drift from code. **Workflow-cop**
|
|
68
82
|
evaluates whether your process actually works. Each member has a
|
|
69
|
-
portfolio,
|
|
70
|
-
|
|
83
|
+
portfolio, produces structured findings, and knows when to speak up
|
|
84
|
+
and when to stay quiet.
|
|
85
|
+
|
|
86
|
+
You convene the cabinet with **`/audit`** — run it occasionally (every
|
|
87
|
+
few sessions, or before a release) to get a full review from every
|
|
88
|
+
relevant member. You don't need to audit every session. The cabinet
|
|
89
|
+
waits until called.
|
|
90
|
+
|
|
91
|
+
Members are organized into **committees** — groups by concern, so you
|
|
92
|
+
can convene just the experts you need. Security review? Convene the
|
|
93
|
+
security committee. Performance concerns? Just the speed committee.
|
|
94
|
+
|
|
95
|
+
### Planning + Execution (included in lean)
|
|
96
|
+
|
|
97
|
+
Don't just start building — brief the cabinet first.
|
|
98
|
+
|
|
99
|
+
- **`/plan`** — describe what you want to build. Claude drafts a plan,
|
|
100
|
+
then the relevant cabinet members critique it before a single line is
|
|
101
|
+
written. The security member notices the missing auth check. The
|
|
102
|
+
data integrity member catches the NULL handling gap. You approve the
|
|
103
|
+
plan, and it carries enough detail for any future session to execute
|
|
104
|
+
without re-exploring.
|
|
105
|
+
- **`/execute`** — pick up an approved plan and build it step by step.
|
|
106
|
+
Cabinet members watch at each checkpoint. The plan tells Claude what
|
|
107
|
+
to do; execute makes sure it gets done right.
|
|
71
108
|
|
|
72
|
-
###
|
|
73
|
-
- **`/plan`** — structured planning with cabinet critique. Before you
|
|
74
|
-
build, the relevant members weigh in on your approach.
|
|
75
|
-
- **`/execute`** — step-through execution with checkpoints. Cabinet
|
|
76
|
-
members watch at each stage.
|
|
109
|
+
### Work Tracking (full install)
|
|
77
110
|
|
|
78
|
-
### Work Tracking (opt-in)
|
|
79
111
|
Local SQLite database for actions, projects, and status tracking. Claude
|
|
80
112
|
reads and writes it directly — no external service needed. Skip this if
|
|
81
113
|
you already use GitHub Issues, Linear, or something else.
|
|
82
114
|
|
|
83
|
-
### Compliance Stack (
|
|
115
|
+
### Compliance Stack (full install)
|
|
116
|
+
|
|
84
117
|
Scoped instructions in `.claude/rules/` that load by file path. An
|
|
85
118
|
enforcement pipeline that promotes recurring feedback into deterministic
|
|
86
119
|
hooks — things that keep going wrong become things that can't go wrong.
|
|
87
120
|
|
|
88
|
-
### Lifecycle (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
- **`/
|
|
121
|
+
### Lifecycle (included in lean)
|
|
122
|
+
|
|
123
|
+
- **`/onboard`** — the cabinet's first briefing. Claude interviews you
|
|
124
|
+
about your project and prepares everything the members need to do
|
|
125
|
+
their jobs. Re-run it as the project matures — the interview adapts.
|
|
126
|
+
- **`/seed`** — recruit new members. Claude detects new tech in your
|
|
127
|
+
project and proposes expert members to cover it. Your cabinet grows
|
|
128
|
+
with your project.
|
|
129
|
+
- **`/cc-upgrade`** — when Claude Cabinet publishes updates, this skill
|
|
130
|
+
runs the installer for the mechanical parts and walks you through
|
|
131
|
+
what changed conversationally. Intelligence is the merge strategy.
|
|
132
|
+
|
|
133
|
+
## Your Workflow
|
|
134
|
+
|
|
135
|
+
The day-to-day rhythm:
|
|
136
|
+
|
|
137
|
+
1. **Start a session** → `/orient` (get briefed)
|
|
138
|
+
2. **Do your work** → talk to Claude, use `/plan` for anything non-trivial
|
|
139
|
+
3. **Build it** → `/execute` to implement approved plans with cabinet oversight
|
|
140
|
+
4. **Check quality** → `/audit` occasionally for a full cabinet review
|
|
141
|
+
5. **Close the session** → `/debrief` (close the loop)
|
|
142
|
+
|
|
143
|
+
Steps 1 and 5 are the minimum. Everything in between is yours to use as
|
|
144
|
+
needed. The more you use, the more the cabinet learns about your project.
|
|
94
145
|
|
|
95
146
|
## How It Works
|
|
96
147
|
|
package/lib/cli.js
CHANGED
|
@@ -105,9 +105,10 @@ function detectProjectState(dir) {
|
|
|
105
105
|
const entries = fs.readdirSync(dir);
|
|
106
106
|
const signals = entries.filter(e => PROJECT_SIGNALS.includes(e));
|
|
107
107
|
const hasClaude = entries.includes('.claude');
|
|
108
|
-
const
|
|
108
|
+
const hasCcrc = fs.existsSync(path.join(dir, '.ccrc.json'));
|
|
109
|
+
const hasCorrc = fs.existsSync(path.join(dir, '.corrc.json'));
|
|
109
110
|
|
|
110
|
-
if (
|
|
111
|
+
if (hasCcrc || hasCorrc) return 'existing-install';
|
|
111
112
|
if (signals.length > 0) return 'existing-project';
|
|
112
113
|
// Allow a few dotfiles (e.g. .git) without calling it a project
|
|
113
114
|
if (entries.filter(e => !e.startsWith('.')).length === 0) return 'empty';
|
package/lib/copy.js
CHANGED
|
@@ -64,7 +64,9 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
|
|
|
64
64
|
results.manifest[relPath] = incomingHash;
|
|
65
65
|
} else {
|
|
66
66
|
results.skipped.push(relPath);
|
|
67
|
-
|
|
67
|
+
// Record the hash of what's actually on disk, not the template —
|
|
68
|
+
// otherwise the manifest lies about file content after a skip.
|
|
69
|
+
results.manifest[relPath] = hashContent(existing);
|
|
68
70
|
}
|
|
69
71
|
continue;
|
|
70
72
|
}
|
package/lib/metadata.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
4
|
const METADATA_FILE = '.ccrc.json';
|
|
5
|
+
const LEGACY_METADATA_FILE = '.corrc.json';
|
|
5
6
|
|
|
6
7
|
function metadataPath(projectDir) {
|
|
7
8
|
return path.join(projectDir, METADATA_FILE);
|
|
@@ -9,8 +10,11 @@ function metadataPath(projectDir) {
|
|
|
9
10
|
|
|
10
11
|
function read(projectDir) {
|
|
11
12
|
const file = metadataPath(projectDir);
|
|
12
|
-
if (
|
|
13
|
-
|
|
13
|
+
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
14
|
+
// Fall back to legacy manifest from pre-v0.6.0 installs
|
|
15
|
+
const legacyFile = path.join(projectDir, LEGACY_METADATA_FILE);
|
|
16
|
+
if (fs.existsSync(legacyFile)) return JSON.parse(fs.readFileSync(legacyFile, 'utf8'));
|
|
17
|
+
return null;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
function write(projectDir, data) {
|
package/package.json
CHANGED
|
@@ -84,7 +84,7 @@ these with `"type": "positive"`:
|
|
|
84
84
|
{
|
|
85
85
|
"id": "{cabinet-member}-p{NNNN}",
|
|
86
86
|
"type": "positive",
|
|
87
|
-
"
|
|
87
|
+
"cabinet-member": "{cabinet-member-name}",
|
|
88
88
|
"severity": "info",
|
|
89
89
|
"title": "Healthy subsystem confirmation",
|
|
90
90
|
"description": "What was checked and found healthy",
|
|
@@ -127,7 +127,7 @@ Return valid JSON matching `scripts/finding-schema.json`.
|
|
|
127
127
|
{
|
|
128
128
|
"id": "{cabinet-member}-{NNNN}",
|
|
129
129
|
"type": "finding",
|
|
130
|
-
"
|
|
130
|
+
"cabinet-member": "{cabinet-member-name}",
|
|
131
131
|
"severity": "critical|warn|info|idea",
|
|
132
132
|
"title": "Short description (max 120 chars)",
|
|
133
133
|
"description": "Full explanation",
|
|
@@ -138,7 +138,7 @@ Return valid JSON matching `scripts/finding-schema.json`.
|
|
|
138
138
|
}
|
|
139
139
|
],
|
|
140
140
|
"meta": {
|
|
141
|
-
"
|
|
141
|
+
"cabinet-member": "{cabinet-member-name}",
|
|
142
142
|
"timestamp": "ISO-8601"
|
|
143
143
|
}
|
|
144
144
|
}
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
"type": "array",
|
|
8
8
|
"items": {
|
|
9
9
|
"type": "object",
|
|
10
|
-
"required": ["id", "
|
|
10
|
+
"required": ["id", "cabinet-member", "severity", "title", "description", "autoFixable"],
|
|
11
11
|
"properties": {
|
|
12
12
|
"id": {
|
|
13
13
|
"type": "string",
|
|
14
14
|
"description": "Unique finding ID: {cabinet-member}-{NNNN}",
|
|
15
15
|
"pattern": "^[a-z-]+-\\d{4}$"
|
|
16
16
|
},
|
|
17
|
-
"
|
|
17
|
+
"cabinet-member": {
|
|
18
18
|
"type": "string",
|
|
19
19
|
"description": "Cabinet member name matching the directory name in skills/cabinet-*/",
|
|
20
20
|
"pattern": "^[a-z][a-z0-9-]+$"
|
|
@@ -81,9 +81,9 @@
|
|
|
81
81
|
},
|
|
82
82
|
"meta": {
|
|
83
83
|
"type": "object",
|
|
84
|
-
"required": ["
|
|
84
|
+
"required": ["cabinet-member", "timestamp"],
|
|
85
85
|
"properties": {
|
|
86
|
-
"
|
|
86
|
+
"cabinet-member": { "type": "string" },
|
|
87
87
|
"timestamp": { "type": "string", "format": "date-time" },
|
|
88
88
|
"commitHash": { "type": "string" },
|
|
89
89
|
"durationSeconds": { "type": "number" },
|
|
@@ -47,12 +47,12 @@ function tryDatabase() {
|
|
|
47
47
|
const db = new Database(DB_PATH, { readonly: true });
|
|
48
48
|
|
|
49
49
|
const rejected = db.prepare(`
|
|
50
|
-
SELECT id,
|
|
50
|
+
SELECT id, cabinet_member, title FROM audit_findings
|
|
51
51
|
WHERE triage_status = 'rejected'
|
|
52
52
|
`).all();
|
|
53
53
|
|
|
54
54
|
const deferred = db.prepare(`
|
|
55
|
-
SELECT id,
|
|
55
|
+
SELECT id, cabinet_member, title FROM audit_findings
|
|
56
56
|
WHERE triage_status = 'deferred'
|
|
57
57
|
`).all();
|
|
58
58
|
|
|
@@ -60,12 +60,12 @@ function tryDatabase() {
|
|
|
60
60
|
|
|
61
61
|
result.rejectedIds = rejected.map(r => r.id);
|
|
62
62
|
result.rejectedFingerprints = rejected.map(r => ({
|
|
63
|
-
|
|
63
|
+
'cabinet-member': r.cabinet_member,
|
|
64
64
|
title: r.title,
|
|
65
65
|
}));
|
|
66
66
|
result.deferredIds = deferred.map(r => r.id);
|
|
67
67
|
result.deferredFingerprints = deferred.map(r => ({
|
|
68
|
-
|
|
68
|
+
'cabinet-member': r.cabinet_member,
|
|
69
69
|
title: r.title,
|
|
70
70
|
}));
|
|
71
71
|
|
|
@@ -116,7 +116,7 @@ function tryFilesystem() {
|
|
|
116
116
|
const verdicts = data.verdicts || data;
|
|
117
117
|
|
|
118
118
|
for (const v of (Array.isArray(verdicts) ? verdicts : [])) {
|
|
119
|
-
const fp = {
|
|
119
|
+
const fp = { 'cabinet-member': v['cabinet-member'] || v.perspective, title: v.title };
|
|
120
120
|
|
|
121
121
|
if (v.verdict === 'reject' || v.status === 'rejected') {
|
|
122
122
|
if (v.id && !result.rejectedIds.includes(v.id)) {
|
|
@@ -54,7 +54,7 @@ if (files.length === 0) {
|
|
|
54
54
|
|
|
55
55
|
const allFindings = [];
|
|
56
56
|
const seenIds = new Set();
|
|
57
|
-
const
|
|
57
|
+
const memberCounts = {};
|
|
58
58
|
const severityCounts = { critical: 0, warn: 0, info: 0, idea: 0 };
|
|
59
59
|
let positiveCount = 0;
|
|
60
60
|
|
|
@@ -62,7 +62,7 @@ for (const file of files) {
|
|
|
62
62
|
try {
|
|
63
63
|
const data = JSON.parse(readFileSync(join(runDir, file), 'utf-8'));
|
|
64
64
|
const findings = data.findings || [];
|
|
65
|
-
const
|
|
65
|
+
const member = data.meta?.['cabinet-member'] || basename(file, '.json');
|
|
66
66
|
|
|
67
67
|
for (const f of findings) {
|
|
68
68
|
if (seenIds.has(f.id)) continue;
|
|
@@ -74,11 +74,11 @@ for (const file of files) {
|
|
|
74
74
|
positiveCount++;
|
|
75
75
|
} else {
|
|
76
76
|
severityCounts[f.severity] = (severityCounts[f.severity] || 0) + 1;
|
|
77
|
-
|
|
77
|
+
memberCounts[member] = (memberCounts[member] || 0) + 1;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
console.log(` ${
|
|
81
|
+
console.log(` ${member}: ${findings.length} findings`);
|
|
82
82
|
} catch (err) {
|
|
83
83
|
console.error(` Error reading ${file}: ${err.message}`);
|
|
84
84
|
}
|
|
@@ -96,14 +96,14 @@ const summary = {
|
|
|
96
96
|
runId,
|
|
97
97
|
timestamp,
|
|
98
98
|
trigger: 'manual',
|
|
99
|
-
|
|
99
|
+
members: Object.keys(memberCounts),
|
|
100
100
|
counts: {
|
|
101
101
|
total: allFindings.length,
|
|
102
102
|
findings: allFindings.length - positiveCount,
|
|
103
103
|
positive: positiveCount,
|
|
104
104
|
...severityCounts,
|
|
105
105
|
},
|
|
106
|
-
|
|
106
|
+
byMember: memberCounts,
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
|
|
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS audit_runs (
|
|
|
48
48
|
CREATE TABLE IF NOT EXISTS audit_findings (
|
|
49
49
|
id TEXT PRIMARY KEY,
|
|
50
50
|
run_id TEXT NOT NULL REFERENCES audit_runs(id),
|
|
51
|
-
|
|
51
|
+
cabinet_member TEXT NOT NULL, -- renamed from 'perspective' in v0.7; migration: ALTER TABLE audit_findings RENAME COLUMN perspective TO cabinet_member
|
|
52
52
|
severity TEXT NOT NULL CHECK(severity IN ('critical','warn','info','idea')),
|
|
53
53
|
title TEXT NOT NULL,
|
|
54
54
|
description TEXT,
|
|
@@ -227,7 +227,7 @@ function ingestFindings(runDir) {
|
|
|
227
227
|
|
|
228
228
|
const insert = d.prepare(`
|
|
229
229
|
INSERT OR REPLACE INTO audit_findings
|
|
230
|
-
(id, run_id,
|
|
230
|
+
(id, run_id, cabinet_member, severity, title, description, assumption,
|
|
231
231
|
evidence, question, file, line, suggested_fix, auto_fixable, type)
|
|
232
232
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
233
233
|
`);
|
|
@@ -235,7 +235,7 @@ function ingestFindings(runDir) {
|
|
|
235
235
|
let count = 0;
|
|
236
236
|
for (const f of (data.findings || [])) {
|
|
237
237
|
insert.run(
|
|
238
|
-
f.id, runId, f
|
|
238
|
+
f.id, runId, f['cabinet-member'], f.severity, f.title,
|
|
239
239
|
f.description || null, f.assumption || null, f.evidence || null,
|
|
240
240
|
f.question || null, f.file || null, f.line || null,
|
|
241
241
|
f.suggestedFix || null, f.autoFixable ? 1 : 0, f.type || 'finding'
|
|
@@ -262,20 +262,20 @@ function triageHistory() {
|
|
|
262
262
|
const d = getDb();
|
|
263
263
|
|
|
264
264
|
const rejected = d.prepare(`
|
|
265
|
-
SELECT id,
|
|
265
|
+
SELECT id, cabinet_member, title FROM audit_findings
|
|
266
266
|
WHERE triage_status = 'rejected'
|
|
267
267
|
`).all();
|
|
268
268
|
|
|
269
269
|
const deferred = d.prepare(`
|
|
270
|
-
SELECT id,
|
|
270
|
+
SELECT id, cabinet_member, title FROM audit_findings
|
|
271
271
|
WHERE triage_status = 'deferred'
|
|
272
272
|
`).all();
|
|
273
273
|
|
|
274
274
|
const result = {
|
|
275
275
|
rejectedIds: rejected.map(r => r.id),
|
|
276
|
-
rejectedFingerprints: rejected.map(r => ({
|
|
276
|
+
rejectedFingerprints: rejected.map(r => ({ 'cabinet-member': r.cabinet_member, title: r.title })),
|
|
277
277
|
deferredIds: deferred.map(r => r.id),
|
|
278
|
-
deferredFingerprints: deferred.map(r => ({
|
|
278
|
+
deferredFingerprints: deferred.map(r => ({ 'cabinet-member': r.cabinet_member, title: r.title })),
|
|
279
279
|
};
|
|
280
280
|
console.log(JSON.stringify(result, null, 2));
|
|
281
281
|
return result;
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
.run-info { color: #909296; font-size: 12px; }
|
|
31
31
|
|
|
32
32
|
/* Perspective groups */
|
|
33
|
-
.
|
|
33
|
+
.member-group {
|
|
34
34
|
margin-bottom: 16px;
|
|
35
35
|
border: 1px solid #373a40;
|
|
36
36
|
border-radius: 8px;
|
|
37
37
|
overflow: hidden;
|
|
38
38
|
}
|
|
39
|
-
.
|
|
39
|
+
.member-header {
|
|
40
40
|
display: flex;
|
|
41
41
|
align-items: center;
|
|
42
42
|
justify-content: space-between;
|
|
@@ -45,21 +45,21 @@
|
|
|
45
45
|
cursor: pointer;
|
|
46
46
|
user-select: none;
|
|
47
47
|
}
|
|
48
|
-
.
|
|
49
|
-
.
|
|
48
|
+
.member-header:hover { background: #2c2e33; }
|
|
49
|
+
.member-name {
|
|
50
50
|
font-weight: 600;
|
|
51
51
|
color: #e9ecef;
|
|
52
52
|
font-size: 14px;
|
|
53
53
|
}
|
|
54
|
-
.
|
|
54
|
+
.member-stats {
|
|
55
55
|
display: flex;
|
|
56
56
|
gap: 8px;
|
|
57
57
|
align-items: center;
|
|
58
58
|
font-size: 12px;
|
|
59
59
|
color: #909296;
|
|
60
60
|
}
|
|
61
|
-
.
|
|
62
|
-
.
|
|
61
|
+
.member-body { padding: 0; }
|
|
62
|
+
.member-body.collapsed { display: none; }
|
|
63
63
|
|
|
64
64
|
/* Finding rows */
|
|
65
65
|
.finding {
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
.feedback-input::placeholder { color: #5c5f66; }
|
|
137
137
|
|
|
138
138
|
/* Perspective-level controls */
|
|
139
|
-
.
|
|
139
|
+
.member-controls {
|
|
140
140
|
padding: 8px 14px;
|
|
141
141
|
background: #2c2e33;
|
|
142
142
|
border-top: 1px solid #373a40;
|
|
@@ -144,14 +144,14 @@
|
|
|
144
144
|
flex-direction: column;
|
|
145
145
|
gap: 6px;
|
|
146
146
|
}
|
|
147
|
-
.
|
|
147
|
+
.member-controls-row {
|
|
148
148
|
display: flex;
|
|
149
149
|
gap: 8px;
|
|
150
150
|
align-items: center;
|
|
151
151
|
font-size: 12px;
|
|
152
152
|
}
|
|
153
|
-
.
|
|
154
|
-
.
|
|
153
|
+
.member-controls-row label { color: #909296; flex-shrink: 0; }
|
|
154
|
+
.member-input {
|
|
155
155
|
flex: 1;
|
|
156
156
|
padding: 4px 8px;
|
|
157
157
|
border: 1px solid #373a40;
|
|
@@ -160,8 +160,8 @@
|
|
|
160
160
|
color: #c1c2c5;
|
|
161
161
|
font-size: 12px;
|
|
162
162
|
}
|
|
163
|
-
.
|
|
164
|
-
.
|
|
163
|
+
.member-input::placeholder { color: #5c5f66; }
|
|
164
|
+
.member-q-btn {
|
|
165
165
|
padding: 3px 10px;
|
|
166
166
|
border: 1px solid #1c7ed6;
|
|
167
167
|
border-radius: 4px;
|
|
@@ -172,8 +172,8 @@
|
|
|
172
172
|
font-weight: 500;
|
|
173
173
|
flex-shrink: 0;
|
|
174
174
|
}
|
|
175
|
-
.
|
|
176
|
-
.
|
|
175
|
+
.member-q-btn:hover { background: #1c7ed622; }
|
|
176
|
+
.member-q-btn.active { background: #1c7ed6; color: #fff; }
|
|
177
177
|
|
|
178
178
|
/* Bulk actions */
|
|
179
179
|
.bulk-bar {
|
|
@@ -284,7 +284,7 @@
|
|
|
284
284
|
// ── State ──
|
|
285
285
|
let findings = [];
|
|
286
286
|
const verdicts = {}; // { findingId: { verdict, feedback } }
|
|
287
|
-
const
|
|
287
|
+
const memberNotes = {}; // { member: { comment, question } }
|
|
288
288
|
|
|
289
289
|
// ── Public API for Claude (javascript_tool) ──
|
|
290
290
|
window.loadFindings = function(data) {
|
|
@@ -304,12 +304,12 @@
|
|
|
304
304
|
total: findings.length,
|
|
305
305
|
triaged: Object.keys(verdicts).length,
|
|
306
306
|
submitted: !!window._submitted,
|
|
307
|
-
|
|
308
|
-
Object.entries(
|
|
307
|
+
memberNotes: Object.fromEntries(
|
|
308
|
+
Object.entries(memberNotes).filter(([_, v]) => v.comment || v.question)
|
|
309
309
|
),
|
|
310
310
|
verdicts: findings.map(f => ({
|
|
311
311
|
id: f.id,
|
|
312
|
-
|
|
312
|
+
'cabinet-member': f['cabinet-member'],
|
|
313
313
|
severity: f.severity,
|
|
314
314
|
title: f.title,
|
|
315
315
|
verdict: verdicts[f.id]?.verdict || null,
|
|
@@ -347,32 +347,33 @@
|
|
|
347
347
|
document.getElementById('loading').style.display = 'none';
|
|
348
348
|
document.getElementById('bottom-bar').style.display = 'flex';
|
|
349
349
|
|
|
350
|
-
// Group by
|
|
350
|
+
// Group by cabinet member
|
|
351
351
|
const groups = {};
|
|
352
352
|
findings.forEach(f => {
|
|
353
|
-
|
|
354
|
-
groups[
|
|
353
|
+
const m = f['cabinet-member'];
|
|
354
|
+
if (!groups[m]) groups[m] = [];
|
|
355
|
+
groups[m].push(f);
|
|
355
356
|
});
|
|
356
357
|
|
|
357
358
|
const root = document.getElementById('findings-root');
|
|
358
359
|
root.innerHTML = '';
|
|
359
360
|
|
|
360
|
-
// Sort
|
|
361
|
-
const
|
|
361
|
+
// Sort members alphabetically
|
|
362
|
+
const members = Object.keys(groups).sort();
|
|
362
363
|
|
|
363
|
-
|
|
364
|
-
const items = groups[
|
|
364
|
+
members.forEach(member => {
|
|
365
|
+
const items = groups[member];
|
|
365
366
|
const triaged = items.filter(f => verdicts[f.id]?.verdict).length;
|
|
366
367
|
|
|
367
368
|
const group = document.createElement('div');
|
|
368
|
-
group.className = '
|
|
369
|
+
group.className = 'member-group';
|
|
369
370
|
|
|
370
371
|
// Header
|
|
371
372
|
const header = document.createElement('div');
|
|
372
|
-
header.className = '
|
|
373
|
+
header.className = 'member-header';
|
|
373
374
|
header.innerHTML = `
|
|
374
|
-
<span class="
|
|
375
|
-
<span class="
|
|
375
|
+
<span class="member-name">${member} (${items.length})</span>
|
|
376
|
+
<span class="member-stats">
|
|
376
377
|
${triaged}/${items.length} triaged
|
|
377
378
|
</span>
|
|
378
379
|
`;
|
|
@@ -383,7 +384,7 @@
|
|
|
383
384
|
|
|
384
385
|
// Body
|
|
385
386
|
const body = document.createElement('div');
|
|
386
|
-
body.className = '
|
|
387
|
+
body.className = 'member-body';
|
|
387
388
|
|
|
388
389
|
items.forEach(f => {
|
|
389
390
|
const row = document.createElement('div');
|
|
@@ -413,21 +414,21 @@
|
|
|
413
414
|
bulk.className = 'bulk-bar';
|
|
414
415
|
bulk.innerHTML = `
|
|
415
416
|
<label>Bulk:</label>
|
|
416
|
-
<button class="bulk-btn" data-p="${
|
|
417
|
-
<button class="bulk-btn" data-p="${
|
|
418
|
-
<button class="bulk-btn" data-p="${
|
|
417
|
+
<button class="bulk-btn" data-p="${member}" data-v="fix">All Fix</button>
|
|
418
|
+
<button class="bulk-btn" data-p="${member}" data-v="defer">All Defer</button>
|
|
419
|
+
<button class="bulk-btn" data-p="${member}" data-v="reject">All Reject</button>
|
|
419
420
|
`;
|
|
420
421
|
body.appendChild(bulk);
|
|
421
422
|
|
|
422
|
-
//
|
|
423
|
-
const pn =
|
|
423
|
+
// Member-level controls
|
|
424
|
+
const pn = memberNotes[member] || { comment: '', question: false };
|
|
424
425
|
const pControls = document.createElement('div');
|
|
425
|
-
pControls.className = '
|
|
426
|
+
pControls.className = 'member-controls';
|
|
426
427
|
pControls.innerHTML = `
|
|
427
|
-
<div class="
|
|
428
|
-
<button class="
|
|
429
|
-
<input class="
|
|
430
|
-
placeholder="${pn.question ? 'your question about this
|
|
428
|
+
<div class="member-controls-row">
|
|
429
|
+
<button class="member-q-btn ${pn.question ? 'active' : ''}" data-member="${member}">?</button>
|
|
430
|
+
<input class="member-input" data-member="${member}"
|
|
431
|
+
placeholder="${pn.question ? 'your question about this member...' : 'comment on this member...'}"
|
|
431
432
|
value="${esc(pn.comment || '')}">
|
|
432
433
|
</div>
|
|
433
434
|
`;
|
|
@@ -479,9 +480,9 @@
|
|
|
479
480
|
}
|
|
480
481
|
// Bulk buttons
|
|
481
482
|
if (e.target.classList.contains('bulk-btn')) {
|
|
482
|
-
const
|
|
483
|
+
const member = e.target.dataset.p;
|
|
483
484
|
const v = e.target.dataset.v;
|
|
484
|
-
findings.filter(f => f
|
|
485
|
+
findings.filter(f => f['cabinet-member'] === member).forEach(f => {
|
|
485
486
|
if (!verdicts[f.id]) verdicts[f.id] = { verdict: '', feedback: '' };
|
|
486
487
|
verdicts[f.id].verdict = v;
|
|
487
488
|
});
|
|
@@ -490,10 +491,10 @@
|
|
|
490
491
|
});
|
|
491
492
|
|
|
492
493
|
document.addEventListener('click', e => {
|
|
493
|
-
if (e.target.classList.contains('
|
|
494
|
-
const p = e.target.dataset.
|
|
495
|
-
if (!
|
|
496
|
-
|
|
494
|
+
if (e.target.classList.contains('member-q-btn')) {
|
|
495
|
+
const p = e.target.dataset.member;
|
|
496
|
+
if (!memberNotes[p]) memberNotes[p] = { comment: '', question: false };
|
|
497
|
+
memberNotes[p].question = !memberNotes[p].question;
|
|
497
498
|
render();
|
|
498
499
|
}
|
|
499
500
|
});
|
|
@@ -504,10 +505,10 @@
|
|
|
504
505
|
if (!verdicts[id]) verdicts[id] = { verdict: '', feedback: '' };
|
|
505
506
|
verdicts[id].feedback = e.target.value;
|
|
506
507
|
}
|
|
507
|
-
if (e.target.classList.contains('
|
|
508
|
-
const p = e.target.dataset.
|
|
509
|
-
if (!
|
|
510
|
-
|
|
508
|
+
if (e.target.classList.contains('member-input')) {
|
|
509
|
+
const p = e.target.dataset.member;
|
|
510
|
+
if (!memberNotes[p]) memberNotes[p] = { comment: '', question: false };
|
|
511
|
+
memberNotes[p].comment = e.target.value;
|
|
511
512
|
}
|
|
512
513
|
});
|
|
513
514
|
|