@dogfood-lab/findings 1.2.1

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/cli.js ADDED
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * dogfood findings CLI
5
+ *
6
+ * Commands:
7
+ * list List all findings (supports --repo, --status, --surface, --issue-kind, --transfer-scope)
8
+ * show <id> Show a single finding by finding_id
9
+ * validate Validate all findings (or a specific file with --file)
10
+ * derive Derive candidate findings from records (--dry-run or --write)
11
+ * explain Explain how a derived finding was produced
12
+ */
13
+
14
+ import { resolve, dirname, relative } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { existsSync } from 'node:fs';
17
+ import {
18
+ validateFindingFile,
19
+ validateFinding,
20
+ discoverFindings,
21
+ discoverFixtures,
22
+ loadFindings,
23
+ findById,
24
+ filterFindings,
25
+ findDuplicates
26
+ } from './index.js';
27
+ import {
28
+ deriveFromRecord,
29
+ deriveFromRecords,
30
+ getRuleInventory,
31
+ getRuleById,
32
+ dedupeAgainstExisting,
33
+ loadRecordById,
34
+ loadRecordsForRepo,
35
+ loadAllRecords,
36
+ writeFindings
37
+ } from './derive/index.js';
38
+ import {
39
+ performAction,
40
+ performMerge,
41
+ getReviewQueue,
42
+ getEventsForFinding
43
+ } from './review/index.js';
44
+ import {
45
+ derivePatterns,
46
+ deriveRecommendations,
47
+ deriveDoctrine,
48
+ validatePattern,
49
+ validateRecommendation,
50
+ validateDoctrine,
51
+ writePattern,
52
+ writeRecommendation,
53
+ writeDoctrine,
54
+ loadPatterns,
55
+ loadRecommendations,
56
+ loadDoctrines
57
+ } from './synthesis/index.js';
58
+ import {
59
+ generateAdviceBundle,
60
+ generateSyncExport
61
+ } from './advise/index.js';
62
+
63
+ const __dirname = dirname(fileURLToPath(import.meta.url));
64
+ const ROOT = resolve(__dirname, '../..');
65
+
66
+ function parseArgs(argv) {
67
+ const args = argv.slice(2);
68
+ const command = args[0];
69
+ const positional = [];
70
+ const flags = {};
71
+
72
+ for (let i = 1; i < args.length; i++) {
73
+ const arg = args[i];
74
+ if (arg.startsWith('--')) {
75
+ const eqIdx = arg.indexOf('=');
76
+ if (eqIdx !== -1) {
77
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
78
+ } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
79
+ flags[arg.slice(2)] = args[++i];
80
+ } else {
81
+ flags[arg.slice(2)] = true;
82
+ }
83
+ } else {
84
+ positional.push(arg);
85
+ }
86
+ }
87
+
88
+ return { command, positional, flags };
89
+ }
90
+
91
+ function formatFindingSummary(f, rootDir) {
92
+ const rel = relative(rootDir, f.path);
93
+ const d = f.data;
94
+ const valid = f.valid ? 'valid' : 'INVALID';
95
+ return `[${d.status}] ${d.finding_id} (${d.product_surface}, ${d.issue_kind}, ${d.transfer_scope}) [${valid}]\n ${d.title}\n ${rel}`;
96
+ }
97
+
98
+ function formatFindingDetail(f, rootDir) {
99
+ const d = f.data;
100
+ const lines = [
101
+ `Finding: ${d.finding_id}`,
102
+ `Title: ${d.title}`,
103
+ `Status: ${d.status}`,
104
+ `Repo: ${d.repo}`,
105
+ `Surface: ${d.product_surface}`,
106
+ d.execution_mode ? `Mode: ${d.execution_mode}` : null,
107
+ `Stage: ${d.journey_stage}`,
108
+ ``,
109
+ `Issue: ${d.issue_kind}`,
110
+ `Root cause: ${d.root_cause_kind}`,
111
+ `Remediation: ${d.remediation_kind}`,
112
+ `Scope: ${d.transfer_scope}`,
113
+ ``,
114
+ `Summary:`,
115
+ ` ${d.summary.trim()}`,
116
+ ];
117
+
118
+ if (d.doctrine_statement) {
119
+ lines.push('', 'Doctrine:', ` ${d.doctrine_statement.trim()}`);
120
+ }
121
+
122
+ if (d.notes) {
123
+ lines.push('', 'Notes:', ` ${d.notes.trim()}`);
124
+ }
125
+
126
+ lines.push('', `Source records: ${d.source_record_ids.join(', ')}`);
127
+
128
+ if (d.scenario_ids && d.scenario_ids.length) {
129
+ lines.push(`Scenarios: ${d.scenario_ids.join(', ')}`);
130
+ }
131
+
132
+ lines.push('', `Evidence (${d.evidence.length}):`);
133
+ for (const e of d.evidence) {
134
+ const parts = [` - ${e.evidence_kind}`];
135
+ if (e.record_id) parts.push(`record=${e.record_id}`);
136
+ if (e.scenario_id) parts.push(`scenario=${e.scenario_id}`);
137
+ if (e.doc_ref) parts.push(`doc=${e.doc_ref}`);
138
+ if (e.policy_ref) parts.push(`policy=${e.policy_ref}`);
139
+ if (e.artifact_ref) parts.push(`artifact=${e.artifact_ref}`);
140
+ if (e.note) parts.push(`| ${e.note}`);
141
+ lines.push(parts.join(' '));
142
+ }
143
+
144
+ if (d.fix_refs && d.fix_refs.length) {
145
+ lines.push('', `Fix refs (${d.fix_refs.length}):`);
146
+ for (const r of d.fix_refs) {
147
+ const parts = [` - ${r.ref_kind}: ${r.ref}`];
148
+ if (r.note) parts.push(`| ${r.note}`);
149
+ lines.push(parts.join(' '));
150
+ }
151
+ }
152
+
153
+ lines.push('', `Valid: ${f.valid ? 'yes' : 'NO'}`);
154
+ if (!f.valid && f.errors.length) {
155
+ for (const err of f.errors) {
156
+ lines.push(` ERROR: ${err.path} — ${err.message}`);
157
+ }
158
+ }
159
+
160
+ lines.push(`File: ${relative(rootDir, f.path)}`);
161
+
162
+ return lines.filter(l => l !== null).join('\n');
163
+ }
164
+
165
+ async function main() {
166
+ const { command, positional, flags } = parseArgs(process.argv);
167
+
168
+ if (!command || command === 'help' || command === '--help') {
169
+ console.log(`dogfood findings — finding contract spine + derivation engine
170
+
171
+ Commands:
172
+ list List all findings
173
+ show <finding_id> Show a single finding in detail
174
+ validate Validate all findings (or --file <path> for one)
175
+ validate --all Validate all findings + all fixtures
176
+ derive Derive candidate findings from records
177
+ explain <finding_id> Show derivation provenance for a finding
178
+ rules List all derivation rules
179
+ accept <id> Accept a finding (--actor, --reason)
180
+ reject <id> Reject a finding (--actor, --reason, --reject-reason)
181
+ review <id> Move finding to reviewed (--actor)
182
+ edit <id> Edit finding fields (--actor, --set field=value)
183
+ merge <ids...> Merge findings (--into <id>, --actor, --reason)
184
+ reopen <id> Reopen a rejected/accepted finding (--actor, --reason)
185
+ invalidate <id> Invalidate an accepted finding (--actor, --reason)
186
+ history <id> Show review history for a finding
187
+ queue Show review queue
188
+
189
+ Derive options:
190
+ --record <run_id> Derive from a specific record
191
+ --repo <org/repo> Derive from all records for a repo
192
+ --all Derive from all records
193
+ --dry-run Show what would be emitted (default)
194
+ --write Write candidates to disk
195
+
196
+ Filters (for list):
197
+ --repo <org/repo>
198
+ --status <candidate|reviewed|accepted|rejected>
199
+ --surface <cli|desktop|web|api|mcp-server|npm-package|plugin|library>
200
+ --issue-kind <kind>
201
+ --transfer-scope <scope>
202
+ --include-fixtures Also list fixture findings`);
203
+ process.exit(0);
204
+ }
205
+
206
+ if (command === 'list') {
207
+ const includeFixtures = flags['include-fixtures'];
208
+ const allFindings = loadFindings(ROOT);
209
+
210
+ if (includeFixtures) {
211
+ allFindings.push(...loadFindings(ROOT, { fixtures: true, fixtureKind: 'valid' }));
212
+ }
213
+
214
+ const filters = {};
215
+ if (flags.repo) filters.repo = flags.repo;
216
+ if (flags.status) filters.status = flags.status;
217
+ if (flags.surface) filters.surface = flags.surface;
218
+ if (flags['issue-kind']) filters.issueKind = flags['issue-kind'];
219
+ if (flags['transfer-scope']) filters.transferScope = flags['transfer-scope'];
220
+
221
+ const filtered = filterFindings(allFindings, filters);
222
+
223
+ if (filtered.length === 0) {
224
+ console.log('No findings found.');
225
+ process.exit(0);
226
+ }
227
+
228
+ for (const f of filtered) {
229
+ console.log(formatFindingSummary(f, ROOT));
230
+ console.log();
231
+ }
232
+ console.log(`${filtered.length} finding(s)`);
233
+ process.exit(0);
234
+ }
235
+
236
+ if (command === 'show') {
237
+ const findingId = positional[0];
238
+ if (!findingId) {
239
+ console.error('Usage: dogfood findings show <finding_id>');
240
+ process.exit(2);
241
+ }
242
+
243
+ const result = findById(ROOT, findingId);
244
+ if (!result) {
245
+ console.error(`Finding not found: ${findingId}`);
246
+ process.exit(1);
247
+ }
248
+
249
+ console.log(formatFindingDetail(result, ROOT));
250
+ process.exit(0);
251
+ }
252
+
253
+ if (command === 'validate') {
254
+ const singleFile = flags.file;
255
+ const all = flags.all;
256
+
257
+ if (singleFile) {
258
+ const filePath = resolve(singleFile);
259
+ if (!existsSync(filePath)) {
260
+ console.error(`File not found: ${filePath}`);
261
+ process.exit(2);
262
+ }
263
+ const result = validateFindingFile(filePath);
264
+ if (result.valid) {
265
+ console.log(`PASS: ${relative(ROOT, filePath)}`);
266
+ process.exit(0);
267
+ } else {
268
+ console.error(`FAIL: ${relative(ROOT, filePath)}`);
269
+ for (const err of result.errors) {
270
+ console.error(` ${err.path} — ${err.message}`);
271
+ }
272
+ process.exit(1);
273
+ }
274
+ }
275
+
276
+ // Validate all findings + optionally fixtures
277
+ let failed = 0;
278
+ let passed = 0;
279
+
280
+ const realFindings = loadFindings(ROOT);
281
+ for (const f of realFindings) {
282
+ if (f.valid) {
283
+ console.log(`PASS: ${relative(ROOT, f.path)}`);
284
+ passed++;
285
+ } else {
286
+ console.error(`FAIL: ${relative(ROOT, f.path)}`);
287
+ for (const err of f.errors) {
288
+ console.error(` ${err.path} — ${err.message}`);
289
+ }
290
+ failed++;
291
+ }
292
+ }
293
+
294
+ if (all) {
295
+ // Valid fixtures should all pass
296
+ const validFixtures = loadFindings(ROOT, { fixtures: true, fixtureKind: 'valid' });
297
+ for (const f of validFixtures) {
298
+ if (f.valid) {
299
+ console.log(`PASS (fixture): ${relative(ROOT, f.path)}`);
300
+ passed++;
301
+ } else {
302
+ console.error(`FAIL (fixture): ${relative(ROOT, f.path)}`);
303
+ for (const err of f.errors) {
304
+ console.error(` ${err.path} — ${err.message}`);
305
+ }
306
+ failed++;
307
+ }
308
+ }
309
+
310
+ // Invalid fixtures should all fail
311
+ const invalidFixtures = loadFindings(ROOT, { fixtures: true, fixtureKind: 'invalid' });
312
+ for (const f of invalidFixtures) {
313
+ if (!f.valid) {
314
+ console.log(`PASS (expected invalid): ${relative(ROOT, f.path)}`);
315
+ passed++;
316
+ } else {
317
+ console.error(`FAIL (expected invalid but passed): ${relative(ROOT, f.path)}`);
318
+ failed++;
319
+ }
320
+ }
321
+ }
322
+
323
+ // Duplicate check
324
+ const allForDupes = [...loadFindings(ROOT)];
325
+ const dupes = findDuplicates(allForDupes);
326
+ if (dupes.length > 0) {
327
+ for (const d of dupes) {
328
+ console.error(`DUPLICATE: ${d.findingId} found in ${d.paths.map(p => relative(ROOT, p)).join(', ')}`);
329
+ failed++;
330
+ }
331
+ }
332
+
333
+ console.log(`\n${passed} passed, ${failed} failed`);
334
+ process.exit(failed > 0 ? 1 : 0);
335
+ }
336
+
337
+ if (command === 'derive') {
338
+ const recordId = flags.record;
339
+ const repoKey = flags.repo;
340
+ const all = flags.all;
341
+ const write = flags.write;
342
+
343
+ // Load records based on scope
344
+ let entries = [];
345
+ if (recordId) {
346
+ const entry = loadRecordById(ROOT, recordId);
347
+ if (!entry) {
348
+ console.error(`Record not found: ${recordId}`);
349
+ process.exit(1);
350
+ }
351
+ entries = [entry];
352
+ } else if (repoKey) {
353
+ entries = loadRecordsForRepo(ROOT, repoKey);
354
+ if (entries.length === 0) {
355
+ console.error(`No records found for repo: ${repoKey}`);
356
+ process.exit(1);
357
+ }
358
+ } else if (all) {
359
+ entries = loadAllRecords(ROOT);
360
+ if (entries.length === 0) {
361
+ console.error('No records found.');
362
+ process.exit(1);
363
+ }
364
+ } else {
365
+ console.error('Specify --record <run_id>, --repo <org/repo>, or --all');
366
+ process.exit(2);
367
+ }
368
+
369
+ // Derive
370
+ const { candidates, ruleErrors, stats } = deriveFromRecords(entries);
371
+
372
+ // Surface per-rule throws — derivation only "succeeded" when ruleErrors is empty.
373
+ // Exit non-zero so CI never silently treats a partially-degraded run as green.
374
+ if (ruleErrors.length > 0) {
375
+ console.error(`${ruleErrors.length} rule error(s) during derivation:`);
376
+ for (const e of ruleErrors) {
377
+ console.error(` rule=${e.ruleId} run_id=${e.runId} msg=${e.message}`);
378
+ }
379
+ process.exit(1);
380
+ }
381
+
382
+ // Validate all candidates against schema
383
+ const invalid = candidates.filter(c => !validateFinding(c).valid);
384
+ if (invalid.length > 0) {
385
+ console.error(`${invalid.length} candidate(s) failed schema validation:`);
386
+ for (const c of invalid) {
387
+ const result = validateFinding(c);
388
+ console.error(` ${c.finding_id}: ${result.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
389
+ }
390
+ process.exit(1);
391
+ }
392
+
393
+ // Dedupe against existing findings
394
+ const existingFindings = loadFindings(ROOT);
395
+ const { toWrite, skippedUnchanged, collisions } = dedupeAgainstExisting(candidates, existingFindings);
396
+
397
+ // Report
398
+ console.log(`Processed ${stats.recordsProcessed} record(s)`);
399
+ console.log(`Rules evaluated: ${stats.rulesEvaluated}`);
400
+ console.log(`Candidates emitted: ${candidates.length}`);
401
+ if (stats.deduped > 0) console.log(`Deduped within batch: ${stats.deduped}`);
402
+ if (skippedUnchanged > 0) console.log(`Skipped unchanged: ${skippedUnchanged}`);
403
+ if (collisions.length > 0) {
404
+ console.log(`Collisions: ${collisions.length}`);
405
+ for (const c of collisions) {
406
+ console.log(` ${c.findingId} (existing status: ${c.existingStatus})`);
407
+ }
408
+ }
409
+ console.log();
410
+
411
+ for (const c of toWrite) {
412
+ console.log(` - ${c.finding_id}`);
413
+ console.log(` rule: ${c.derived.rule_id}`);
414
+ const evSummary = c.evidence.map(e => `${e.evidence_kind}:${e.record_id || e.scenario_id || ''}`).join(', ');
415
+ console.log(` evidence: ${evSummary}`);
416
+ }
417
+
418
+ if (!write) {
419
+ console.log(`\n(dry-run) ${toWrite.length} candidate(s) would be written. Use --write to materialize.`);
420
+ process.exit(0);
421
+ }
422
+
423
+ // Write mode
424
+ const { written, errors } = writeFindings(ROOT, toWrite);
425
+ console.log(`\nWritten: ${written.length}`);
426
+ for (const p of written) {
427
+ console.log(` ${relative(ROOT, p)}`);
428
+ }
429
+ if (errors.length > 0) {
430
+ console.error(`Errors: ${errors.length}`);
431
+ for (const e of errors) {
432
+ console.error(` ${e.findingId}: ${e.error}`);
433
+ }
434
+ process.exit(1);
435
+ }
436
+ process.exit(0);
437
+ }
438
+
439
+ if (command === 'explain') {
440
+ const findingId = positional[0];
441
+ if (!findingId) {
442
+ console.error('Usage: dogfood findings explain <finding_id>');
443
+ process.exit(2);
444
+ }
445
+
446
+ const result = findById(ROOT, findingId);
447
+ if (!result) {
448
+ console.error(`Finding not found: ${findingId}`);
449
+ process.exit(1);
450
+ }
451
+
452
+ const d = result.data;
453
+ const lines = [
454
+ `Finding: ${d.finding_id}`,
455
+ `Title: ${d.title}`,
456
+ `Status: ${d.status}`,
457
+ ''
458
+ ];
459
+
460
+ if (d.derived) {
461
+ lines.push(
462
+ 'Derivation:',
463
+ ` Method: ${d.derived.method}`,
464
+ ` Rule: ${d.derived.rule_id}`,
465
+ ` Derived: ${d.derived.derived_at}`,
466
+ '',
467
+ 'Rationale:',
468
+ ` ${d.derived.rationale.trim()}`,
469
+ ''
470
+ );
471
+
472
+ const rule = getRuleById(d.derived.rule_id);
473
+ if (rule) {
474
+ lines.push(`Rule description:`, ` ${rule.description}`, '');
475
+ }
476
+ } else {
477
+ lines.push('(Hand-authored finding — no derivation metadata)', '');
478
+ }
479
+
480
+ lines.push(`Source records: ${d.source_record_ids.join(', ')}`);
481
+ if (d.scenario_ids?.length) {
482
+ lines.push(`Scenarios: ${d.scenario_ids.join(', ')}`);
483
+ }
484
+
485
+ lines.push('', `Evidence (${d.evidence.length}):`);
486
+ for (const e of d.evidence) {
487
+ const parts = [` - ${e.evidence_kind}`];
488
+ if (e.record_id) parts.push(`record=${e.record_id}`);
489
+ if (e.scenario_id) parts.push(`scenario=${e.scenario_id}`);
490
+ if (e.note) parts.push(`| ${e.note}`);
491
+ lines.push(parts.join(' '));
492
+ }
493
+
494
+ lines.push('', `Classification:`);
495
+ lines.push(` Issue: ${d.issue_kind}`);
496
+ lines.push(` Root cause: ${d.root_cause_kind}`);
497
+ lines.push(` Remediation: ${d.remediation_kind}`);
498
+ lines.push(` Scope: ${d.transfer_scope}`);
499
+
500
+ console.log(lines.join('\n'));
501
+ process.exit(0);
502
+ }
503
+
504
+ // ── Review commands ──────────────────────────────
505
+
506
+ if (['accept', 'reject', 'review', 'reopen', 'invalidate'].includes(command)) {
507
+ const findingId = positional[0];
508
+ if (!findingId) {
509
+ console.error(`Usage: dogfood findings ${command} <finding_id> --actor <name> [--reason "..."]`);
510
+ process.exit(2);
511
+ }
512
+ const actor = flags.actor || 'operator';
513
+ const result = performAction(ROOT, {
514
+ findingId,
515
+ action: command,
516
+ actor,
517
+ reason: flags.reason,
518
+ rejectReason: flags['reject-reason'],
519
+ notes: flags.notes
520
+ });
521
+ if (!result.success) {
522
+ console.error(`FAILED: ${result.error}`);
523
+ process.exit(1);
524
+ }
525
+ console.log(`${command}: ${findingId} → ${result.finding.status}`);
526
+ if (result.event) {
527
+ console.log(`Event: ${result.event.review_event_id} (${result.event.from_status} → ${result.event.to_status})`);
528
+ }
529
+ process.exit(0);
530
+ }
531
+
532
+ if (command === 'edit') {
533
+ const findingId = positional[0];
534
+ if (!findingId) {
535
+ console.error('Usage: dogfood findings edit <finding_id> --actor <name> --set field=value [--set field=value]');
536
+ process.exit(2);
537
+ }
538
+ // Parse --set flags
539
+ const fieldChanges = {};
540
+ const args = process.argv.slice(2);
541
+ for (let i = 0; i < args.length; i++) {
542
+ if (args[i] === '--set' && i + 1 < args.length) {
543
+ const [field, ...rest] = args[++i].split('=');
544
+ fieldChanges[field] = rest.join('=');
545
+ }
546
+ }
547
+ if (Object.keys(fieldChanges).length === 0) {
548
+ console.error('No field changes specified. Use --set field=value');
549
+ process.exit(2);
550
+ }
551
+ const actor = flags.actor || 'operator';
552
+ const result = performAction(ROOT, {
553
+ findingId,
554
+ action: 'edit',
555
+ actor,
556
+ fieldChanges,
557
+ notes: flags.notes
558
+ });
559
+ if (!result.success) {
560
+ console.error(`FAILED: ${result.error}`);
561
+ process.exit(1);
562
+ }
563
+ console.log(`Edited: ${findingId}`);
564
+ if (result.event?.field_changes) {
565
+ for (const [field, change] of Object.entries(result.event.field_changes)) {
566
+ console.log(` ${field}: "${change.from}" → "${change.to}"`);
567
+ }
568
+ }
569
+ process.exit(0);
570
+ }
571
+
572
+ if (command === 'merge') {
573
+ const sourceIds = positional;
574
+ const canonicalId = flags.into;
575
+ const actor = flags.actor || 'operator';
576
+ const reason = flags.reason;
577
+ if (sourceIds.length < 2 || !canonicalId) {
578
+ console.error('Usage: dogfood findings merge <id1> <id2> [<id3>...] --into <canonical_id> --actor <name> --reason "..."');
579
+ process.exit(2);
580
+ }
581
+ const result = performMerge(ROOT, { sourceIds, canonicalId, actor, reason });
582
+ if (!result.success) {
583
+ console.error(`FAILED: ${result.error}`);
584
+ process.exit(1);
585
+ }
586
+ console.log(`Merged into: ${canonicalId}`);
587
+ console.log(`Sources superseded: ${sourceIds.filter(id => id !== canonicalId).join(', ')}`);
588
+ console.log(`Evidence count: ${result.canonical.evidence.length}`);
589
+ console.log(`Source records: ${result.canonical.source_record_ids.length}`);
590
+ process.exit(0);
591
+ }
592
+
593
+ if (command === 'history') {
594
+ const findingId = positional[0];
595
+ if (!findingId) {
596
+ console.error('Usage: dogfood findings history <finding_id>');
597
+ process.exit(2);
598
+ }
599
+ const events = getEventsForFinding(ROOT, findingId);
600
+ if (events.length === 0) {
601
+ console.log(`No review history for: ${findingId}`);
602
+ process.exit(0);
603
+ }
604
+ console.log(`Review history for ${findingId} (${events.length} event(s)):\n`);
605
+ for (const e of events) {
606
+ console.log(` ${e.timestamp} ${e.action} ${e.from_status} → ${e.to_status} by ${e.actor}`);
607
+ if (e.reason) console.log(` Reason: ${e.reason}`);
608
+ if (e.field_changes) {
609
+ for (const [field, change] of Object.entries(e.field_changes)) {
610
+ console.log(` ${field}: "${change.from}" → "${change.to}"`);
611
+ }
612
+ }
613
+ if (e.merged_from_ids) console.log(` Merged from: ${e.merged_from_ids.join(', ')}`);
614
+ }
615
+ process.exit(0);
616
+ }
617
+
618
+ if (command === 'queue') {
619
+ const queue = getReviewQueue(ROOT);
620
+ if (queue.length === 0) {
621
+ console.log('Review queue is empty.');
622
+ process.exit(0);
623
+ }
624
+ console.log(`Review queue (${queue.length} item(s)):\n`);
625
+ for (const item of queue) {
626
+ console.log(` [${item.data.status}] ${item.data.finding_id}`);
627
+ console.log(` ${item.queueReason}`);
628
+ console.log(` ${item.data.title}`);
629
+ console.log();
630
+ }
631
+ process.exit(0);
632
+ }
633
+
634
+ // ── Synthesis commands ──────────────────────────────
635
+
636
+ if (command === 'patterns') {
637
+ const sub = positional[0];
638
+ if (sub === 'derive') {
639
+ const write = flags.write;
640
+ const { patterns, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
641
+
642
+ // Validate all
643
+ const invalid = patterns.filter(p => !validatePattern(p).valid);
644
+ if (invalid.length > 0) {
645
+ console.error(`${invalid.length} pattern(s) failed schema validation`);
646
+ for (const p of invalid) {
647
+ const r = validatePattern(p);
648
+ console.error(` ${p.pattern_id}: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
649
+ }
650
+ process.exit(1);
651
+ }
652
+
653
+ console.log(`Findings considered: ${stats.findingsConsidered}`);
654
+ console.log(`Clusters found: ${stats.clustersFound}`);
655
+ console.log(`Below threshold: ${stats.belowThreshold}`);
656
+ console.log(`Patterns emitted: ${patterns.length}\n`);
657
+
658
+ for (const p of patterns) {
659
+ console.log(` ${p.pattern_id} [${p.pattern_strength}]`);
660
+ console.log(` ${p.title}`);
661
+ console.log(` findings: ${p.source_finding_ids.join(', ')}`);
662
+ console.log();
663
+ }
664
+
665
+ if (write && patterns.length > 0) {
666
+ for (const p of patterns) {
667
+ const path = writePattern(ROOT, p);
668
+ console.log(`Written: ${relative(ROOT, path)}`);
669
+ }
670
+ } else if (!write && patterns.length > 0) {
671
+ console.log(`(dry-run) ${patterns.length} pattern(s) would be written. Use --write to materialize.`);
672
+ }
673
+ process.exit(0);
674
+ }
675
+
676
+ if (sub === 'list') {
677
+ const patterns = loadPatterns(ROOT);
678
+ if (patterns.length === 0) { console.log('No patterns found.'); process.exit(0); }
679
+ for (const p of patterns) {
680
+ console.log(`[${p.status}] ${p.pattern_id} (${p.pattern_strength || 'unknown'})`);
681
+ console.log(` ${p.title}`);
682
+ console.log();
683
+ }
684
+ console.log(`${patterns.length} pattern(s)`);
685
+ process.exit(0);
686
+ }
687
+
688
+ if (sub === 'show' || sub === 'explain') {
689
+ const id = positional[1];
690
+ if (!id) { console.error(`Usage: dogfood findings patterns ${sub} <pattern_id>`); process.exit(2); }
691
+ const all = loadPatterns(ROOT);
692
+ const p = all.find(x => x.pattern_id === id);
693
+ if (!p) { console.error(`Pattern not found: ${id}`); process.exit(1); }
694
+
695
+ console.log(`Pattern: ${p.pattern_id}`);
696
+ console.log(`Title: ${p.title}`);
697
+ console.log(`Status: ${p.status}`);
698
+ console.log(`Kind: ${p.pattern_kind}`);
699
+ console.log(`Strength: ${p.pattern_strength || 'unknown'}`);
700
+ console.log(`Scope: ${p.transfer_scope}`);
701
+ console.log(`\nSummary:\n ${p.summary}`);
702
+ console.log(`\nSupport: ${p.support.finding_count} findings, ${p.support.repo_count} repos, ${p.support.surface_count} surfaces`);
703
+ console.log(`\nSource findings: ${p.source_finding_ids.join(', ')}`);
704
+ console.log(`Dimensions: surfaces=${(p.dimensions.product_surfaces||[]).join(',')}, issues=${(p.dimensions.issue_kinds||[]).join(',')}`);
705
+ if (p.lineage_note) console.log(`\nLineage: ${p.lineage_note}`);
706
+ process.exit(0);
707
+ }
708
+
709
+ console.error('Usage: dogfood findings patterns <derive|list|show|explain> [options]');
710
+ process.exit(2);
711
+ }
712
+
713
+ if (command === 'recommendations') {
714
+ const sub = positional[0];
715
+ if (sub === 'derive') {
716
+ const write = flags.write;
717
+ const { recommendations, stats } = deriveRecommendations(ROOT);
718
+
719
+ console.log(`Patterns considered: ${stats.patternsConsidered}`);
720
+ console.log(`Recommendations emitted: ${stats.recommendationsEmitted}\n`);
721
+
722
+ for (const r of recommendations) {
723
+ console.log(` ${r.recommendation_id} [${r.confidence}]`);
724
+ console.log(` ${r.title}`);
725
+ console.log();
726
+ }
727
+
728
+ if (write && recommendations.length > 0) {
729
+ for (const r of recommendations) {
730
+ const path = writeRecommendation(ROOT, r);
731
+ console.log(`Written: ${relative(ROOT, path)}`);
732
+ }
733
+ } else if (!write && recommendations.length > 0) {
734
+ console.log(`(dry-run) ${recommendations.length} recommendation(s) would be written. Use --write to materialize.`);
735
+ }
736
+ process.exit(0);
737
+ }
738
+
739
+ if (sub === 'list') {
740
+ const recs = loadRecommendations(ROOT);
741
+ if (recs.length === 0) { console.log('No recommendations found.'); process.exit(0); }
742
+ for (const r of recs) {
743
+ console.log(`[${r.status}] ${r.recommendation_id}`);
744
+ console.log(` ${r.title}`);
745
+ console.log();
746
+ }
747
+ process.exit(0);
748
+ }
749
+
750
+ if (sub === 'show') {
751
+ const id = positional[1];
752
+ if (!id) { console.error('Usage: dogfood findings recommendations show <id>'); process.exit(2); }
753
+ const all = loadRecommendations(ROOT);
754
+ const r = all.find(x => x.recommendation_id === id);
755
+ if (!r) { console.error(`Recommendation not found: ${id}`); process.exit(1); }
756
+ console.log(`Recommendation: ${r.recommendation_id}`);
757
+ console.log(`Title: ${r.title}`);
758
+ console.log(`Status: ${r.status}`);
759
+ console.log(`Kind: ${r.recommendation_kind}`);
760
+ console.log(`Confidence: ${r.confidence}`);
761
+ console.log(`\nSummary:\n ${r.summary}`);
762
+ console.log(`\nAction: ${r.action.type} → ${r.action.target}`);
763
+ console.log(` ${r.action.details}`);
764
+ console.log(`\nBased on patterns: ${r.based_on_pattern_ids.join(', ')}`);
765
+ console.log(`Applies to: ${(r.applies_to?.product_surfaces || []).join(', ')}`);
766
+ process.exit(0);
767
+ }
768
+
769
+ console.error('Usage: dogfood findings recommendations <derive|list|show> [options]');
770
+ process.exit(2);
771
+ }
772
+
773
+ if (command === 'doctrine') {
774
+ const sub = positional[0];
775
+ if (sub === 'derive') {
776
+ const write = flags.write;
777
+ const { doctrines, stats } = deriveDoctrine(ROOT);
778
+
779
+ console.log(`Patterns considered: ${stats.patternsConsidered}`);
780
+ console.log(`Doctrines emitted: ${stats.doctrinesEmitted}`);
781
+ console.log(`Below threshold: ${stats.belowThreshold}\n`);
782
+
783
+ for (const d of doctrines) {
784
+ console.log(` ${d.doctrine_id} [${d.strength}]`);
785
+ console.log(` ${d.title}`);
786
+ console.log();
787
+ }
788
+
789
+ if (write && doctrines.length > 0) {
790
+ for (const d of doctrines) {
791
+ const path = writeDoctrine(ROOT, d);
792
+ console.log(`Written: ${relative(ROOT, path)}`);
793
+ }
794
+ } else if (!write && doctrines.length > 0) {
795
+ console.log(`(dry-run) ${doctrines.length} doctrine(s) would be written. Use --write to materialize.`);
796
+ }
797
+ process.exit(0);
798
+ }
799
+
800
+ if (sub === 'list') {
801
+ const docs = loadDoctrines(ROOT);
802
+ if (docs.length === 0) { console.log('No doctrine found.'); process.exit(0); }
803
+ for (const d of docs) {
804
+ console.log(`[${d.status}] ${d.doctrine_id} [${d.strength}]`);
805
+ console.log(` ${d.statement}`);
806
+ console.log();
807
+ }
808
+ process.exit(0);
809
+ }
810
+
811
+ if (sub === 'show') {
812
+ const id = positional[1];
813
+ if (!id) { console.error('Usage: dogfood findings doctrine show <id>'); process.exit(2); }
814
+ const all = loadDoctrines(ROOT);
815
+ const d = all.find(x => x.doctrine_id === id);
816
+ if (!d) { console.error(`Doctrine not found: ${id}`); process.exit(1); }
817
+ console.log(`Doctrine: ${d.doctrine_id}`);
818
+ console.log(`Title: ${d.title}`);
819
+ console.log(`Status: ${d.status}`);
820
+ console.log(`Kind: ${d.doctrine_kind}`);
821
+ console.log(`Strength: ${d.strength}`);
822
+ console.log(`Scope: ${d.transfer_scope}`);
823
+ console.log(`\nStatement:\n ${d.statement}`);
824
+ console.log(`\nRationale:\n ${d.rationale}`);
825
+ console.log(`\nBased on patterns: ${d.based_on_pattern_ids.join(', ')}`);
826
+ process.exit(0);
827
+ }
828
+
829
+ console.error('Usage: dogfood findings doctrine <derive|list|show> [options]');
830
+ process.exit(2);
831
+ }
832
+
833
+ // ── Advice commands ──────────────────────────────
834
+
835
+ if (command === 'advise') {
836
+ const surface = flags.surface;
837
+ const executionMode = flags['execution-mode'] || flags.mode;
838
+ const repo = flags.repo;
839
+
840
+ if (!surface && !repo) {
841
+ console.error('Usage: dogfood findings advise --surface <surface> [--execution-mode <mode>] [--repo <org/repo>]');
842
+ process.exit(2);
843
+ }
844
+
845
+ const bundle = generateAdviceBundle(ROOT, { surface, executionMode, repo });
846
+ const a = bundle.advice;
847
+
848
+ console.log(`Advice for: ${[surface, executionMode, repo].filter(Boolean).join(', ') || 'general'}\n`);
849
+
850
+ if (a.starter_checks.length > 0) {
851
+ console.log(`Starter checks (${a.starter_checks.length}):`);
852
+ for (const c of a.starter_checks) {
853
+ console.log(` [${c.confidence}] ${c.id}`);
854
+ console.log(` ${c.title}`);
855
+ }
856
+ console.log();
857
+ }
858
+
859
+ if (a.evidence_expectations.length > 0) {
860
+ console.log(`Evidence expectations (${a.evidence_expectations.length}):`);
861
+ for (const e of a.evidence_expectations) {
862
+ console.log(` [${e.confidence}] ${e.id}`);
863
+ console.log(` ${e.title}`);
864
+ }
865
+ console.log();
866
+ }
867
+
868
+ if (a.verification_rules.length > 0) {
869
+ console.log(`Verification rules (${a.verification_rules.length}):`);
870
+ for (const v of a.verification_rules) {
871
+ console.log(` [${v.confidence}] ${v.id}`);
872
+ console.log(` ${v.title}`);
873
+ }
874
+ console.log();
875
+ }
876
+
877
+ if (a.likely_failure_classes.length > 0) {
878
+ console.log(`Likely failure classes:`);
879
+ for (const fc of a.likely_failure_classes) {
880
+ console.log(` ${fc.issueKind} (${fc.count} finding(s))`);
881
+ }
882
+ console.log();
883
+ }
884
+
885
+ if (a.relevant_doctrine.length > 0) {
886
+ console.log(`Relevant doctrine (${a.relevant_doctrine.length}):`);
887
+ for (const d of a.relevant_doctrine) {
888
+ console.log(` [${d.strength}] ${d.id}`);
889
+ console.log(` ${d.statement}`);
890
+ }
891
+ console.log();
892
+ }
893
+
894
+ console.log(`Support: ${bundle.support.pattern_count} patterns, ${bundle.support.finding_count} findings, ${bundle.support.recommendation_count} recommendations, ${bundle.support.doctrine_count} doctrine`);
895
+ if (bundle.support.pattern_ids.length > 0) {
896
+ console.log(`Pattern IDs: ${bundle.support.pattern_ids.join(', ')}`);
897
+ }
898
+ process.exit(0);
899
+ }
900
+
901
+ if (command === 'sync-export') {
902
+ const bundle = generateSyncExport(ROOT);
903
+ const json = flags.json;
904
+
905
+ if (json) {
906
+ console.log(JSON.stringify(bundle, null, 2));
907
+ } else {
908
+ console.log(`Dogfood sync export (${bundle.exported_at})`);
909
+ console.log(` Findings: ${bundle.counts.findings}`);
910
+ console.log(` Patterns: ${bundle.counts.patterns}`);
911
+ console.log(` Recommendations: ${bundle.counts.recommendations}`);
912
+ console.log(` Doctrine: ${bundle.counts.doctrine}`);
913
+ console.log(`\nUse --json for machine-readable output.`);
914
+ }
915
+ process.exit(0);
916
+ }
917
+
918
+ if (command === 'rules') {
919
+ const inventory = getRuleInventory();
920
+ console.log(`Derivation rules (${inventory.length}):\n`);
921
+ for (const r of inventory) {
922
+ console.log(` ${r.ruleId}`);
923
+ console.log(` ${r.description}`);
924
+ console.log();
925
+ }
926
+ process.exit(0);
927
+ }
928
+
929
+ console.error(`Unknown command: ${command}. Run with --help for usage.`);
930
+ process.exit(2);
931
+ }
932
+
933
+ main().catch(err => {
934
+ console.error(err);
935
+ process.exit(2);
936
+ });