cipher-security 2.0.8 → 2.2.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 (70) hide show
  1. package/bin/cipher.js +11 -1
  2. package/lib/agent-runtime/handlers/architect.js +199 -0
  3. package/lib/agent-runtime/handlers/base.js +240 -0
  4. package/lib/agent-runtime/handlers/blue.js +220 -0
  5. package/lib/agent-runtime/handlers/incident.js +161 -0
  6. package/lib/agent-runtime/handlers/privacy.js +190 -0
  7. package/lib/agent-runtime/handlers/purple.js +209 -0
  8. package/lib/agent-runtime/handlers/recon.js +174 -0
  9. package/lib/agent-runtime/handlers/red.js +246 -0
  10. package/lib/agent-runtime/handlers/researcher.js +170 -0
  11. package/lib/agent-runtime/handlers.js +35 -0
  12. package/lib/agent-runtime/index.js +196 -0
  13. package/lib/agent-runtime/parser.js +316 -0
  14. package/lib/analyze/consistency.js +566 -0
  15. package/lib/analyze/constitution.js +110 -0
  16. package/lib/analyze/sharding.js +251 -0
  17. package/lib/autonomous/agent-tool.js +165 -0
  18. package/lib/autonomous/feedback-loop.js +13 -6
  19. package/lib/autonomous/framework.js +17 -0
  20. package/lib/autonomous/handoff.js +506 -0
  21. package/lib/autonomous/modes/blue.js +26 -0
  22. package/lib/autonomous/modes/red.js +585 -0
  23. package/lib/autonomous/modes/researcher.js +322 -0
  24. package/lib/autonomous/researcher.js +12 -45
  25. package/lib/autonomous/runner.js +9 -537
  26. package/lib/benchmark/agent.js +88 -26
  27. package/lib/benchmark/baselines.js +3 -0
  28. package/lib/benchmark/claude-code-solver.js +254 -0
  29. package/lib/benchmark/cognitive.js +283 -0
  30. package/lib/benchmark/index.js +12 -2
  31. package/lib/benchmark/knowledge.js +281 -0
  32. package/lib/benchmark/llm.js +156 -15
  33. package/lib/benchmark/models.js +5 -2
  34. package/lib/benchmark/nyu-ctf.js +192 -0
  35. package/lib/benchmark/overthewire.js +347 -0
  36. package/lib/benchmark/picoctf.js +281 -0
  37. package/lib/benchmark/prompts.js +280 -0
  38. package/lib/benchmark/registry.js +219 -0
  39. package/lib/benchmark/remote-solver.js +356 -0
  40. package/lib/benchmark/remote-target.js +263 -0
  41. package/lib/benchmark/reporter.js +35 -0
  42. package/lib/benchmark/runner.js +174 -10
  43. package/lib/benchmark/sandbox.js +35 -0
  44. package/lib/benchmark/scorer.js +22 -4
  45. package/lib/benchmark/solver.js +34 -1
  46. package/lib/benchmark/tools.js +262 -16
  47. package/lib/commands.js +9 -0
  48. package/lib/execution/council.js +434 -0
  49. package/lib/execution/parallel.js +292 -0
  50. package/lib/gates/circuit-breaker.js +135 -0
  51. package/lib/gates/confidence.js +302 -0
  52. package/lib/gates/corrections.js +219 -0
  53. package/lib/gates/self-check.js +245 -0
  54. package/lib/gateway/commands.js +727 -0
  55. package/lib/guardrails/engine.js +364 -0
  56. package/lib/mcp/server.js +349 -3
  57. package/lib/memory/compressor.js +94 -7
  58. package/lib/pipeline/hooks.js +288 -0
  59. package/lib/pipeline/index.js +11 -0
  60. package/lib/review/budget.js +210 -0
  61. package/lib/review/engine.js +526 -0
  62. package/lib/review/layers/acceptance-auditor.js +279 -0
  63. package/lib/review/layers/blind-hunter.js +500 -0
  64. package/lib/review/layers/defense-in-depth.js +209 -0
  65. package/lib/review/layers/edge-case-hunter.js +266 -0
  66. package/lib/review/panel.js +519 -0
  67. package/lib/review/two-stage.js +244 -0
  68. package/lib/session/cost-tracker.js +203 -0
  69. package/lib/session/logger.js +349 -0
  70. package/package.json +1 -1
@@ -0,0 +1,566 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * CIPHER Cross-Artifact Consistency Analyzer
7
+ *
8
+ * Scans the CIPHER artifact ecosystem (commands, agents, skills, knowledge,
9
+ * CLAUDE.md) for stale references, orphan artifacts, conflicting instructions,
10
+ * and coverage gaps.
11
+ *
12
+ * @module analyze/consistency
13
+ */
14
+
15
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
16
+ import { join, resolve, dirname, basename } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Finding types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** @enum {string} */
26
+ export const IssueSeverity = Object.freeze({
27
+ ERROR: 'error',
28
+ WARNING: 'warning',
29
+ INFO: 'info',
30
+ });
31
+
32
+ /** @enum {string} */
33
+ export const IssueCategory = Object.freeze({
34
+ STALE_REF: 'stale-reference',
35
+ ORPHAN: 'orphan-artifact',
36
+ MISSING: 'missing-artifact',
37
+ MODE_MISMATCH: 'mode-mismatch',
38
+ COVERAGE_GAP: 'coverage-gap',
39
+ DUPLICATE: 'duplicate',
40
+ STRUCTURE: 'structure-issue',
41
+ });
42
+
43
+ /**
44
+ * A single consistency issue.
45
+ */
46
+ export class ConsistencyIssue {
47
+ /**
48
+ * @param {object} opts
49
+ * @param {string} opts.category - Issue category
50
+ * @param {string} opts.severity - error|warning|info
51
+ * @param {string} opts.file - File where the issue was found
52
+ * @param {number} [opts.line] - Line number
53
+ * @param {string} opts.message - Human-readable description
54
+ * @param {string} [opts.reference] - The stale/missing reference
55
+ * @param {string} [opts.suggestion] - How to fix
56
+ */
57
+ constructor(opts = {}) {
58
+ this.category = opts.category ?? IssueCategory.STRUCTURE;
59
+ this.severity = opts.severity ?? IssueSeverity.WARNING;
60
+ this.file = opts.file ?? '';
61
+ this.line = opts.line ?? 0;
62
+ this.message = opts.message ?? '';
63
+ this.reference = opts.reference ?? '';
64
+ this.suggestion = opts.suggestion ?? '';
65
+ }
66
+
67
+ toReport() {
68
+ const loc = this.line ? `${this.file}:${this.line}` : this.file;
69
+ const sev = this.severity.toUpperCase();
70
+ const lines = [`[${sev}] ${this.category} — ${loc}`, ` ${this.message}`];
71
+ if (this.reference) lines.push(` Reference: ${this.reference}`);
72
+ if (this.suggestion) lines.push(` Fix: ${this.suggestion}`);
73
+ return lines.join('\n');
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Artifact Indexer
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Index all CIPHER artifacts from a repo root.
83
+ */
84
+ export class ArtifactIndex {
85
+ /**
86
+ * @param {string} repoRoot - Path to CIPHER repo root
87
+ */
88
+ constructor(repoRoot) {
89
+ this.root = repoRoot;
90
+ /** @type {Map<string, string>} command name → file path */
91
+ this.commands = new Map();
92
+ /** @type {Map<string, string>} agent name → file path */
93
+ this.agents = new Map();
94
+ /** @type {Set<string>} knowledge filenames (basename) */
95
+ this.knowledgeFiles = new Set();
96
+ /** @type {Map<string, {hasSkillMd: boolean, hasAgentJs: boolean, skillCount: number}>} */
97
+ this.skills = new Map();
98
+ /** @type {Set<string>} modes defined in CLAUDE.md */
99
+ this.modes = new Set();
100
+ }
101
+
102
+ /**
103
+ * Scan all artifact directories and populate the index.
104
+ */
105
+ index() {
106
+ this._indexModes();
107
+ this._indexCommands();
108
+ this._indexAgents();
109
+ this._indexKnowledge();
110
+ this._indexSkills();
111
+ }
112
+
113
+ _indexModes() {
114
+ const claudePath = join(this.root, 'CLAUDE.md');
115
+ if (!existsSync(claudePath)) return;
116
+ const content = readFileSync(claudePath, 'utf-8');
117
+ // Extract modes from ### `[MODE: X]` headers
118
+ const modeRe = /\[MODE:\s*([A-Z]+)\]/g;
119
+ let match;
120
+ while ((match = modeRe.exec(content)) !== null) {
121
+ this.modes.add(match[1]);
122
+ }
123
+ }
124
+
125
+ _indexCommands() {
126
+ const dir = join(this.root, 'commands');
127
+ if (!existsSync(dir)) return;
128
+ for (const file of readdirSync(dir)) {
129
+ if (!file.endsWith('.md')) continue;
130
+ const name = file.replace(/\.md$/, '');
131
+ this.commands.set(name, join(dir, file));
132
+ }
133
+ }
134
+
135
+ _indexAgents() {
136
+ const dir = join(this.root, 'agents');
137
+ if (!existsSync(dir)) return;
138
+ for (const file of readdirSync(dir)) {
139
+ if (!file.endsWith('.md')) continue;
140
+ const name = file.replace(/\.md$/, '');
141
+ this.agents.set(name, join(dir, file));
142
+ }
143
+ }
144
+
145
+ _indexKnowledge() {
146
+ const dir = join(this.root, 'knowledge');
147
+ if (!existsSync(dir)) return;
148
+ for (const file of readdirSync(dir)) {
149
+ if (!file.endsWith('.md')) continue;
150
+ this.knowledgeFiles.add(file);
151
+ }
152
+ }
153
+
154
+ _indexSkills() {
155
+ const dir = join(this.root, 'skills');
156
+ if (!existsSync(dir)) return;
157
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
158
+ if (!entry.isDirectory()) continue;
159
+ const skillDir = join(dir, entry.name);
160
+ const hasSkillMd = existsSync(join(skillDir, 'SKILL.md'));
161
+ const techniquesDir = join(skillDir, 'techniques');
162
+ let hasAgentJs = false;
163
+ let skillCount = 0;
164
+ if (existsSync(techniquesDir)) {
165
+ for (const sub of readdirSync(techniquesDir, { withFileTypes: true })) {
166
+ if (sub.isDirectory()) {
167
+ skillCount++;
168
+ const scriptsDir = join(techniquesDir, sub.name, 'scripts');
169
+ if (existsSync(join(scriptsDir, 'agent.js')) || existsSync(join(techniquesDir, sub.name, 'agent.js'))) {
170
+ hasAgentJs = true;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ this.skills.set(entry.name, { hasSkillMd, hasAgentJs, skillCount });
176
+ }
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Consistency Checks
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Check for stale knowledge references in commands.
186
+ * Commands reference knowledge as `knowledge/filename.md`.
187
+ *
188
+ * @param {ArtifactIndex} index
189
+ * @returns {ConsistencyIssue[]}
190
+ */
191
+ function checkCommandKnowledgeRefs(index) {
192
+ const issues = [];
193
+ for (const [name, filePath] of index.commands) {
194
+ const content = readFileSync(filePath, 'utf-8');
195
+ const lines = content.split('\n');
196
+ for (let i = 0; i < lines.length; i++) {
197
+ const knowledgeRe = /knowledge\/([a-zA-Z0-9_-]+\.md)/g;
198
+ let match;
199
+ while ((match = knowledgeRe.exec(lines[i])) !== null) {
200
+ const ref = match[1];
201
+ if (!index.knowledgeFiles.has(ref)) {
202
+ issues.push(new ConsistencyIssue({
203
+ category: IssueCategory.STALE_REF,
204
+ severity: IssueSeverity.ERROR,
205
+ file: `commands/${name}.md`,
206
+ line: i + 1,
207
+ message: `References knowledge file that does not exist.`,
208
+ reference: `knowledge/${ref}`,
209
+ suggestion: `Remove the reference or create knowledge/${ref}.`,
210
+ }));
211
+ }
212
+ }
213
+ }
214
+ }
215
+ return issues;
216
+ }
217
+
218
+ /**
219
+ * Check for stale knowledge references in agents.
220
+ * Agents reference knowledge as bare filenames in a YAML list.
221
+ *
222
+ * @param {ArtifactIndex} index
223
+ * @returns {ConsistencyIssue[]}
224
+ */
225
+ function checkAgentKnowledgeRefs(index) {
226
+ const issues = [];
227
+ for (const [name, filePath] of index.agents) {
228
+ const content = readFileSync(filePath, 'utf-8');
229
+ const lines = content.split('\n');
230
+ let inKnowledge = false;
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const line = lines[i];
233
+ if (/^knowledge:/.test(line)) {
234
+ inKnowledge = true;
235
+ continue;
236
+ }
237
+ if (inKnowledge) {
238
+ const itemMatch = line.match(/^\s+-\s+(.+\.md)\s*$/);
239
+ if (itemMatch) {
240
+ const ref = itemMatch[1].trim();
241
+ if (!index.knowledgeFiles.has(ref)) {
242
+ issues.push(new ConsistencyIssue({
243
+ category: IssueCategory.STALE_REF,
244
+ severity: IssueSeverity.ERROR,
245
+ file: `agents/${name}.md`,
246
+ line: i + 1,
247
+ message: `References knowledge file that does not exist.`,
248
+ reference: ref,
249
+ suggestion: `Remove from knowledge list or create knowledge/${ref}.`,
250
+ }));
251
+ }
252
+ } else if (!/^\s+-/.test(line) && line.trim() !== '') {
253
+ inKnowledge = false; // End of YAML list
254
+ }
255
+ }
256
+ }
257
+ }
258
+ return issues;
259
+ }
260
+
261
+ /**
262
+ * Check for orphan knowledge files — not referenced by any command or agent.
263
+ *
264
+ * @param {ArtifactIndex} index
265
+ * @returns {ConsistencyIssue[]}
266
+ */
267
+ function checkOrphanKnowledge(index) {
268
+ const issues = [];
269
+ // Collect all referenced knowledge files
270
+ const referenced = new Set();
271
+
272
+ for (const [, filePath] of index.commands) {
273
+ const content = readFileSync(filePath, 'utf-8');
274
+ const re = /knowledge\/([a-zA-Z0-9_-]+\.md)/g;
275
+ let match;
276
+ while ((match = re.exec(content)) !== null) {
277
+ referenced.add(match[1]);
278
+ }
279
+ }
280
+
281
+ for (const [, filePath] of index.agents) {
282
+ const content = readFileSync(filePath, 'utf-8');
283
+ const lines = content.split('\n');
284
+ let inKnowledge = false;
285
+ for (const line of lines) {
286
+ if (/^knowledge:/.test(line)) { inKnowledge = true; continue; }
287
+ if (inKnowledge) {
288
+ const itemMatch = line.match(/^\s+-\s+(.+\.md)\s*$/);
289
+ if (itemMatch) referenced.add(itemMatch[1].trim());
290
+ else if (!/^\s+-/.test(line) && line.trim() !== '') inKnowledge = false;
291
+ }
292
+ }
293
+ }
294
+
295
+ // Also check if knowledge files reference each other
296
+ for (const kf of index.knowledgeFiles) {
297
+ if (kf === '00-MASTER-INDEX.md') continue; // Index file is expected to be standalone
298
+ if (!referenced.has(kf)) {
299
+ issues.push(new ConsistencyIssue({
300
+ category: IssueCategory.ORPHAN,
301
+ severity: IssueSeverity.INFO,
302
+ file: `knowledge/${kf}`,
303
+ message: `Knowledge file not referenced by any command or agent.`,
304
+ suggestion: `Add reference in a relevant command or agent, or remove if obsolete.`,
305
+ }));
306
+ }
307
+ }
308
+ return issues;
309
+ }
310
+
311
+ /**
312
+ * Check for mode mismatches — agents/commands claiming modes not in CLAUDE.md.
313
+ *
314
+ * @param {ArtifactIndex} index
315
+ * @returns {ConsistencyIssue[]}
316
+ */
317
+ function checkModeMismatches(index) {
318
+ const issues = [];
319
+ if (index.modes.size === 0) return issues;
320
+
321
+ // Check agents
322
+ for (const [name, filePath] of index.agents) {
323
+ const content = readFileSync(filePath, 'utf-8');
324
+ const modeMatch = content.match(/^mode:\s*([A-Z]+)\s*$/m);
325
+ if (modeMatch) {
326
+ const mode = modeMatch[1];
327
+ if (!index.modes.has(mode)) {
328
+ issues.push(new ConsistencyIssue({
329
+ category: IssueCategory.MODE_MISMATCH,
330
+ severity: IssueSeverity.WARNING,
331
+ file: `agents/${name}.md`,
332
+ message: `Agent declares mode "${mode}" which is not defined in CLAUDE.md.`,
333
+ suggestion: `Add [MODE: ${mode}] to CLAUDE.md or fix the agent mode.`,
334
+ }));
335
+ }
336
+ }
337
+ }
338
+
339
+ // Check commands for MODE references
340
+ for (const [name, filePath] of index.commands) {
341
+ const content = readFileSync(filePath, 'utf-8');
342
+ const modeRe = /\[MODE:\s*([A-Z]+)\]/g;
343
+ let match;
344
+ while ((match = modeRe.exec(content)) !== null) {
345
+ if (!index.modes.has(match[1])) {
346
+ issues.push(new ConsistencyIssue({
347
+ category: IssueCategory.MODE_MISMATCH,
348
+ severity: IssueSeverity.WARNING,
349
+ file: `commands/${name}.md`,
350
+ message: `Command references mode "${match[1]}" not defined in CLAUDE.md.`,
351
+ suggestion: `Fix the mode reference or add the mode to CLAUDE.md.`,
352
+ }));
353
+ }
354
+ }
355
+ }
356
+ return issues;
357
+ }
358
+
359
+ /**
360
+ * Check for structural issues in skills.
361
+ *
362
+ * @param {ArtifactIndex} index
363
+ * @returns {ConsistencyIssue[]}
364
+ */
365
+ function checkSkillStructure(index) {
366
+ const issues = [];
367
+ for (const [name, info] of index.skills) {
368
+ if (!info.hasSkillMd) {
369
+ issues.push(new ConsistencyIssue({
370
+ category: IssueCategory.MISSING,
371
+ severity: IssueSeverity.WARNING,
372
+ file: `skills/${name}/`,
373
+ message: `Skill directory missing SKILL.md.`,
374
+ suggestion: `Add SKILL.md with name, description, domain, tags.`,
375
+ }));
376
+ }
377
+ if (info.skillCount > 0 && !info.hasAgentJs) {
378
+ issues.push(new ConsistencyIssue({
379
+ category: IssueCategory.MISSING,
380
+ severity: IssueSeverity.INFO,
381
+ file: `skills/${name}/`,
382
+ message: `Skill has ${info.skillCount} techniques but no agent.js found in any.`,
383
+ suggestion: `Add agent.js to technique subdirectories for autonomous execution.`,
384
+ }));
385
+ }
386
+ }
387
+ return issues;
388
+ }
389
+
390
+ /**
391
+ * Check for duplicate skill names (same directory name pattern).
392
+ *
393
+ * @param {ArtifactIndex} index
394
+ * @returns {ConsistencyIssue[]}
395
+ */
396
+ function checkDuplicateSkills(index) {
397
+ // Look for cipher-X skills that overlap with X skills
398
+ const issues = [];
399
+ const names = [...index.skills.keys()];
400
+ const cipherPrefixed = names.filter((n) => n.startsWith('cipher-'));
401
+ for (const cp of cipherPrefixed) {
402
+ const base = cp.replace(/^cipher-/, '');
403
+ // Check if there's a non-prefixed version or similar
404
+ const similar = names.filter(
405
+ (n) => n !== cp && (n === base || n.endsWith(`-${base}`) || n.startsWith(`${base}-`)),
406
+ );
407
+ if (similar.length > 0) {
408
+ issues.push(new ConsistencyIssue({
409
+ category: IssueCategory.DUPLICATE,
410
+ severity: IssueSeverity.INFO,
411
+ file: `skills/${cp}/`,
412
+ message: `Possible overlap with: ${similar.join(', ')}.`,
413
+ suggestion: `Verify these are distinct skills, not duplicates.`,
414
+ }));
415
+ }
416
+ }
417
+ return issues;
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // ConsistencyAnalyzer — main orchestrator
422
+ // ---------------------------------------------------------------------------
423
+
424
+ /**
425
+ * Run all consistency checks and return unified results.
426
+ */
427
+ export class ConsistencyAnalyzer {
428
+ /**
429
+ * @param {string} [repoRoot] - Path to CIPHER repo root (auto-detected if omitted)
430
+ */
431
+ constructor(repoRoot) {
432
+ this.root = repoRoot ?? this._findRoot();
433
+ this.index = new ArtifactIndex(this.root);
434
+ }
435
+
436
+ _findRoot() {
437
+ let dir = resolve(__dirname, '..', '..');
438
+ for (let i = 0; i < 10; i++) {
439
+ if (existsSync(join(dir, 'skills')) && existsSync(join(dir, 'CLAUDE.md'))) {
440
+ return dir;
441
+ }
442
+ const parent = dirname(dir);
443
+ if (parent === dir) break;
444
+ dir = parent;
445
+ }
446
+ return process.cwd();
447
+ }
448
+
449
+ /**
450
+ * Run all checks.
451
+ * @returns {ConsistencyResult}
452
+ */
453
+ analyze() {
454
+ const t0 = Date.now();
455
+ this.index.index();
456
+
457
+ const issues = [
458
+ ...checkCommandKnowledgeRefs(this.index),
459
+ ...checkAgentKnowledgeRefs(this.index),
460
+ ...checkOrphanKnowledge(this.index),
461
+ ...checkModeMismatches(this.index),
462
+ ...checkSkillStructure(this.index),
463
+ ...checkDuplicateSkills(this.index),
464
+ ];
465
+
466
+ // Sort: errors first, then warnings, then info
467
+ const sevRank = { error: 2, warning: 1, info: 0 };
468
+ issues.sort((a, b) => (sevRank[b.severity] ?? 0) - (sevRank[a.severity] ?? 0));
469
+
470
+ return new ConsistencyResult({
471
+ issues,
472
+ stats: {
473
+ commands: this.index.commands.size,
474
+ agents: this.index.agents.size,
475
+ knowledgeFiles: this.index.knowledgeFiles.size,
476
+ skills: this.index.skills.size,
477
+ modes: this.index.modes.size,
478
+ },
479
+ totalTime: Date.now() - t0,
480
+ });
481
+ }
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // ConsistencyResult
486
+ // ---------------------------------------------------------------------------
487
+
488
+ export class ConsistencyResult {
489
+ constructor({ issues = [], stats = {}, totalTime = 0 } = {}) {
490
+ this.issues = issues;
491
+ this.stats = stats;
492
+ this.totalTime = totalTime;
493
+ }
494
+
495
+ get counts() {
496
+ const c = { error: 0, warning: 0, info: 0 };
497
+ for (const issue of this.issues) {
498
+ c[issue.severity] = (c[issue.severity] ?? 0) + 1;
499
+ }
500
+ return c;
501
+ }
502
+
503
+ get summary() {
504
+ const c = this.counts;
505
+ const parts = [];
506
+ if (c.error) parts.push(`${c.error} error${c.error !== 1 ? 's' : ''}`);
507
+ if (c.warning) parts.push(`${c.warning} warning${c.warning !== 1 ? 's' : ''}`);
508
+ if (c.info) parts.push(`${c.info} info`);
509
+ const total = this.issues.length;
510
+ const detail = parts.length ? ` (${parts.join(', ')})` : '';
511
+ return `${total} issue${total !== 1 ? 's' : ''}${detail} — scanned ${this.stats.commands} commands, ${this.stats.agents} agents, ${this.stats.knowledgeFiles} knowledge docs, ${this.stats.skills} skill domains in ${this.totalTime}ms`;
512
+ }
513
+
514
+ toReport() {
515
+ const lines = [
516
+ '═══════════════════════════════════════════════════════',
517
+ ' CIPHER Artifact Consistency Report',
518
+ '═══════════════════════════════════════════════════════',
519
+ '',
520
+ `Summary: ${this.summary}`,
521
+ '',
522
+ ];
523
+
524
+ if (this.issues.length === 0) {
525
+ lines.push('No issues found. All artifacts are consistent.');
526
+ } else {
527
+ // Group by category
528
+ const grouped = new Map();
529
+ for (const issue of this.issues) {
530
+ if (!grouped.has(issue.category)) grouped.set(issue.category, []);
531
+ grouped.get(issue.category).push(issue);
532
+ }
533
+
534
+ for (const [category, catIssues] of grouped) {
535
+ lines.push(`── ${category} (${catIssues.length}) ──`);
536
+ for (const issue of catIssues) {
537
+ lines.push(issue.toReport());
538
+ }
539
+ lines.push('');
540
+ }
541
+ }
542
+
543
+ lines.push('───────────────────────────────────────────────────────');
544
+ lines.push(`Artifact counts: ${this.stats.commands} commands, ${this.stats.agents} agents, ${this.stats.knowledgeFiles} knowledge, ${this.stats.skills} skill domains, ${this.stats.modes} modes`);
545
+
546
+ return lines.join('\n');
547
+ }
548
+
549
+ toJSON() {
550
+ return {
551
+ summary: this.summary,
552
+ counts: this.counts,
553
+ stats: this.stats,
554
+ totalTime: this.totalTime,
555
+ issues: this.issues.map((i) => ({
556
+ category: i.category,
557
+ severity: i.severity,
558
+ file: i.file,
559
+ line: i.line,
560
+ message: i.message,
561
+ reference: i.reference,
562
+ suggestion: i.suggestion,
563
+ })),
564
+ };
565
+ }
566
+ }
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * CIPHER Constitution Versioning
7
+ *
8
+ * Tracks CLAUDE.md changes via content hash. Provides version info
9
+ * for audit trails and consistency checking.
10
+ *
11
+ * @module analyze/constitution
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { createHash } from 'node:crypto';
16
+ import { join, resolve, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Constitution version info.
23
+ */
24
+ export class ConstitutionVersion {
25
+ /**
26
+ * @param {object} opts
27
+ * @param {string} opts.hash - SHA-256 hash of CLAUDE.md content
28
+ * @param {string} opts.shortHash - First 8 chars of hash
29
+ * @param {number} opts.modes - Number of modes defined
30
+ * @param {string[]} opts.modeNames - Mode names
31
+ * @param {number} opts.lines - Total line count
32
+ * @param {number} opts.size - File size in bytes
33
+ * @param {string} opts.path - File path
34
+ */
35
+ constructor(opts = {}) {
36
+ this.hash = opts.hash ?? '';
37
+ this.shortHash = opts.shortHash ?? '';
38
+ this.modes = opts.modes ?? 0;
39
+ this.modeNames = opts.modeNames ?? [];
40
+ this.lines = opts.lines ?? 0;
41
+ this.size = opts.size ?? 0;
42
+ this.path = opts.path ?? '';
43
+ }
44
+
45
+ toReport() {
46
+ return [
47
+ `Constitution: ${this.path}`,
48
+ `Version hash: ${this.shortHash}`,
49
+ `Modes: ${this.modeNames.join(', ')} (${this.modes})`,
50
+ `Size: ${this.lines} lines, ${this.size} bytes`,
51
+ ].join('\n');
52
+ }
53
+
54
+ toJSON() {
55
+ return {
56
+ hash: this.hash,
57
+ shortHash: this.shortHash,
58
+ modes: this.modes,
59
+ modeNames: this.modeNames,
60
+ lines: this.lines,
61
+ size: this.size,
62
+ path: this.path,
63
+ };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get constitution version info from CLAUDE.md.
69
+ *
70
+ * @param {string} [repoRoot] - Path to repo root (auto-detected if omitted)
71
+ * @returns {ConstitutionVersion|null}
72
+ */
73
+ export function getConstitutionVersion(repoRoot) {
74
+ const root = repoRoot ?? findRoot();
75
+ const claudePath = join(root, 'CLAUDE.md');
76
+
77
+ if (!existsSync(claudePath)) return null;
78
+
79
+ const content = readFileSync(claudePath, 'utf-8');
80
+ const hash = createHash('sha256').update(content).digest('hex');
81
+
82
+ // Extract modes
83
+ const modeNames = [];
84
+ const modeRe = /\[MODE:\s*([A-Z]+)\]/g;
85
+ let match;
86
+ while ((match = modeRe.exec(content)) !== null) {
87
+ if (!modeNames.includes(match[1])) modeNames.push(match[1]);
88
+ }
89
+
90
+ return new ConstitutionVersion({
91
+ hash,
92
+ shortHash: hash.slice(0, 8),
93
+ modes: modeNames.length,
94
+ modeNames,
95
+ lines: content.split('\n').length,
96
+ size: Buffer.byteLength(content),
97
+ path: claudePath,
98
+ });
99
+ }
100
+
101
+ function findRoot() {
102
+ let dir = resolve(__dirname, '..', '..');
103
+ for (let i = 0; i < 10; i++) {
104
+ if (existsSync(join(dir, 'CLAUDE.md'))) return dir;
105
+ const parent = dirname(dir);
106
+ if (parent === dir) break;
107
+ dir = parent;
108
+ }
109
+ return process.cwd();
110
+ }