create-claude-cabinet 0.28.0 → 0.29.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.
Files changed (53) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +1 -0
  3. package/package.json +1 -1
  4. package/templates/cabinet/critique-contract.md +63 -0
  5. package/templates/cabinet/output-contract.md +19 -0
  6. package/templates/scripts/finding-schema.json +49 -0
  7. package/templates/scripts/merge-findings.js +23 -14
  8. package/templates/scripts/pib-db-lib.mjs +31 -8
  9. package/templates/scripts/pib-db-schema.sql +5 -1
  10. package/templates/scripts/triage-ui.html +178 -5
  11. package/templates/site-audit-runtime/package.json +1 -2
  12. package/templates/site-audit-runtime/src/checks/axe-core.mjs +17 -1
  13. package/templates/site-audit-runtime/src/checks/blacklight.mjs +7 -0
  14. package/templates/site-audit-runtime/src/checks/dns.mjs +12 -1
  15. package/templates/site-audit-runtime/src/checks/lighthouse.mjs +15 -3
  16. package/templates/site-audit-runtime/src/checks/linkinator.mjs +5 -0
  17. package/templates/site-audit-runtime/src/checks/meta-og.mjs +8 -1
  18. package/templates/site-audit-runtime/src/checks/nuclei.mjs +9 -1
  19. package/templates/site-audit-runtime/src/checks/observatory.mjs +7 -1
  20. package/templates/site-audit-runtime/src/checks/pa11y.mjs +7 -0
  21. package/templates/site-audit-runtime/src/checks/security-headers.mjs +5 -0
  22. package/templates/site-audit-runtime/src/checks/ssl-cert.mjs +15 -1
  23. package/templates/site-audit-runtime/src/checks/structured-data.mjs +7 -1
  24. package/templates/site-audit-runtime/src/checks/testssl.mjs +10 -1
  25. package/templates/site-audit-runtime/src/checks/unlighthouse.mjs +7 -1
  26. package/templates/site-audit-runtime/src/checks/website-carbon.mjs +7 -1
  27. package/templates/site-audit-runtime/src/cli.mjs +1 -1
  28. package/templates/site-audit-runtime/src/orchestrator.mjs +9 -4
  29. package/templates/site-audit-runtime/src/report.mjs +128 -17
  30. package/templates/site-audit-runtime/src/schema.mjs +2 -0
  31. package/templates/site-audit-runtime/tests/orchestrator.test.mjs +5 -1
  32. package/templates/skills/audit/SKILL.md +43 -1
  33. package/templates/skills/verify/SKILL.md +177 -27
  34. package/templates/skills/verify/install.sh +2 -1
  35. package/templates/skills/verify/phases/cleanup.md +38 -0
  36. package/templates/skills/verify/phases/post-run.md +16 -0
  37. package/templates/skills/verify/phases/run.md +24 -0
  38. package/templates/skills/verify/phases/scenario-template.md +30 -0
  39. package/templates/verify-runtime/CONVENTIONS.md +37 -0
  40. package/templates/verify-runtime/package-lock.json +165 -50
  41. package/templates/verify-runtime/package.json +2 -2
  42. package/templates/verify-runtime/src/baseline-steps.ts +1 -0
  43. package/templates/verify-runtime/src/demo-recorder.ts +46 -0
  44. package/templates/verify-runtime/src/human-verdict.ts +79 -14
  45. package/templates/verify-runtime/src/index.ts +20 -1
  46. package/templates/verify-runtime/src/launch-options.ts +28 -0
  47. package/templates/verify-runtime/src/output.ts +13 -0
  48. package/templates/verify-runtime/src/pause-on-failure.ts +53 -0
  49. package/templates/verify-runtime/src/trace.ts +15 -0
  50. package/templates/verify-runtime/src/world.ts +66 -16
  51. package/templates/verify-runtime/test/demo-mode.test.ts +85 -0
  52. package/templates/verify-runtime/test/trace.test.ts +40 -0
  53. package/templates/workflows/deliberative-audit.js +325 -0
package/README.md CHANGED
@@ -90,6 +90,16 @@ few sessions, or before a release) to get a full review from every
90
90
  relevant member. You don't need to audit every session. The cabinet
91
91
  waits until called.
92
92
 
93
+ Each member is also a registered **agent type** — invoke any member
94
+ directly with `@cabinet-security`, `@cabinet-architecture`, etc.
95
+
96
+ When the Workflow tool is available, `/audit` runs a **deliberative
97
+ workflow**: Stage-1 members investigate, then Stage-2 critics
98
+ (anti-confirmation, QA, architecture) annotate findings — challenging
99
+ assumptions, adding context, or confirming. Findings arrive to triage
100
+ with the debate already attached. Optional `--rebuttal` mode lets
101
+ challenged members respond before triage.
102
+
93
103
  Members are organized into **committees** — groups by concern, so you
94
104
  can convene just the experts you need. Security review? Convene the
95
105
  security committee. Performance concerns? Just the speed committee.
@@ -235,6 +245,8 @@ source code.
235
245
  │ # (incl. pib-db-access.md, pib-db-triggers.md)
236
246
  ├── briefing/ # project briefing templates
237
247
  ├── hooks/ # git guardrails, telemetry
248
+ ├── agents/ # generated agent-type wrappers (enables @cabinet-*)
249
+ ├── workflows/ # deliberative-audit.js (two-stage audit workflow)
238
250
  ├── rules/ # enforcement pipeline
239
251
  ├── memory/ # pattern templates
240
252
  └── settings.json # hook configuration
package/lib/cli.js CHANGED
@@ -546,6 +546,7 @@ const MODULES = {
546
546
  'scripts/triage-server.mjs', 'scripts/triage-ui.html',
547
547
  'scripts/finding-schema.json', 'scripts/resolve-committees.cjs',
548
548
  'scripts/review-server.mjs', 'scripts/review-ui.html',
549
+ 'workflows/deliberative-audit.js', 'cabinet/critique-contract.md',
549
550
  ],
550
551
  },
551
552
  'lifecycle': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.28.0",
3
+ "version": "0.29.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"
@@ -0,0 +1,63 @@
1
+ # Critique Contract — Stage-2 Audit Output
2
+
3
+ This contract defines how **Stage-2 critics** annotate Stage-1 findings
4
+ during a deliberative audit. Stage-1 members produce findings (see
5
+ `output-contract.md`). Stage-2 members review those findings through
6
+ their domain lens and produce annotations.
7
+
8
+ ## Input
9
+
10
+ You receive a JSON array of findings from Stage-1 members. Each finding
11
+ has the fields defined in `output-contract.md`: id, cabinet-member,
12
+ severity, title, description, evidence, etc.
13
+
14
+ ## Your Task
15
+
16
+ Review each finding through your domain lens. For findings that touch
17
+ your expertise, produce an annotation. For findings outside your domain,
18
+ stay silent — silence means "no objection from my perspective."
19
+
20
+ Default to no annotation. Only speak when you have something to add:
21
+ a factual correction, a challenge to an assumption, supporting evidence,
22
+ or additional context that changes how the finding should be interpreted.
23
+
24
+ ## Annotation Types
25
+
26
+ | Type | When to use | Example |
27
+ |------|------------|---------|
28
+ | `challenge` | You disagree with the finding's conclusion or severity | "This input is already escaped by the ORM — the vulnerability described doesn't exist in the current implementation" |
29
+ | `support` | You have independent evidence confirming the finding | "The audit log also replicates to analytics DB — blast radius is wider than stated" |
30
+ | `context` | Additional information that changes interpretation | "This coupling is intentional — it's the auth boundary described in the architecture doc" |
31
+ | `correction` | A factual error in the finding | "The file path referenced was renamed in commit abc123 — the actual location is..." |
32
+
33
+ ## Output Format
34
+
35
+ Return a JSON object matching this schema:
36
+
37
+ ```json
38
+ {
39
+ "annotations": [
40
+ {
41
+ "findingId": "security-0001",
42
+ "type": "challenge",
43
+ "text": "Your annotation here",
44
+ "severitySuggestion": "info"
45
+ }
46
+ ]
47
+ }
48
+ ```
49
+
50
+ Fields:
51
+ - **findingId** (required): the `id` of the Stage-1 finding you're annotating
52
+ - **type** (required): one of `challenge`, `support`, `context`, `correction`
53
+ - **text** (required): your annotation — be specific and cite evidence
54
+ - **severitySuggestion** (optional): if you think the severity should change,
55
+ suggest the new level. Omit if the current severity is appropriate.
56
+
57
+ ## Scope Rules
58
+
59
+ - Annotate only findings that intersect your domain expertise.
60
+ - You may annotate findings from any Stage-1 member, not just your own domain.
61
+ - Do not produce new findings — that's Stage 1's job.
62
+ - Do not repeat or rephrase a finding. Add to it or challenge it.
63
+ - If two findings contradict each other, annotate both to surface the tension.
@@ -146,3 +146,22 @@ Return valid JSON matching `scripts/finding-schema.json`.
146
146
 
147
147
  Your response must be ONLY the JSON object — no markdown fences, no
148
148
  commentary outside the JSON.
149
+
150
+ ## Deliberation Fields (Two-Stage Audit)
151
+
152
+ When the audit runs in two-stage deliberative mode, findings may acquire
153
+ additional fields after initial creation:
154
+
155
+ - `status` — Set during deliberation. Values: `upheld` (no challenges),
156
+ `challenged` (critic disagreed), `modified` (author accepted critique),
157
+ `withdrawn` (author conceded), `rebutted` (author defended).
158
+ - `annotations[]` — Array of Stage-2 critic annotations. Each has
159
+ `cabinet-member`, `type` (challenge/support/context/correction),
160
+ `text`, and optional `severity-suggestion`.
161
+ - `rebuttal` — The original author's response to challenges. Has
162
+ `response` (withdraw/modify/defend) and `comment`.
163
+
164
+ **Stage-1 members:** Emit findings as normal using the schema above.
165
+ These deliberation fields are added later by the workflow.
166
+
167
+ **Stage-2 members:** Use `critique-contract.md` instead of this file.
@@ -75,6 +75,55 @@
75
75
  "type": "array",
76
76
  "items": { "type": "string" },
77
77
  "description": "Categorization tags for filtering"
78
+ },
79
+ "status": {
80
+ "type": "string",
81
+ "enum": ["upheld", "challenged", "modified", "withdrawn", "rebutted"],
82
+ "description": "Set during Stage 2/3 deliberation. Absent for single-stage audits."
83
+ },
84
+ "annotations": {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "object",
88
+ "required": ["cabinet-member", "type", "text"],
89
+ "properties": {
90
+ "cabinet-member": {
91
+ "type": "string",
92
+ "description": "Stage-2 critic who made this annotation"
93
+ },
94
+ "type": {
95
+ "type": "string",
96
+ "enum": ["challenge", "support", "context", "correction"],
97
+ "description": "challenge = disagrees, support = confirms, context = adds info, correction = factual fix"
98
+ },
99
+ "text": {
100
+ "type": "string",
101
+ "description": "The annotation content"
102
+ },
103
+ "severity-suggestion": {
104
+ "type": "string",
105
+ "enum": ["critical", "warn", "info", "idea"],
106
+ "description": "Critic's suggested severity change, if any"
107
+ }
108
+ }
109
+ },
110
+ "description": "Stage-2 critic annotations from deliberative audit. Absent for single-stage audits."
111
+ },
112
+ "rebuttal": {
113
+ "type": "object",
114
+ "properties": {
115
+ "response": {
116
+ "type": "string",
117
+ "enum": ["withdraw", "modify", "defend"],
118
+ "description": "Stage-1 member's response to challenges"
119
+ },
120
+ "comment": {
121
+ "type": "string",
122
+ "description": "Explanation of the response"
123
+ }
124
+ },
125
+ "required": ["response", "comment"],
126
+ "description": "Stage-1 member's response to Stage-2 challenges (opt-in rebuttal mode). Absent when no rebuttal requested or finding was not challenged."
78
127
  }
79
128
  }
80
129
  }
@@ -90,23 +90,32 @@ for (const file of files) {
90
90
  const timestamp = new Date().toISOString();
91
91
  const runId = `run-${basename(runDir)}`;
92
92
 
93
- const summary = {
94
- findings: allFindings,
95
- meta: {
96
- runId,
97
- timestamp,
98
- trigger: 'manual',
99
- members: Object.keys(memberCounts),
100
- counts: {
101
- total: allFindings.length,
102
- findings: allFindings.length - positiveCount,
103
- positive: positiveCount,
104
- ...severityCounts,
105
- },
106
- byMember: memberCounts,
93
+ const meta = {
94
+ runId,
95
+ timestamp,
96
+ trigger: 'manual',
97
+ members: Object.keys(memberCounts),
98
+ counts: {
99
+ total: allFindings.length,
100
+ findings: allFindings.length - positiveCount,
101
+ positive: positiveCount,
102
+ ...severityCounts,
107
103
  },
104
+ byMember: memberCounts,
108
105
  };
109
106
 
107
+ if (allFindings.some(f => f.annotations && f.annotations.length > 0)) {
108
+ meta.deliberation = {
109
+ annotatedCount: allFindings.filter(f => f.annotations && f.annotations.length > 0).length,
110
+ challengedCount: allFindings.filter(f => f.status === 'challenged' || f.status === 'rebutted').length,
111
+ upheldCount: allFindings.filter(f => f.status === 'upheld').length,
112
+ withdrawnCount: allFindings.filter(f => f.status === 'withdrawn').length,
113
+ modifiedCount: allFindings.filter(f => f.status === 'modified').length,
114
+ };
115
+ }
116
+
117
+ const summary = { findings: allFindings, meta };
118
+
110
119
  const summaryPath = join(runDir, 'run-summary.json');
111
120
  writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
112
121
  console.log(`\nMerged ${allFindings.length} findings → ${summaryPath}`);
@@ -285,21 +285,44 @@ export function ingestFindings(db, { runDir }) {
285
285
  VALUES (?, ?, ?, ?, ?)
286
286
  `).run(runId, dateStr, timestamp, data.meta?.trigger || 'manual', data.findings?.length || 0);
287
287
 
288
- const insert = db.prepare(`
289
- INSERT OR REPLACE INTO audit_findings
290
- (id, run_id, cabinet_member, severity, title, description, assumption,
291
- evidence, question, file, line, suggested_fix, auto_fixable, type)
292
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
293
- `);
288
+ // Try the full INSERT with deliberation columns first; fall back to the
289
+ // legacy shape for existing databases that haven't added them yet.
290
+ let insert;
291
+ let hasDeliberationCols = true;
292
+ try {
293
+ insert = db.prepare(`
294
+ INSERT OR REPLACE INTO audit_findings
295
+ (id, run_id, cabinet_member, severity, title, description, assumption,
296
+ evidence, question, file, line, suggested_fix, auto_fixable, type,
297
+ status, annotations, rebuttal)
298
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
299
+ `);
300
+ } catch {
301
+ hasDeliberationCols = false;
302
+ insert = db.prepare(`
303
+ INSERT OR REPLACE INTO audit_findings
304
+ (id, run_id, cabinet_member, severity, title, description, assumption,
305
+ evidence, question, file, line, suggested_fix, auto_fixable, type)
306
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
307
+ `);
308
+ }
294
309
 
295
310
  let count = 0;
296
311
  for (const f of (data.findings || [])) {
297
- insert.run(
312
+ const base = [
298
313
  f.id, runId, f['cabinet-member'], f.severity, f.title,
299
314
  f.description || null, f.assumption || null, f.evidence || null,
300
315
  f.question || null, f.file || null, f.line || null,
301
316
  f.suggestedFix || null, f.autoFixable ? 1 : 0, f.type || 'finding'
302
- );
317
+ ];
318
+ if (hasDeliberationCols) {
319
+ base.push(
320
+ f.status || null,
321
+ f.annotations ? JSON.stringify(f.annotations) : null,
322
+ f.rebuttal ? JSON.stringify(f.rebuttal) : null
323
+ );
324
+ }
325
+ insert.run(...base);
303
326
  count++;
304
327
  }
305
328
  return { count, runId, message: `Ingested ${count} findings from ${runDir} (run: ${runId})` };
@@ -66,7 +66,11 @@ CREATE TABLE IF NOT EXISTS audit_findings (
66
66
  CHECK(triage_status IN ('open','approved','rejected','deferred','fixed','archived')),
67
67
  triage_notes TEXT,
68
68
  triaged_at TEXT,
69
- fix_description TEXT
69
+ fix_description TEXT,
70
+ -- Deliberation fields (two-stage audit). Absent for single-stage audits.
71
+ status TEXT CHECK(status IS NULL OR status IN ('upheld','challenged','modified','withdrawn','rebutted')),
72
+ annotations TEXT, -- JSON array of Stage-2 critic annotations
73
+ rebuttal TEXT -- JSON object: Stage-1 member's response to challenges
70
74
  );
71
75
 
72
76
  -- Append-only history of trigger-condition evaluations.
@@ -63,11 +63,17 @@
63
63
 
64
64
  /* Finding rows */
65
65
  .finding {
66
- padding: 10px 14px;
66
+ padding: 10px 14px 10px 18px;
67
67
  border-top: 1px solid #2c2e33;
68
+ border-left: 4px solid transparent;
68
69
  transition: background 0.1s;
69
70
  }
70
71
  .finding:hover { background: #25262b; }
72
+ .finding[data-status="challenged"] { border-left-color: #e03131; background: #1e1a1a; }
73
+ .finding[data-status="rebutted"] { border-left-color: #7048e8; background: #1a1820; }
74
+ .finding[data-status="upheld"] { border-left-color: #2b8a3e; }
75
+ .finding[data-status="modified"] { border-left-color: #f08c00; }
76
+ .finding[data-status="withdrawn"] { border-left-color: #868e96; }
71
77
  .finding-top {
72
78
  display: flex;
73
79
  align-items: flex-start;
@@ -93,11 +99,119 @@
93
99
  .finding-id { color: #5c5f66; font-size: 11px; font-family: monospace; flex-shrink: 0; }
94
100
 
95
101
  .commentary {
96
- color: #909296;
102
+ color: #c1c2c5;
103
+ font-size: 13px;
104
+ margin: 6px 0 8px 0;
105
+ padding: 0;
106
+ line-height: 1.6;
107
+ }
108
+
109
+ .finding-detail {
110
+ margin: 6px 0;
111
+ padding: 8px 10px;
112
+ background: #1a1b1e;
113
+ border-radius: 4px;
97
114
  font-size: 12px;
98
- margin: 4px 0 8px 0;
99
- padding-left: 2px;
100
115
  line-height: 1.6;
116
+ color: #c1c2c5;
117
+ }
118
+ .finding-detail p { margin: 0 0 6px 0; }
119
+ .finding-detail p:last-child { margin-bottom: 0; }
120
+ .detail-label {
121
+ color: #909296;
122
+ font-size: 11px;
123
+ font-weight: 600;
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.5px;
126
+ margin-bottom: 2px;
127
+ }
128
+ .detail-section { margin-bottom: 8px; }
129
+ .detail-section:last-child { margin-bottom: 0; }
130
+ .detail-fix {
131
+ border-left: 2px solid #2b8a3e;
132
+ padding-left: 8px;
133
+ margin-top: 4px;
134
+ }
135
+ .detail-question {
136
+ border-left: 2px solid #f08c00;
137
+ padding-left: 8px;
138
+ margin-top: 4px;
139
+ font-style: italic;
140
+ }
141
+ .annotation {
142
+ margin: 6px 0 6px 20px;
143
+ padding: 7px 12px;
144
+ border-left: 3px solid;
145
+ border-radius: 0 4px 4px 0;
146
+ background: #1e1f22;
147
+ font-size: 12px;
148
+ line-height: 1.5;
149
+ color: #c1c2c5;
150
+ }
151
+ .annotation.ann-type-challenge { border-left-color: #e03131; }
152
+ .annotation.ann-type-support { border-left-color: #2b8a3e; }
153
+ .annotation.ann-type-context { border-left-color: #1c7ed6; }
154
+ .annotation.ann-type-correction { border-left-color: #f08c00; }
155
+ .annotation-header {
156
+ display: flex;
157
+ gap: 6px;
158
+ align-items: center;
159
+ margin-bottom: 2px;
160
+ font-size: 11px;
161
+ }
162
+ .annotation-member { color: #909296; font-family: monospace; }
163
+ .ann-badge {
164
+ padding: 1px 5px;
165
+ border-radius: 3px;
166
+ font-size: 10px;
167
+ font-weight: 600;
168
+ text-transform: uppercase;
169
+ }
170
+ .ann-challenge { background: #e03131; color: #fff; }
171
+ .ann-support { background: #2b8a3e; color: #fff; }
172
+ .ann-context { background: #1c7ed6; color: #fff; }
173
+ .ann-correction { background: #f08c00; color: #fff; }
174
+ .finding-rebuttal {
175
+ margin: 6px 0 6px 40px;
176
+ padding: 7px 12px;
177
+ border-left: 3px solid #7048e8;
178
+ border-radius: 0 4px 4px 0;
179
+ background: #1e1f22;
180
+ font-size: 12px;
181
+ color: #c1c2c5;
182
+ }
183
+ .rebuttal-header { color: #7048e8; font-size: 11px; font-weight: 600; margin-bottom: 2px; }
184
+ .status-badge {
185
+ padding: 1px 5px;
186
+ border-radius: 3px;
187
+ font-size: 10px;
188
+ font-weight: 600;
189
+ margin-left: 6px;
190
+ }
191
+ .status-challenged { background: #e03131; color: #fff; }
192
+ .status-upheld { background: #2b8a3e; color: #fff; }
193
+ .status-modified { background: #f08c00; color: #fff; }
194
+ .status-withdrawn { background: #868e96; color: #fff; }
195
+ .status-rebutted { background: #7048e8; color: #fff; }
196
+ .annotation-section-label {
197
+ color: #5c5f66;
198
+ font-size: 11px;
199
+ font-weight: 600;
200
+ text-transform: uppercase;
201
+ letter-spacing: 0.5px;
202
+ margin: 8px 0 4px 16px;
203
+ }
204
+ .no-objections {
205
+ color: #5c5f66;
206
+ font-size: 11px;
207
+ font-style: italic;
208
+ margin: 6px 0 2px 16px;
209
+ padding: 4px 0;
210
+ }
211
+ .critique-zone {
212
+ margin-top: 10px;
213
+ padding-top: 10px;
214
+ border-top: 1px dashed #2c2e33;
101
215
  }
102
216
 
103
217
  .controls {
@@ -398,12 +512,15 @@
398
512
  group.className = 'member-group';
399
513
 
400
514
  // Header
515
+ const challenged = items.filter(f => f.status === 'challenged' || f.status === 'rebutted').length;
516
+
401
517
  const header = document.createElement('div');
402
518
  header.className = 'member-header';
403
519
  header.innerHTML = `
404
520
  <span class="member-name">${member} (${items.length})</span>
405
521
  <span class="member-stats">
406
522
  ${triaged}/${items.length} triaged
523
+ ${challenged ? ` · <span style="color:#e03131">${challenged} contested</span>` : ''}
407
524
  </span>
408
525
  `;
409
526
  header.addEventListener('click', () => {
@@ -418,15 +535,71 @@
418
535
  items.forEach(f => {
419
536
  const row = document.createElement('div');
420
537
  row.className = 'finding';
538
+ if (f.status) row.dataset.status = f.status;
421
539
  const v = verdicts[f.id]?.verdict || '';
422
540
 
541
+ // --- Finding detail section ---
542
+ const detailParts = [];
543
+ if (f.description) {
544
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Description</div><p>${esc(f.description)}</p></div>`);
545
+ }
546
+ if (f.evidence) {
547
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Evidence</div><p>${esc(f.evidence)}</p></div>`);
548
+ }
549
+ if (f.assumption) {
550
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Assumption</div><p>${esc(f.assumption)}</p></div>`);
551
+ }
552
+ if (f.question) {
553
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Open question</div><div class="detail-question">${esc(f.question)}</div></div>`);
554
+ }
555
+ if (f.suggestedFix) {
556
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Suggested fix</div><div class="detail-fix">${esc(f.suggestedFix)}</div></div>`);
557
+ }
558
+ if (f.file) {
559
+ detailParts.push(`<div class="detail-section"><div class="detail-label">Location</div><p>${esc(f.file)}${f.line ? ':' + f.line : ''}</p></div>`);
560
+ }
561
+ const detailHtml = detailParts.length > 0
562
+ ? `<div class="finding-detail">${detailParts.join('')}</div>`
563
+ : '';
564
+
565
+ // --- Annotations section ---
566
+ const anns = f.annotations || [];
567
+ let annHtml = '';
568
+ if (anns.length > 0) {
569
+ annHtml = `<div class="annotation-section-label">Stage-2 critique (${anns.length})</div>` +
570
+ anns.map(a => `
571
+ <div class="annotation ann-type-${a.type || 'context'}">
572
+ <div class="annotation-header">
573
+ <span class="annotation-member">${esc(a['cabinet-member'] || '')}</span>
574
+ <span class="ann-badge ann-${a.type || 'context'}">${esc(a.type || 'context')}</span>
575
+ ${a['severity-suggestion'] ? `<span class="badge badge-${a['severity-suggestion']}">→ ${esc(a['severity-suggestion'])}</span>` : ''}
576
+ </div>
577
+ ${esc(a.text || '')}
578
+ </div>`).join('');
579
+ } else if (f.status === 'upheld') {
580
+ const allAnns = findings.flatMap(ff => (ff.annotations || []).map(a => a['cabinet-member']));
581
+ const critics = [...new Set(allAnns)];
582
+ const criticList = critics.length > 0 ? critics.join(', ') : 'Stage-2 critics';
583
+ annHtml = `<div class="no-objections">Reviewed by ${esc(criticList)} — no objections</div>`;
584
+ }
585
+
586
+ const rebHtml = f.rebuttal ? `
587
+ <div class="annotation-section-label">Author response</div>
588
+ <div class="finding-rebuttal">
589
+ <div class="rebuttal-header">${esc(f['cabinet-member'])} — ${esc(f.rebuttal.response)}</div>
590
+ ${esc(f.rebuttal.comment || '')}
591
+ </div>` : '';
592
+ const statusHtml = f.status ? `<span class="status-badge status-${f.status}">${esc(f.status)}</span>` : '';
593
+
423
594
  row.innerHTML = `
424
595
  <div class="finding-top">
425
596
  <span class="badge badge-${f.severity}">${f.severity}</span>
426
- <span class="finding-title">${esc(f.title)}</span>
597
+ <span class="finding-title">${esc(f.title)}${statusHtml}</span>
427
598
  <span class="finding-id">${esc(f.id)}</span>
428
599
  </div>
429
600
  ${f.commentary ? `<div class="commentary">${esc(f.commentary)}</div>` : ''}
601
+ ${detailHtml}
602
+ <div class="critique-zone">${annHtml}${rebHtml}</div>
430
603
  <div class="controls">
431
604
  <button class="verdict-btn ${v === 'fix' ? 'sel-fix' : ''}" data-id="${f.id}" data-v="fix">Fix</button>
432
605
  <button class="verdict-btn ${v === 'defer' ? 'sel-defer' : ''}" data-id="${f.id}" data-v="defer">Defer</button>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-cabinet/site-audit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Comprehensive deployed-site quality audit engine for Claude Cabinet. Runs checks across performance, accessibility, security, SEO, content, DNS, and privacy against a deployed URL; single-site and comparison modes; standalone HTML report.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,6 @@
19
19
  "dependencies": {
20
20
  "@axe-core/cli": "^4.10.0",
21
21
  "@mdn/mdn-http-observatory": "^1.6.0",
22
- "@themarkup/blacklight-collector": "^1.0.0",
23
22
  "lighthouse": "^12.0.0",
24
23
  "linkinator": "^7.0.0",
25
24
  "pa11y": "^9.0.0",
@@ -18,7 +18,17 @@ export function normalize(raw, durationMs) {
18
18
  }
19
19
  let data;
20
20
  try { data = JSON.parse(raw.stdout); } catch {
21
- return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse axe-core JSON' };
21
+ // @axe-core/cli may output non-JSON prefix lines before the JSON array;
22
+ // strip lines until we find the opening bracket.
23
+ const lines = (raw.stdout || '').split('\n');
24
+ const jsonStart = lines.findIndex(l => l.trimStart().startsWith('[') || l.trimStart().startsWith('{'));
25
+ if (jsonStart >= 0) {
26
+ try { data = JSON.parse(lines.slice(jsonStart).join('\n')); } catch {
27
+ return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse axe-core JSON' };
28
+ }
29
+ } else {
30
+ return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse axe-core JSON' };
31
+ }
22
32
  }
23
33
 
24
34
  const violations = Array.isArray(data) ? data.flatMap(p => p.violations || []) : (data.violations || []);
@@ -34,8 +44,14 @@ export function normalize(raw, durationMs) {
34
44
  return (o[f.severity] ?? 3) < (o[w] ?? 3) ? f.severity : w;
35
45
  }, 'info') : null;
36
46
 
47
+ const pages = Array.isArray(data) ? data.length : 1;
48
+ const passSummary = findings.length === 0
49
+ ? `No WCAG AA violations across ${pages} page${pages !== 1 ? 's' : ''}`
50
+ : undefined;
51
+
37
52
  return {
38
53
  checkId, tool, status: findings.length === 0 ? 'pass' : 'fail',
39
54
  score: null, grade: null, severity: worstSev, findings, durationMs,
55
+ ...(passSummary && { passSummary }),
40
56
  };
41
57
  }
@@ -72,8 +72,15 @@ export function normalize(raw, durationMs) {
72
72
  }, 'info') : null;
73
73
 
74
74
  const hasSerious = findings.some(f => f.severity === 'serious' || f.severity === 'critical');
75
+ const passSummary = !hasSerious
76
+ ? (findings.length === 0
77
+ ? 'No trackers, fingerprinters, or session recorders detected'
78
+ : `No serious trackers detected (${findings.length} low-severity item${findings.length !== 1 ? 's' : ''} only)`)
79
+ : undefined;
80
+
75
81
  return {
76
82
  checkId, tool, status: hasSerious ? 'fail' : 'pass',
77
83
  score: null, grade: null, severity: worstSev, findings, durationMs,
84
+ ...(passSummary && { passSummary }),
78
85
  };
79
86
  }
@@ -53,13 +53,24 @@ export function normalize(raw, durationMs) {
53
53
  const passing = total - findings.filter(f => f.severity !== 'info').length;
54
54
  const score = Math.round((passing / total) * 100);
55
55
 
56
+ const isPass = !findings.some(f => f.severity !== 'info');
57
+ const passed = [];
58
+ if (!findings.some(f => f.message.includes('DNSSEC'))) passed.push('DNSSEC');
59
+ if (!findings.some(f => f.message.includes('SPF'))) passed.push('SPF');
60
+ if (!findings.some(f => f.message.includes('DMARC'))) passed.push('DMARC');
61
+ if (httpVersion && (httpVersion.startsWith('2') || httpVersion.startsWith('3'))) passed.push(`HTTP/${httpVersion}`);
62
+ const passSummary = isPass
63
+ ? `${passed.join(', ')} verified`
64
+ : undefined;
65
+
56
66
  return {
57
- checkId, tool, status: findings.some(f => f.severity !== 'info') ? 'fail' : 'pass',
67
+ checkId, tool, status: isPass ? 'pass' : 'fail',
58
68
  score, grade: null,
59
69
  severity: findings.length ? findings.reduce((w, f) => {
60
70
  const o = { critical: 0, serious: 1, moderate: 2, info: 3 };
61
71
  return (o[f.severity] ?? 3) < (o[w] ?? 3) ? f.severity : w;
62
72
  }, 'info') : null,
63
73
  findings, durationMs,
74
+ ...(passSummary && { passSummary }),
64
75
  };
65
76
  }
@@ -39,11 +39,17 @@ export function normalize(raw, durationMs) {
39
39
  const audits = data.audits || {};
40
40
  for (const [id, audit] of Object.entries(audits)) {
41
41
  if (audit.score !== null && audit.score < 0.5 && audit.title) {
42
- findings.push({
42
+ const f = {
43
43
  severity: audit.score === 0 ? 'serious' : 'moderate',
44
44
  message: audit.title,
45
45
  context: audit.displayValue || undefined,
46
- });
46
+ };
47
+ if (audit.details?.items?.length) {
48
+ f.url = audit.details.items.slice(0, 5).map(
49
+ item => item.url || item.source?.url || item.node?.selector || ''
50
+ ).filter(Boolean).join(', ') || undefined;
51
+ }
52
+ findings.push(f);
47
53
  }
48
54
  }
49
55
 
@@ -52,9 +58,15 @@ export function normalize(raw, durationMs) {
52
58
  return (order[f.severity] ?? 3) < (order[w] ?? 3) ? f.severity : w;
53
59
  }, 'info') : null;
54
60
 
61
+ const details = { categories: scores };
62
+ const passSummary = Object.entries(scores)
63
+ .map(([k, v]) => `${k.replace(/-/g, ' ')}: ${v}`)
64
+ .join(', ');
65
+
55
66
  return {
56
67
  checkId, tool, status: avg !== null && avg >= 50 ? 'pass' : 'fail',
57
- score: avg, grade: scoreToGrade(avg), severity: worstSev, findings, durationMs,
68
+ score: avg, grade: scoreToGrade(avg), severity: worstSev, findings,
69
+ details, passSummary, durationMs,
58
70
  };
59
71
  }
60
72