docgov-cli 0.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +41 -0
  3. package/LICENSE +21 -0
  4. package/README.md +136 -0
  5. package/agents/architect.md +65 -0
  6. package/agents/classifier.md +44 -0
  7. package/agents/drift-reviewer.md +59 -0
  8. package/agents/quality-reviewer.md +59 -0
  9. package/bin/docgov +1160 -0
  10. package/bin/docgov.cmd +2 -0
  11. package/core/check.js +298 -0
  12. package/core/classify.js +233 -0
  13. package/core/config.js +162 -0
  14. package/core/context.js +144 -0
  15. package/core/document.js +132 -0
  16. package/core/drift.js +225 -0
  17. package/core/find.js +61 -0
  18. package/core/frontmatter.js +65 -0
  19. package/core/git.js +113 -0
  20. package/core/graph.js +182 -0
  21. package/core/health.js +101 -0
  22. package/core/impact.js +146 -0
  23. package/core/invariants.js +126 -0
  24. package/core/inventory.js +167 -0
  25. package/core/links.js +80 -0
  26. package/core/migrate.js +158 -0
  27. package/core/onboard.js +271 -0
  28. package/core/paths.js +53 -0
  29. package/core/publish.js +92 -0
  30. package/core/registry.js +71 -0
  31. package/core/similarity.js +89 -0
  32. package/core/size.js +87 -0
  33. package/core/suppressions.js +58 -0
  34. package/core/taxonomy.js +477 -0
  35. package/core/templates.js +159 -0
  36. package/core/util.js +124 -0
  37. package/core/yaml.js +250 -0
  38. package/hooks/hooks.json +65 -0
  39. package/lenses/agent.md +38 -0
  40. package/lenses/architecture.md +30 -0
  41. package/lenses/developer.md +26 -0
  42. package/lenses/operations.md +32 -0
  43. package/lenses/readme.md +32 -0
  44. package/lenses/security.md +33 -0
  45. package/lenses/user.md +30 -0
  46. package/package.json +39 -0
  47. package/policy/documentation.md +82 -0
  48. package/schemas/config.json +239 -0
  49. package/schemas/frontmatter.json +299 -0
  50. package/skills/affected/SKILL.md +41 -0
  51. package/skills/brief/SKILL.md +38 -0
  52. package/skills/create/SKILL.md +53 -0
  53. package/skills/find/SKILL.md +32 -0
  54. package/skills/health/SKILL.md +36 -0
  55. package/skills/inspect/SKILL.md +58 -0
  56. package/skills/publish/SKILL.md +45 -0
  57. package/skills/review/SKILL.md +65 -0
  58. package/skills/setup/SKILL.md +52 -0
  59. package/skills/stale/SKILL.md +55 -0
  60. package/skills/tag/SKILL.md +59 -0
  61. package/templates/architecture.adr.md +42 -0
  62. package/templates/architecture.domain.md +44 -0
  63. package/templates/architecture.trd.md +72 -0
  64. package/templates/constitution.invariants.md +40 -0
  65. package/templates/operations.runbook.md +47 -0
  66. package/templates/product.prd.md +60 -0
  67. package/templates/security.threat-model.md +51 -0
  68. package/templates/user.readme.md +43 -0
package/bin/docgov ADDED
@@ -0,0 +1,1160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * docgov — documentation governance engine.
4
+ *
5
+ * One binary serves three callers: Claude Code skills (via `--json`), Claude Code
6
+ * hooks (via `docgov hook <event>` speaking the hook JSON protocol on stdin/stdout),
7
+ * and CI (via exit codes). Keeping them on one implementation is why a governance
8
+ * decision cannot differ between the editor and the pipeline.
9
+ */
10
+ import path from 'node:path';
11
+ import fs from 'node:fs';
12
+ import process from 'node:process';
13
+
14
+ import * as cfgmod from '../core/config.js';
15
+ import * as reg from '../core/registry.js';
16
+ import * as graphmod from '../core/graph.js';
17
+ import * as checkmod from '../core/check.js';
18
+ import * as driftmod from '../core/drift.js';
19
+ import * as impactmod from '../core/impact.js';
20
+ import * as onboardmod from '../core/onboard.js';
21
+ import * as migratemod from '../core/migrate.js';
22
+ import * as tpl from '../core/templates.js';
23
+ import * as inv from '../core/inventory.js';
24
+ import * as invariantsmod from '../core/invariants.js';
25
+ import * as ctxpack from '../core/context.js';
26
+ import * as findmod from '../core/find.js';
27
+ import * as healthmod from '../core/health.js';
28
+ import * as pubmod from '../core/publish.js';
29
+ import * as supp from '../core/suppressions.js';
30
+ import * as git from '../core/git.js';
31
+ import * as yaml from '../core/yaml.js';
32
+ import * as fm from '../core/frontmatter.js';
33
+ import { Document } from '../core/document.js';
34
+ import { classify, destinationFor } from '../core/classify.js';
35
+ import { AUTHORITY, TYPES, typeDef, FULL_NAMESPACES, COMPACT_NAMESPACES } from '../core/taxonomy.js';
36
+ import { staleness } from '../core/drift.js';
37
+ import { EXIT, DocGovError, table, write, read, exists, toPosix, plural } from '../core/util.js';
38
+
39
+ const argv = process.argv.slice(2);
40
+
41
+ /**
42
+ * Conventional flags, because `docgov --version` is what people type and
43
+ * `docgov version` is what they have to discover. Without this, every
44
+ * `--version` / `--help` lands in the unknown-command path and exits 3, which
45
+ * reads as a broken install.
46
+ */
47
+ const FLAG_ALIASES = {
48
+ '--version': 'version', '-v': 'version', '-V': 'version',
49
+ '--help': 'help', '-h': 'help', '-?': 'help',
50
+ };
51
+ const cmd = FLAG_ALIASES[argv[0]] || argv[0];
52
+ const flags = parseFlags(argv.slice(1));
53
+ const JSONOUT = flags.json === true;
54
+
55
+ function parseFlags(args) {
56
+ const out = { _: [] };
57
+ for (let i = 0; i < args.length; i++) {
58
+ const a = args[i];
59
+ if (a.startsWith('--')) {
60
+ const [k, v] = a.slice(2).split('=');
61
+ const key = k.replace(/-/g, '_');
62
+ if (v !== undefined) out[key] = v;
63
+ else if (args[i + 1] && !args[i + 1].startsWith('--')) out[key] = args[++i];
64
+ else out[key] = true;
65
+ } else out._.push(a);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function list(v) { return v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean); }
71
+ function say(...a) { if (!JSONOUT) console.log(...a); }
72
+ function emit(obj) { if (JSONOUT) console.log(JSON.stringify(obj, null, flags.compact ? 0 : 2)); }
73
+
74
+ /** Load everything once. Every command sees the same snapshot. */
75
+ function ctx({ requireInit = true } = {}) {
76
+ const { root, cfg, raw, initialized } = cfgmod.load(process.cwd());
77
+ if (requireInit && !initialized) {
78
+ throw new DocGovError('this repository is not governed yet. Run `docgov setup` (or `docgov review` for an existing documentation tree).');
79
+ }
80
+ const i = inv.inventory(root, cfg);
81
+ const docs = i.documents;
82
+ const { registry, collisions } = reg.build(docs, i.contracts);
83
+ const graph = graphmod.build(docs, registry, cfg, i.contracts);
84
+ return { root, cfg, raw, initialized, inv: i, docs, registry, collisions, graph };
85
+ }
86
+
87
+ const COMMANDS = {
88
+ setup: cmdSetup,
89
+ review: cmdReview,
90
+ fix: cmdFix,
91
+ whatis: cmdWhatis,
92
+ tag: cmdTag,
93
+ create: cmdCreate,
94
+ check: cmdCheck,
95
+ stale: cmdStale,
96
+ affected: cmdAffected,
97
+ checklist: cmdChecklist,
98
+ brief: cmdBrief,
99
+ find: cmdFind,
100
+ health: cmdHealth,
101
+ publish: cmdPublish,
102
+ registry: cmdRegistry,
103
+ graph: cmdGraph,
104
+ rules: cmdRules,
105
+ types: cmdTypes,
106
+ tools: cmdTools,
107
+ ignore: cmdIgnore,
108
+ inspect: cmdInspect,
109
+ hook: cmdHook,
110
+ version: () => { say(packageVersion()); return EXIT.OK; },
111
+ help: usage,
112
+ };
113
+
114
+ try {
115
+ const fn = COMMANDS[cmd];
116
+ if (!fn) { usage(); process.exit(cmd ? EXIT.CONFIG : EXIT.OK); }
117
+ process.exit(fn() ?? EXIT.OK);
118
+ } catch (e) {
119
+ if (e instanceof DocGovError) {
120
+ if (JSONOUT) console.log(JSON.stringify({ error: e.message }));
121
+ else console.error(`docgov: ${e.message}`);
122
+ process.exit(e.code ?? EXIT.CONFIG);
123
+ }
124
+ if (JSONOUT) console.log(JSON.stringify({ error: String(e.message || e) }));
125
+ else console.error(`docgov: unexpected error: ${e.stack || e}`);
126
+ process.exit(EXIT.CONFIG);
127
+ }
128
+
129
+ // ───────────────────────────── commands ─────────────────────────────
130
+
131
+ function cmdSetup() {
132
+ const { root, initialized } = cfgmod.load(process.cwd());
133
+ if (initialized && !flags.force) throw new DocGovError('already initialized. Pass --force to rewrite .docgov/config.yaml.');
134
+
135
+ const i = inv.inventory(root, cfgmod.defaults());
136
+ const mode = flags.mode || inferMode(root, i);
137
+ const layout = flags.layout || (i.documents.length > 25 ? 'full' : 'compact');
138
+ const visibility = flags.visibility || (exists(path.join(root, 'LICENSE')) ? 'mixed' : 'internal');
139
+
140
+ // Adoption ramp: a repository with documentation that predates DocGov would otherwise
141
+ // fail CI the moment governance is switched on, which is how a governance tool earns
142
+ // its uninstall. Start in warn-only and tell the user when to turn it off.
143
+ const preexisting = i.documents.filter((d) => !d.frontmatter?.docgov).length;
144
+ const warnOnly = preexisting > 0 && mode !== 'solo';
145
+
146
+ const raw = {
147
+ version: 1,
148
+ project: { name: projectName(root), mode, visibility, layout },
149
+ documentation: { root: 'docs' },
150
+ governance: {
151
+ canonical_changes_require_review: mode !== 'solo',
152
+ prevent_duplicate_domains: true,
153
+ ...(warnOnly ? { warn_only: true } : {}),
154
+ },
155
+ drift: { enabled: true },
156
+ generated: { allow_manual_edit: false },
157
+ domains: {},
158
+ };
159
+ cfgmod.save(root, raw);
160
+
161
+ const { cfg } = cfgmod.load(root);
162
+ const made = [];
163
+ for (const dir of layout === 'full' ? FULL_NAMESPACES : COMPACT_NAMESPACES) {
164
+ const abs = path.join(root, dir);
165
+ if (!exists(abs)) { fs.mkdirSync(abs, { recursive: true }); made.push(dir); }
166
+ }
167
+
168
+ const rulesPath = path.join(root, '.claude', 'rules', 'documentation.md');
169
+ if (!exists(rulesPath) || flags.force) { write(rulesPath, agentRules(cfg)); made.push('.claude/rules/documentation.md'); }
170
+
171
+ const { registry } = reg.build(i.documents, i.contracts);
172
+ reg.save(root, registry);
173
+ graphmod.save(root, graphmod.build(i.documents, registry, cfg, i.contracts));
174
+
175
+ if (JSONOUT) return emit({ initialized: true, mode, layout, visibility, created: made }) ?? EXIT.OK;
176
+ say(`Initialized DocGov in ${root}`);
177
+ say(` mode ${mode} (${cfgmod.MODE_PROFILES[mode].block.length} rules block, the rest warn)`);
178
+ say(` layout ${layout}`);
179
+ say(` visibility ${visibility}`);
180
+ say(` documents ${i.documents.length} found, ${i.contracts.length} machine contract(s)`);
181
+ say('');
182
+ say('Created:');
183
+ for (const m of made.slice(0, 12)) say(` ${m}`);
184
+ if (made.length > 12) say(` … and ${made.length - 12} more namespaces`);
185
+ say('');
186
+ if (warnOnly) {
187
+ say(`warn_only is on because ${preexisting} document(s) predate DocGov — nothing will block yet.`);
188
+ say('Run `docgov review`, then `docgov fix`, then remove `warn_only` from .docgov/config.yaml.');
189
+ say('');
190
+ }
191
+ say(i.documents.length > 2
192
+ ? 'Next: `docgov review` to classify what is already here.'
193
+ : 'Next: `docgov create user.readme` or `docgov check`.');
194
+ if (git.isRepo(root) && !git.isClean(root)) {
195
+ say('');
196
+ say('Commit this before running `docgov fix` — it needs a clean tree so it stays revertible:');
197
+ say(' git add -A && git commit -m "chore: adopt DocGov"');
198
+ }
199
+ return EXIT.OK;
200
+ }
201
+
202
+
203
+ function cmdReview() {
204
+ const c = ctx({ requireInit: false });
205
+ if (!c.initialized) {
206
+ say('Not initialized — running `docgov setup` with inferred settings first.\n');
207
+ cmdSetup();
208
+ return cmdReview();
209
+ }
210
+ const planData = onboardmod.plan(c);
211
+ const md = onboardmod.render(planData, c.cfg);
212
+ write(path.join(c.root, onboardmod.PLAN_PATH), md + '\n');
213
+ write(path.join(c.root, onboardmod.PLAN_DATA_PATH), JSON.stringify(planData, null, 2) + '\n');
214
+
215
+ if (JSONOUT) return emit({ plan: onboardmod.PLAN_PATH, data: onboardmod.PLAN_DATA_PATH, ...planData }) ?? EXIT.OK;
216
+ const s = planData.summary;
217
+ say('DocGov review');
218
+ say('─────────────');
219
+ say(`${plural(s.documents, 'document')} inventoried · ${plural(planData.contracts.length, 'machine contract')} · stack: ${planData.stack.map((x) => x.id).join(', ') || 'none detected'}`);
220
+ say('');
221
+ say(table([
222
+ { Finding: 'unclassified', Count: s.unclassified },
223
+ { Finding: 'low-confidence classification', Count: s.lowConfidence },
224
+ { Finding: 'moves proposed', Count: s.moves },
225
+ { Finding: 'frontmatter to add', Count: s.annotations },
226
+ { Finding: 'split candidates', Count: s.splits },
227
+ { Finding: 'README extractions', Count: s.extracts },
228
+ { Finding: 'suspected duplicates', Count: s.merges },
229
+ { Finding: 'documents to archive', Count: s.archives },
230
+ { Finding: 'missing documents', Count: s.creates },
231
+ { Finding: 'broken internal links', Count: s.brokenLinks },
232
+ ], ['Finding', 'Count']));
233
+ say('');
234
+ say(`Plan written to ${onboardmod.PLAN_PATH} — nothing has changed.`);
235
+ say(`${s.needJudgement} of ${planData.actions.length} actions need a judgement call.`);
236
+ say('');
237
+ say('Read the plan, delete anything you disagree with, then: docgov fix --dry-run');
238
+ return EXIT.OK;
239
+ }
240
+
241
+ function cmdFix() {
242
+ const c = ctx();
243
+ const dataPath = path.join(c.root, onboardmod.PLAN_DATA_PATH);
244
+ if (!exists(dataPath)) throw new DocGovError('no fix plan found. Run `docgov review` first.');
245
+ const planData = JSON.parse(read(dataPath));
246
+ const dryRun = flags.dry_run === true || flags.dry === true;
247
+ const include = list(flags.include);
248
+ const useGit = flags.no_git !== true;
249
+
250
+ let branch = null;
251
+ if (!dryRun && useGit && git.isRepo(c.root) && flags.no_branch !== true) {
252
+ branch = typeof flags.branch === 'string' ? flags.branch : `docgov/migration-${new Date().toISOString().slice(0, 10)}`;
253
+ if (git.revParse(c.root, branch)) say(`Branch ${branch} already exists — committing onto it.`);
254
+ else git.createBranch(c.root, branch);
255
+ }
256
+
257
+ const result = migratemod.migrate({ root: c.root, cfg: c.cfg, docs: c.docs, planData, dryRun, include, branch, useGit });
258
+
259
+ if (!dryRun) {
260
+ const after = ctx();
261
+ const problems = migratemod.verify({ root: after.root, docs: after.docs, inv: after.inv });
262
+ reg.save(after.root, reg.build(after.docs, after.inv.contracts).registry);
263
+ graphmod.save(after.root, after.graph);
264
+ result.verification = problems;
265
+ if (problems.length && flags.keep !== true && useGit && git.isRepo(c.root)) {
266
+ result.aborted = true;
267
+ migratemod.abort(c.root);
268
+ } else if (flags.commit === true) {
269
+ git.add(c.root, ['.']);
270
+ git.commit(c.root, `docs: migrate documentation to the DocGov taxonomy\n\n${result.moved} moved, ${result.annotated} annotated, ${result.linksRepaired} link sets repaired.`);
271
+ result.committed = true;
272
+ }
273
+ }
274
+
275
+ if (JSONOUT) return emit(result) ?? (result.aborted ? EXIT.VIOLATION : EXIT.OK);
276
+ say(dryRun ? 'Migration plan (dry run — nothing changed)' : 'Migration');
277
+ say('─'.repeat(42));
278
+ for (const op of result.ops.slice(0, 60)) {
279
+ if (op.op === 'move') say(` MOVE ${op.from}\n → ${op.to}${op.rewroteLinks ? ' (links repaired)' : ''}${op.annotated ? ' (frontmatter added)' : ''}`);
280
+ else if (op.op === 'edit') say(` EDIT ${op.path}${op.rewroteLinks ? ' (links repaired)' : ''}${op.annotated ? ' (frontmatter added)' : ''}`);
281
+ else say(` MKDIR ${op.path} (${op.type})`);
282
+ }
283
+ if (result.ops.length > 60) say(` … and ${result.ops.length - 60} more operations`);
284
+ say('');
285
+ say(`${result.moved} moved · ${result.edited} edited in place · ${result.annotated} annotated · ${result.linksRepaired} documents had links repaired`);
286
+ if (branch) say(`Branch: ${branch}`);
287
+ if (result.deferred.length) {
288
+ say('');
289
+ say(`Deferred to /docgov:tag (these rewrite prose, so an agent does them with you): ${result.deferred.length}`);
290
+ for (const d of result.deferred.slice(0, 8)) say(` ${d.kind.padEnd(8)} ${d.path || d.to}`);
291
+ }
292
+ if (result.verification?.length) {
293
+ say('');
294
+ say(result.aborted
295
+ ? `Verification failed with ${result.verification.length} problem(s) — migration reverted with git reset --hard.`
296
+ : `Verification found ${result.verification.length} problem(s):`);
297
+ for (const p of result.verification.slice(0, 10)) say(` ${p.kind} ${p.path}${p.target ? ` → ${p.target}` : ''}`);
298
+ return result.aborted ? EXIT.VIOLATION : EXIT.REVIEW;
299
+ }
300
+ if (!dryRun) say('\nVerified: no broken links, no duplicate ids.');
301
+ return EXIT.OK;
302
+ }
303
+
304
+ function cmdWhatis() {
305
+ const c = ctx({ requireInit: false });
306
+ const targets = flags.path ? [String(flags.path)] : (flags._.length ? flags._ : c.docs.map((d) => d.path));
307
+ const rows = [];
308
+ for (const p of targets) {
309
+ const doc = c.docs.find((d) => d.path === p) ||
310
+ (exists(path.join(c.root, p)) ? new Document(c.root, p) : { path: p, body: '', frontmatter: {} });
311
+ const res = classify(doc);
312
+ rows.push({
313
+ path: p, type: res.type, confidence: res.confidence, declared: res.declared,
314
+ needsReview: res.needsReview, signals: res.signals, candidates: res.candidates,
315
+ destination: res.type === 'unknown' ? null : destinationFor(c.cfg, res.type, p),
316
+ authority: res.type === 'unknown' ? null : typeDef(res.type).authority,
317
+ requiredSections: res.type === 'unknown' ? [] : typeDef(res.type).sections,
318
+ });
319
+ }
320
+ if (JSONOUT) return emit(rows.length === 1 ? rows[0] : rows) ?? EXIT.OK;
321
+ for (const r of rows) {
322
+ say(`${r.path}`);
323
+ say(` type ${r.type} ${r.declared ? '(declared)' : `(${r.confidence}% confidence${r.needsReview ? ', needs review' : ''})`}`);
324
+ if (r.authority) say(` authority ${r.authority}`);
325
+ if (r.destination && r.destination !== r.path) say(` belongs at ${r.destination}`);
326
+ if (r.signals.length) say(` because ${r.signals.join('; ')}`);
327
+ if (r.needsReview && r.candidates.length > 1) say(` also ${r.candidates.slice(1).map((x) => `${x.type} (${x.score})`).join(', ')}`);
328
+ say('');
329
+ }
330
+ return EXIT.OK;
331
+ }
332
+
333
+ function cmdTag() {
334
+ const c = ctx();
335
+ const apply = flags.apply === true;
336
+ const only = flags.path ? [String(flags.path)] : null;
337
+ const docs = only ? c.docs.filter((d) => only.includes(d.path)) : c.docs;
338
+ const changes = [];
339
+ for (const d of docs) {
340
+ const res = classify(d);
341
+ if (res.type === 'unknown') { changes.push({ path: d.path, action: 'needs-classification' }); continue; }
342
+ const needsMeta = !d.externallyRegistered && (!d.registered || !d.meta.type || !d.meta.visibility);
343
+ const dest = destinationFor(c.cfg, res.type, d.path);
344
+ if (!needsMeta && dest === d.path) continue;
345
+ const entry = { path: d.path, action: needsMeta ? 'annotate' : 'move', type: res.type, destination: dest };
346
+ if (apply && needsMeta) {
347
+ const meta = tpl.frontmatterFor({ type: res.type, id: d.meta.id || d.id, title: d.title, cfg: c.cfg,
348
+ domain: d.domain, visibility: d.meta.visibility, owner: d.owner });
349
+ write(path.join(c.root, d.path), fm.patchDocgov(d.source, meta));
350
+ entry.applied = true;
351
+ }
352
+ changes.push(entry);
353
+ }
354
+ if (apply) {
355
+ const after = ctx();
356
+ reg.save(after.root, reg.build(after.docs, after.inv.contracts).registry);
357
+ graphmod.save(after.root, after.graph);
358
+ }
359
+ if (JSONOUT) return emit({ applied: apply, changes }) ?? EXIT.OK;
360
+ if (!changes.length) { say('Every document is classified, annotated and in the right place.'); return EXIT.OK; }
361
+ say(table(changes.map((x) => ({ Document: x.path, Action: x.action, Type: x.type || '', Destination: x.destination || '' })),
362
+ ['Document', 'Action', 'Type', 'Destination']));
363
+ say('');
364
+ say(apply ? 'Frontmatter applied. Moves are handled by `docgov fix` so they stay transactional.'
365
+ : 'Pass --apply to write frontmatter. Moves go through `docgov review` + `docgov fix`.');
366
+ return EXIT.OK;
367
+ }
368
+
369
+
370
+ function cmdCreate() {
371
+ const c = ctx();
372
+ const type = flags._[0] || flags.type;
373
+ if (!type) throw new DocGovError('usage: docgov create <type> [name] [--domain d] [--path p]\nRun `docgov types` to list document classes.');
374
+ if (!TYPES[type]) {
375
+ const near = Object.keys(TYPES).filter((t) => t.includes(String(type).split('.').pop())).slice(0, 5);
376
+ throw new DocGovError(`unknown type "${type}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Run \`docgov types\`.`);
377
+ }
378
+ const name = flags._[1] || flags.name || typeDef(type).label;
379
+ const domain = flags.domain ? String(flags.domain) : null;
380
+ const def = typeDef(type);
381
+
382
+ let target = flags.path ? String(flags.path) : null;
383
+ if (!target) {
384
+ const loc = cfgmod.locationFor(c.cfg, type);
385
+ target = loc.endsWith('/')
386
+ ? loc + `${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.md`
387
+ : loc;
388
+ }
389
+ if (exists(path.join(c.root, target)) && !flags.force)
390
+ throw new DocGovError(`${target} already exists. Edit it, or pass --force.`);
391
+
392
+ // Wire the new document into the graph automatically: that is the difference
393
+ // between a template and a governed document.
394
+ const relationships = {};
395
+ const related = findmod.find({ docs: c.docs, query: domain || name, limit: 6 });
396
+ const deps = related.filter((r) => (AUTHORITY[r.authority]?.rank ?? 9) < (AUTHORITY[def.authority]?.rank ?? 9))
397
+ .map((r) => r.id).slice(0, 3);
398
+ if (deps.length) relationships.depends_on = deps;
399
+ if (type === 'architecture.trd') {
400
+ const prd = c.docs.find((d) => d.type === 'product.prd' && (!domain || d.domain === domain));
401
+ if (prd) relationships.implements = [prd.id];
402
+ }
403
+ if (flags.implements) relationships.implements = list(flags.implements);
404
+ if (flags.supersedes) relationships.supersedes = list(flags.supersedes);
405
+
406
+ const content = tpl.create({ type, title: String(name), id: flags.id ? String(flags.id) : undefined,
407
+ cfg: c.cfg, domain, relationships, owner: flags.owner ? String(flags.owner) : null,
408
+ visibility: flags.visibility ? String(flags.visibility) : null });
409
+ write(path.join(c.root, target), content);
410
+
411
+ const after = ctx();
412
+ reg.save(after.root, reg.build(after.docs, after.inv.contracts).registry);
413
+ graphmod.save(after.root, after.graph);
414
+
415
+ if (JSONOUT) return emit({ created: target, type, relationships, requiredSections: def.sections,
416
+ softLimit: def.soft, quality: def.quality, related: related.map((r) => ({ id: r.id, path: r.path, authority: r.authority })) }) ?? EXIT.OK;
417
+ say(`Created ${target}`);
418
+ say(` type ${type} (${def.authority})`);
419
+ if (Object.keys(relationships).length) {
420
+ for (const [k, v] of Object.entries(relationships)) say(` ${k.padEnd(11)} ${v.join(', ')}`);
421
+ }
422
+ if (def.sections?.length) say(` sections ${def.sections.length} required: ${def.sections.join(', ')}`);
423
+ if (def.soft) say(` limits ${def.soft} soft / ${def.hard} hard lines`);
424
+ if (related.length) {
425
+ say('');
426
+ say('Authoritative context you should read before writing this:');
427
+ for (const r of related.slice(0, 4)) say(` [${r.label}] ${r.path}`);
428
+ say(` (or run: docgov brief ${domain || name})`);
429
+ }
430
+ return EXIT.OK;
431
+ }
432
+
433
+ function cmdCheck() {
434
+ const c = ctx();
435
+ const changedOnly = flags.changed === true;
436
+ const base = flags.base ? String(flags.base) : 'HEAD';
437
+ let only = null;
438
+ if (changedOnly) {
439
+ const changed = git.changedFiles(c.root, base).map((x) => x.path);
440
+ only = changed.filter((p) => /\.mdx?$/.test(p));
441
+ }
442
+ if (flags.path) only = [String(flags.path)];
443
+
444
+ const { findings } = checkmod.run({ ...c, only });
445
+ const sup = supp.load(c.root, c.cfg);
446
+ const withIds = findings.map((f) => ({ ...f, id: findingId(f) }));
447
+ const split = supp.apply(withIds, sup);
448
+
449
+ let drift = { findings: [] };
450
+ if (c.cfg.drift.enabled && flags.no_drift !== true) {
451
+ drift = driftmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base });
452
+ }
453
+ const driftSplit = supp.apply(drift.findings, sup);
454
+
455
+ const code = checkmod.exitCode({ findings: split.active, driftFindings: driftSplit.active, cfg: c.cfg });
456
+ const stats = checkmod.summarize(split.active);
457
+
458
+ if (JSONOUT) {
459
+ emit({ exitCode: code, stats, findings: split.active, suppressed: split.suppressed.length,
460
+ expiredSuppressions: split.expired, drift: driftSplit.active, unusedSuppressions: split.unused });
461
+ return code;
462
+ }
463
+
464
+ if (!split.active.length && !driftSplit.active.length) {
465
+ say(`✓ ${plural(c.docs.length, 'document')} checked, no findings.`);
466
+ if (split.suppressed.length) say(` (${split.suppressed.length} suppressed)`);
467
+ return code;
468
+ }
469
+ say(`DocGov check — ${plural(c.docs.length, 'document')}, mode ${c.cfg.project.mode}`);
470
+ say('─'.repeat(54));
471
+ const blocking = split.active.filter((f) => f.blocking);
472
+ const advisory = split.active.filter((f) => !f.blocking);
473
+ if (blocking.length) {
474
+ say('');
475
+ say(`BLOCKING (${blocking.length}) — these fail CI`);
476
+ for (const f of blocking) printFinding(f);
477
+ }
478
+ if (advisory.length) {
479
+ say('');
480
+ say(`ADVISORY (${advisory.length}) — reported, not enforced in mode "${c.cfg.project.mode}"`);
481
+ for (const f of advisory.slice(0, flags.all ? advisory.length : 25)) printFinding(f);
482
+ if (!flags.all && advisory.length > 25) say(` … ${advisory.length - 25} more (pass --all)`);
483
+ }
484
+ if (driftSplit.active.length) {
485
+ say('');
486
+ say(`DRIFT (${driftSplit.active.length})`);
487
+ for (const f of driftSplit.active.slice(0, 12)) {
488
+ say(` ${f.severity.toUpperCase().padEnd(8)} ${f.id} ${f.document}`);
489
+ say(` ${f.why}`);
490
+ say(` → ${f.action}`);
491
+ }
492
+ }
493
+ if (split.expired.length) {
494
+ say('');
495
+ say(`EXPIRED SUPPRESSIONS (${split.expired.length}) — no longer suppressing:`);
496
+ for (const f of split.expired) say(` ${f.id} expired ${f.suppression.expires}: ${f.suppression.reason}`);
497
+ }
498
+ say('');
499
+ say(`exit ${code} ${code === 0 ? '(pass)' : code === 1 ? '(deterministic violation)' : '(drift requires review)'}`);
500
+ return code;
501
+ }
502
+
503
+ function printFinding(f) {
504
+ say(` ${f.severity.padEnd(8)} ${f.check.padEnd(21)} ${f.path}`);
505
+ say(` ${f.message}`);
506
+ if (f.fix) say(` fix: ${f.fix}`);
507
+ }
508
+
509
+ function findingId(f) {
510
+ const basis = `${f.check}:${f.path}:${f.other || ''}`;
511
+ let h = 0;
512
+ for (let i = 0; i < basis.length; i++) h = (h * 31 + basis.charCodeAt(i)) >>> 0;
513
+ return `${f.check.toUpperCase().replace(/-/g, '')}-${String(h % 10000).padStart(4, '0')}`;
514
+ }
515
+
516
+ function cmdStale() {
517
+ const c = ctx();
518
+ const base = flags.base ? String(flags.base) : 'HEAD';
519
+ const res = driftmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base });
520
+ const sup = supp.load(c.root, c.cfg);
521
+ const split = supp.apply(res.findings, sup);
522
+ const stale = flags.no_staleness === true ? [] : staleness({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph });
523
+
524
+ if (JSONOUT) {
525
+ emit({ ...res, findings: split.active, suppressed: split.suppressed, staleness: stale,
526
+ packets: driftmod.reviewPackets({ root: c.root, findings: split.active, base }) });
527
+ return split.active.some((f) => ['critical', 'high'].includes(f.severity)) ? EXIT.REVIEW : EXIT.OK;
528
+ }
529
+
530
+ if (!res.usable) { say(res.note); return EXIT.OK; }
531
+ const by = (s) => split.active.filter((f) => f.severity === s).length;
532
+ say('DOCUMENTATION DRIFT REPORT');
533
+ say(`Base: ${base} · ${plural(res.changed.length, 'changed file')}`);
534
+ say(`Critical: ${by('critical')} High: ${by('high')} Medium: ${by('medium')} Low: ${by('low')}`);
535
+ say('');
536
+ for (const f of split.active) {
537
+ say(`${f.severity.toUpperCase()} ${f.id} [${f.kind}]`);
538
+ say(` Document: ${f.document} (${f.documentAuthority})`);
539
+ if (f.implementation?.length) say(` Implementation: ${f.implementation.slice(0, 4).join(', ')}${f.implementationCount > 4 ? ` +${f.implementationCount - 4} more` : ''}`);
540
+ say(` Why: ${f.why}`);
541
+ say(` Action: ${f.action}`);
542
+ if (f.invariants?.length) say(` Invariants: ${f.invariants.join(', ')}`);
543
+ say('');
544
+ }
545
+ if (!split.active.length) say('No drift detected.');
546
+ const hot = stale.filter((s) => s.risk >= (c.cfg.drift.stale_threshold ?? 60));
547
+ if (hot.length) {
548
+ say(`STALENESS RISK (${hot.length} document(s) above ${c.cfg.drift.stale_threshold})`);
549
+ for (const s of hot.slice(0, 10)) {
550
+ say(` ${String(s.risk).padStart(3)}/100 ${s.path}`);
551
+ for (const sig of s.signals.slice(0, 2)) say(` ${sig}`);
552
+ }
553
+ say('');
554
+ }
555
+ if (split.suppressed.length) say(`${split.suppressed.length} finding(s) suppressed.`);
556
+ say('Semantic confirmation (does the prose actually contradict the code?) is the drift-reviewer');
557
+ say('agent\'s job: run /docgov:stale in Claude Code. This report is the deterministic half.');
558
+ return split.active.some((f) => ['critical', 'high'].includes(f.severity)) ? EXIT.REVIEW : EXIT.OK;
559
+ }
560
+
561
+ function cmdAffected() {
562
+ const c = ctx();
563
+ const base = flags.base ? String(flags.base) : 'HEAD';
564
+ const paths = flags.paths ? list(flags.paths) : (flags._.length ? flags._ : null);
565
+ const res = impactmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base, paths });
566
+ if (JSONOUT) return emit({ ...res, checklist: impactmod.checklist(res) }) ?? EXIT.OK;
567
+ say('CHANGE IMPACT');
568
+ say(`Changed: ${res.changed.length} file(s)${res.domains.length ? ` · domains: ${res.domains.join(', ')}` : ''}`);
569
+ say(`Impact level: ${res.level}`);
570
+ say('');
571
+ const sig = res.signals;
572
+ say(`behaviour ${yn(sig.behaviorChanged)} api ${yn(sig.apiChanged)} security ${yn(sig.securityChanged)} tests ${yn(sig.testsChanged)} user-visible ${yn(sig.userVisible)}`);
573
+ say('');
574
+ const req = res.affected.filter((a) => a.required);
575
+ const opt = res.affected.filter((a) => !a.required);
576
+ say(`Required review (${req.length}):`);
577
+ for (const a of req) say(` ${a.updated ? '✓' : '✗'} ${a.path} [${a.authority}] ${a.reasons[0] || ''}`);
578
+ if (!req.length) say(' (none)');
579
+ if (opt.length) {
580
+ say('');
581
+ say(`Optional review (${opt.length}):`);
582
+ for (const a of opt.slice(0, 10)) say(` · ${a.path} ${a.reasons[0] || ''}`);
583
+ }
584
+ return EXIT.OK;
585
+ }
586
+
587
+ function yn(b) { return b ? 'yes' : 'no '; }
588
+
589
+ function cmdChecklist() {
590
+ const c = ctx();
591
+ const base = flags.base ? String(flags.base) : 'HEAD';
592
+ const res = impactmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base });
593
+ const m = impactmod.checklist(res);
594
+ const out = path.join(c.root, '.docgov', 'checklist.yaml');
595
+ if (flags.no_write !== true) write(out, yaml.stringify(m));
596
+ if (flags.pr === true) {
597
+ const drift = driftmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base });
598
+ const report = impactmod.prReport(res, drift, c.cfg);
599
+ if (JSONOUT) return emit({ checklist: m, pr: report }) ?? (report.blocked ? EXIT.REVIEW : EXIT.OK);
600
+ say(report.text);
601
+ return report.blocked ? EXIT.REVIEW : EXIT.OK;
602
+ }
603
+ if (JSONOUT) return emit(m) ?? EXIT.OK;
604
+ say(yaml.stringify(m).trimEnd());
605
+ say('');
606
+ say(`Written to ${path.relative(c.root, out)} — delete it after merge.`);
607
+ return m.docs.outstanding.length ? EXIT.REVIEW : EXIT.OK;
608
+ }
609
+
610
+ function cmdBrief() {
611
+ const c = ctx();
612
+ const topic = flags._[0] || flags.topic;
613
+ if (!topic) throw new DocGovError('usage: docgov brief <topic>');
614
+ const pack = ctxpack.pack({ cfg: c.cfg, docs: c.docs, graph: c.graph, topic: String(topic),
615
+ budget: flags.budget ? parseInt(flags.budget, 10) : undefined,
616
+ include: flags.include ? list(flags.include) : null });
617
+ if (JSONOUT) return emit({ topic, pack }) ?? EXIT.OK;
618
+ console.log(pack); // always stdout: this is what a skill injects
619
+ return EXIT.OK;
620
+ }
621
+
622
+ function cmdFind() {
623
+ const c = ctx();
624
+ const q = flags._.join(' ') || flags.query;
625
+ if (!q) throw new DocGovError('usage: docgov find "<query>"');
626
+ const results = findmod.find({ docs: c.docs, query: q, limit: flags.limit ? parseInt(flags.limit, 10) : 10 });
627
+ if (JSONOUT) return emit(results) ?? EXIT.OK;
628
+ if (!results.length) { say(`No document matches "${q}".`); return EXIT.OK; }
629
+ let i = 0;
630
+ for (const r of results) {
631
+ say(`${++i}. [${r.label}] ${r.title}`);
632
+ say(` ${r.path}${r.domain ? ` · domain: ${r.domain}` : ''}${r.status !== 'active' ? ` · ${r.status}` : ''}`);
633
+ say(` ${r.snippet}`);
634
+ say('');
635
+ }
636
+ say('Ordered by authority first, then relevance: read the top entry before the ones below it.');
637
+ return EXIT.OK;
638
+ }
639
+
640
+ function cmdHealth() {
641
+ const c = ctx();
642
+ const { findings } = checkmod.run(c);
643
+ const stale = staleness({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph });
644
+ const h = healthmod.health({ cfg: c.cfg, docs: c.docs, inv: c.inv, graph: c.graph, registry: c.registry, findings, stale });
645
+ if (JSONOUT) return emit(h) ?? EXIT.OK;
646
+ say(`Documentation Health: ${h.overall}/100`);
647
+ say('');
648
+ for (const [k, v] of Object.entries(h.components)) say(` ${k.padEnd(20)} ${String(v).padStart(3)}`);
649
+ say('');
650
+ say('Issues:');
651
+ for (const [k, v] of Object.entries(h.issues)) if (v) say(` ${v} ${k.replace(/([A-Z])/g, ' $1').toLowerCase().trim()}`);
652
+ if (h.gaps.length) {
653
+ say('');
654
+ say('Documentation the repository implies but does not have:');
655
+ for (const g of h.gaps.slice(0, 8)) say(` ${g.label.padEnd(26)} ${g.because === 'baseline' ? 'baseline' : `${g.because} detected`}`);
656
+ say('');
657
+ say(` create with: docgov create ${h.gaps[0].type}`);
658
+ }
659
+ return EXIT.OK;
660
+ }
661
+
662
+ function cmdPublish() {
663
+ const c = ctx();
664
+ const res = pubmod.analyze({ cfg: c.cfg, docs: c.docs, target: flags.target ? String(flags.target) : undefined });
665
+ if (JSONOUT) return emit({ ...res, briefs: res.rewrite.slice(0, 5).map((r) => ({ path: r.path, brief: pubmod.rewriteBrief(r) })) }) ?? EXIT.OK;
666
+ say('PUBLISHING ANALYSIS');
667
+ say('');
668
+ say(`Publishable as-is (${res.publishable.length}):`);
669
+ for (const p of res.publishable) say(` ✓ ${p.path}${p.softFlags ? ` (${p.softFlags} soft flag(s) to review)` : ''}`);
670
+ if (!res.publishable.length) say(' (none)');
671
+ say('');
672
+ say(`Blocked (${res.blocked.length}):`);
673
+ for (const b of res.blocked) {
674
+ say(` ✗ ${b.path} — ${b.reason}`);
675
+ for (const l of (b.leaks || []).slice(0, 3)) say(` line ${l.line}: ${l.what} (${l.sample})`);
676
+ }
677
+ if (!res.blocked.length) say(' (none)');
678
+ say('');
679
+ say(`Needs an external-lens rewrite (${res.rewrite.length}) — a public document is a different artifact, not a copy:`);
680
+ for (const r of res.rewrite.slice(0, 10)) say(` · ${r.path} (${r.currentVisibility})${r.leaks.length ? ` ${r.leaks.length} pattern(s) to remove` : ''}`);
681
+ say('');
682
+ say(res.gate);
683
+ return EXIT.OK;
684
+ }
685
+
686
+ function cmdRegistry() {
687
+ const c = ctx();
688
+ if (flags.rebuild === true) {
689
+ reg.save(c.root, c.registry);
690
+ graphmod.save(c.root, c.graph);
691
+ if (JSONOUT) return emit({ rebuilt: true, documents: Object.keys(c.registry.documents).length, collisions: c.collisions }) ?? EXIT.OK;
692
+ say(`Registry rebuilt: ${Object.keys(c.registry.documents).length} document(s).`);
693
+ if (c.collisions.length) {
694
+ say('');
695
+ say('Id collisions (these must be fixed — an id is how agents address a document):');
696
+ for (const col of c.collisions) say(` ${col.id}: ${col.paths.join(' and ')}`);
697
+ return EXIT.VIOLATION;
698
+ }
699
+ return EXIT.OK;
700
+ }
701
+ if (JSONOUT) return emit(c.registry) ?? EXIT.OK;
702
+ say(table(Object.entries(c.registry.documents).map(([id, e]) => ({
703
+ Id: id, Authority: e.authority, Visibility: e.visibility, Path: e.path,
704
+ })), ['Id', 'Authority', 'Visibility', 'Path']));
705
+ return EXIT.OK;
706
+ }
707
+
708
+ function cmdGraph() {
709
+ const c = ctx();
710
+ if (flags.save === true) graphmod.save(c.root, c.graph);
711
+ const j = c.graph.toJSON();
712
+ if (JSONOUT) return emit(j) ?? EXIT.OK;
713
+ if (flags.dot === true) {
714
+ say('digraph docgov {');
715
+ say(' rankdir=TB; node [shape=box, fontname="Helvetica"];');
716
+ for (const n of j.nodes) say(` "${n.id}" [label="${n.id}\\n${n.authority}"];`);
717
+ for (const e of j.edges) if (!e.inferred) say(` "${e.from}" -> "${e.to}" [label="${e.rel}"];`);
718
+ say('}');
719
+ return EXIT.OK;
720
+ }
721
+ say(`${j.nodes.length} node(s), ${j.edges.length} edge(s) (${j.edges.filter((e) => e.inferred).length} inferred)`);
722
+ say('');
723
+ const tiers = new Map();
724
+ for (const n of j.nodes) {
725
+ const t = AUTHORITY[n.authority]?.tier ?? 9;
726
+ if (!tiers.has(t)) tiers.set(t, []);
727
+ tiers.get(t).push(n);
728
+ }
729
+ for (const t of [...tiers.keys()].sort()) {
730
+ say(`Tier ${t} — ${AUTHORITY[tiers.get(t)[0].authority]?.label || ''}`);
731
+ for (const n of tiers.get(t)) {
732
+ const outs = c.graph.out(n.id).filter((e) => !e.inferred);
733
+ say(` ${n.id} ${outs.length ? `→ ${outs.map((e) => `${e.rel}:${e.to}`).join(', ')}` : ''}`);
734
+ }
735
+ say('');
736
+ }
737
+ const orphans = c.graph.orphans();
738
+ if (orphans.length) say(`${orphans.length} orphan(s): ${orphans.slice(0, 6).map((o) => o.id).join(', ')}`);
739
+ return EXIT.OK;
740
+ }
741
+
742
+ function cmdRules() {
743
+ const c = ctx();
744
+ const set = invariantsmod.collect(c.docs, c.cfg);
745
+ if (flags.for) {
746
+ const applicable = invariantsmod.applicable(set, list(flags.for), c.cfg);
747
+ if (JSONOUT) return emit(applicable) ?? EXIT.OK;
748
+ const rendered = invariantsmod.render(applicable);
749
+ if (rendered) say(rendered); else say('No rules apply to those paths.');
750
+ return EXIT.OK;
751
+ }
752
+ if (JSONOUT) return emit(set) ?? EXIT.OK;
753
+ if (!set.invariants.length) {
754
+ say('No rules declared.');
755
+ say('');
756
+ say('Declare them in any canonical document as list items beginning with an id:');
757
+ say(' - INV-LIC-001 A license belongs to exactly one organization.');
758
+ say('Then map the code they govern with `documents:` in that document\'s frontmatter,');
759
+ say('and every agent editing that code gets them injected automatically.');
760
+ return EXIT.OK;
761
+ }
762
+ say(table(set.invariants.map((i) => ({
763
+ Id: i.id, Domain: i.domain || '', Statement: i.statement.slice(0, 58), Source: i.source,
764
+ })), ['Id', 'Domain', 'Statement', 'Source']));
765
+ if (set.duplicates.length) {
766
+ say('');
767
+ say('Duplicate rule ids:');
768
+ for (const d of set.duplicates) say(` ${d.id}: ${d.sources.join(' and ')}`);
769
+ return EXIT.VIOLATION;
770
+ }
771
+ return EXIT.OK;
772
+ }
773
+
774
+ function cmdTypes() {
775
+ const rows = tpl.listTypes();
776
+ if (JSONOUT) return emit(rows) ?? EXIT.OK;
777
+ const filter = flags._[0];
778
+ const shown = filter ? rows.filter((r) => r.type.includes(filter) || r.authority.includes(filter)) : rows;
779
+ say(table(shown.map((r) => ({
780
+ Type: r.type, Label: r.label, Authority: r.authority, Lens: r.lens,
781
+ Soft: r.soft, Hard: r.hard, Sections: r.sections,
782
+ })), ['Type', 'Label', 'Authority', 'Lens', 'Soft', 'Hard', 'Sections']));
783
+ say('');
784
+ say(`${shown.length} document class(es). Create one with: docgov create <type> "<name>"`);
785
+ return EXIT.OK;
786
+ }
787
+
788
+ function cmdTools() {
789
+ const { root } = cfgmod.load(process.cwd());
790
+ const plugins = list(flags.plugins);
791
+ const caps = inv.capabilities(root, plugins);
792
+ const outPath = path.join(root, '.docgov', 'tools.json');
793
+ write(outPath, JSON.stringify({ generated: new Date().toISOString(), capabilities: caps }, null, 2) + '\n');
794
+ if (JSONOUT) return emit({ capabilities: caps, written: '.docgov/tools.json' }) ?? EXIT.OK;
795
+ say('Capability registry');
796
+ say('');
797
+ for (const [cap, providers] of Object.entries(caps).sort()) say(` ${cap.padEnd(24)} ${providers.join(', ')}`);
798
+ say('');
799
+ say('DocGov delegates to these instead of reimplementing them. Absent capability = DocGov does it itself.');
800
+ return EXIT.OK;
801
+ }
802
+
803
+ function cmdIgnore() {
804
+ const c = ctx();
805
+ if (flags.list === true || flags._[0] === 'list') {
806
+ const data = supp.load(c.root, c.cfg);
807
+ if (JSONOUT) return emit(data) ?? EXIT.OK;
808
+ if (!data.suppressions.length) { say('No suppressions.'); return EXIT.OK; }
809
+ say(table(data.suppressions.map((s) => ({ Id: s.id, Created: s.created, Expires: s.expires || 'never', Reason: s.reason })),
810
+ ['Id', 'Created', 'Expires', 'Reason']));
811
+ return EXIT.OK;
812
+ }
813
+ if (flags.remove) {
814
+ const ok = supp.remove(c.root, c.cfg, String(flags.remove));
815
+ say(ok ? `Removed suppression ${flags.remove}.` : `No suppression with id ${flags.remove}.`);
816
+ return ok ? EXIT.OK : EXIT.CONFIG;
817
+ }
818
+ const id = flags._[0] || flags.id;
819
+ if (!id) throw new DocGovError('usage: docgov ignore <FINDING-ID> --reason "why" [--expires YYYY-MM-DD]');
820
+ const entry = supp.add(c.root, c.cfg, { id: String(id), reason: flags.reason, expires: flags.expires ? String(flags.expires) : null, by: flags.by ? String(flags.by) : null });
821
+ if (JSONOUT) return emit(entry) ?? EXIT.OK;
822
+ say(`Suppressed ${entry.id}${entry.expires ? ` until ${entry.expires}` : ''}: ${entry.reason}`);
823
+ say('It stays visible in `docgov ignore --list` and in every report. Nothing is silently ignored.');
824
+ return EXIT.OK;
825
+ }
826
+
827
+ function cmdInspect() {
828
+ const c = ctx();
829
+ const base = flags.base ? String(flags.base) : 'HEAD';
830
+ const what = flags._[0] || 'stale';
831
+ if (what === 'contradictions') {
832
+ const planPath = path.join(c.root, onboardmod.PLAN_DATA_PATH);
833
+ const pairs = exists(planPath)
834
+ ? JSON.parse(read(planPath)).contradictionCandidates
835
+ : onboardmod.plan(c).contradictionCandidates;
836
+ const packets = pairs.slice(0, parseInt(flags.limit || '10', 10)).map((p) => ({
837
+ pair: [p.a, p.b], score: p.score, note: p.note,
838
+ a: excerpt(c.docs.find((d) => d.path === p.a)), b: excerpt(c.docs.find((d) => d.path === p.b)),
839
+ }));
840
+ emitOrPrint({ kind: 'contradictions', packets });
841
+ return EXIT.OK;
842
+ }
843
+ if (what === 'quality') {
844
+ const { findings } = checkmod.run(c);
845
+ const targets = flags.path ? c.docs.filter((d) => d.path === flags.path) : c.docs;
846
+ const packets = targets.map((d) => ({
847
+ ...healthmod.qualityFloor({ cfg: c.cfg, doc: d, findings }),
848
+ lens: d.lens, agentReadiness: ctxpack.agentReadiness(d), excerpt: excerpt(d),
849
+ }));
850
+ emitOrPrint({ kind: 'quality', packets });
851
+ return EXIT.OK;
852
+ }
853
+ const drift = driftmod.analyze({ root: c.root, cfg: c.cfg, docs: c.docs, graph: c.graph, base });
854
+ const packets = driftmod.reviewPackets({ root: c.root, findings: drift.findings, base,
855
+ limit: parseInt(flags.limit || '12', 10) });
856
+ for (const p of packets) {
857
+ const doc = c.docs.find((d) => d.path === p.document);
858
+ p.documentExcerpt = excerpt(doc);
859
+ }
860
+ emitOrPrint({ kind: 'stale', packets });
861
+ return EXIT.OK;
862
+ }
863
+
864
+ function emitOrPrint(obj) {
865
+ if (JSONOUT) return emit(obj);
866
+ console.log(JSON.stringify(obj, null, 2));
867
+ }
868
+
869
+ function excerpt(doc, chars = 2500) {
870
+ if (!doc) return null;
871
+ const body = doc.body.trim();
872
+ return { path: doc.path, type: doc.type, authority: doc.authority,
873
+ text: body.length <= chars ? body : `${body.slice(0, chars)}\n… (${body.length - chars} more characters)` };
874
+ }
875
+
876
+ // ───────────────────────────── hook protocol ─────────────────────────────
877
+
878
+ /**
879
+ * `docgov hook <event>` reads the Claude Code hook JSON on stdin and writes hook
880
+ * JSON on stdout. This is ring 1 from FEASIBILITY §3.1: pure CLI, no model call,
881
+ * budgeted at well under 100 ms so it can run on every single write.
882
+ */
883
+ function cmdHook() {
884
+ const event = flags._[0] || 'pre-write';
885
+ const input = readStdinJSON();
886
+ const handlers = {
887
+ 'pre-tool': hookPreTool,
888
+ 'pre-write': hookPreWrite,
889
+ 'post-write': hookPostWrite,
890
+ 'session-start': hookSessionStart,
891
+ 'pre-code-edit': hookPreCodeEdit,
892
+ 'stop': hookStop,
893
+ };
894
+ const h = handlers[event];
895
+ if (!h) { process.stderr.write(`docgov hook: unknown event "${event}"\n`); return EXIT.OK; }
896
+ try { return h(input); }
897
+ catch (e) {
898
+ // A governance hook must never break the user's session. Fail open, say why.
899
+ process.stderr.write(`docgov hook ${event}: ${e.message}\n`);
900
+ return EXIT.OK;
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Single PreToolUse entry point. Routes on the target path so one node process
906
+ * serves both the documentation gate and the invariant injection — two hooks on
907
+ * the same matcher would double the per-edit latency for no benefit.
908
+ */
909
+ function hookPreTool(input) {
910
+ const abs = targetPathOf(input);
911
+ if (!abs) return EXIT.OK;
912
+ return /\.mdx?$/.test(abs) ? hookPreWrite(input) : hookPreCodeEdit(input);
913
+ }
914
+
915
+ function readStdinJSON() {
916
+ let raw = '';
917
+ try { raw = fs.readFileSync(0, 'utf8'); } catch { return {}; }
918
+ try { return JSON.parse(raw || '{}'); }
919
+ catch (e) {
920
+ // Fail open — a governance hook must never break a session — but never silently:
921
+ // a malformed payload that produces no output is indistinguishable from "all clear".
922
+ process.stderr.write(`docgov hook: could not parse hook input (${e.message}); doing nothing\n`);
923
+ return {};
924
+ }
925
+ }
926
+
927
+ function hookOut(event, fields) {
928
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...fields } }));
929
+ }
930
+
931
+ function targetPathOf(input) {
932
+ const ti = input.tool_input || {};
933
+ return ti.file_path || ti.path || ti.notebook_path || null;
934
+ }
935
+
936
+ function hookPreWrite(input) {
937
+ const abs = targetPathOf(input);
938
+ if (!abs) return EXIT.OK;
939
+ const { root, cfg, initialized } = cfgmod.load(input.cwd || process.cwd());
940
+ if (!initialized) return EXIT.OK;
941
+ const rel = toPosix(path.relative(root, abs));
942
+ if (rel.startsWith('..')) return EXIT.OK;
943
+ if (!/\.mdx?$/.test(rel) && !/\.docgov\//.test(rel)) return EXIT.OK;
944
+
945
+ const registry = reg.load(root);
946
+ const isNew = !exists(abs);
947
+ const content = (input.tool_input || {}).content ?? null;
948
+ const reasons = checkmod.preWrite({ cfg, relPath: rel, registry, isNew, content });
949
+
950
+ const blocking = reasons.filter((r) => r.blocking);
951
+ if (blocking.length) {
952
+ hookOut('PreToolUse', {
953
+ permissionDecision: 'deny',
954
+ permissionDecisionReason: [
955
+ `DocGov blocked this write to ${rel}:`,
956
+ ...blocking.map((r) => ` ${r.check}: ${r.message}${r.fix ? `\n → ${r.fix}` : ''}`),
957
+ '',
958
+ 'This is a deterministic rule, not a judgement call. Fix the cause or run',
959
+ '`docgov ignore <id> --reason "..."` if the rule is wrong here.',
960
+ ].join('\n'),
961
+ });
962
+ return EXIT.OK;
963
+ }
964
+
965
+ const advisory = reasons.filter((r) => !r.blocking);
966
+ const guidance = [];
967
+ if (isNew && /\.mdx?$/.test(rel)) {
968
+ const doc = { path: rel, body: content || '', frontmatter: {} };
969
+ const c = classify(doc);
970
+ if (c.type !== 'unknown') {
971
+ const def = typeDef(c.type);
972
+ const dest = destinationFor(cfg, c.type, rel);
973
+ guidance.push(`DocGov: this looks like a ${def.label} (${c.confidence}% confidence).`);
974
+ if (dest !== rel) guidance.push(` Canonical location: ${dest}`);
975
+ if (def.sections?.length) guidance.push(` Required sections: ${def.sections.join(', ')}`);
976
+ if (def.soft) guidance.push(` Soft limit ${def.soft} lines, hard ${def.hard}.`);
977
+ guidance.push(` Add frontmatter: docgov: { id, type: ${c.type}, authority: ${def.authority}, visibility: ${def.visibility} }`);
978
+ const existing = Object.entries(registry.documents).filter(([, e]) => e.type === c.type);
979
+ if (existing.length && def.singleton) guidance.push(` ⚠ a ${def.label} already exists at ${existing[0][1].path} — update it instead of creating a second one`);
980
+ } else {
981
+ guidance.push(`DocGov: could not classify ${rel}. Run \`docgov whatis --path ${rel}\` or declare docgov.type explicitly.`);
982
+ }
983
+ }
984
+ for (const a of advisory) guidance.push(`DocGov (advisory) ${a.check}: ${a.message}${a.fix ? ` — ${a.fix}` : ''}`);
985
+
986
+ if (guidance.length) hookOut('PreToolUse', { additionalContext: guidance.join('\n') });
987
+ return EXIT.OK;
988
+ }
989
+
990
+ function hookPostWrite(input) {
991
+ const abs = targetPathOf(input);
992
+ if (!abs) return EXIT.OK;
993
+ const { root, cfg, initialized } = cfgmod.load(input.cwd || process.cwd());
994
+ if (!initialized || !exists(abs)) return EXIT.OK;
995
+ const rel = toPosix(path.relative(root, abs));
996
+ if (rel.startsWith('..') || !/\.mdx?$/.test(rel)) return EXIT.OK;
997
+
998
+ const i = inv.inventory(root, cfg);
999
+ const { registry } = reg.build(i.documents, i.contracts);
1000
+ const graph = graphmod.build(i.documents, registry, cfg, i.contracts);
1001
+ reg.save(root, registry);
1002
+ graphmod.save(root, graph);
1003
+
1004
+ const { findings } = checkmod.run({ root, cfg, docs: i.documents, registry, graph, inv: i, only: [rel] });
1005
+ const mine = findings.filter((f) => f.path === rel);
1006
+ if (!mine.length) return EXIT.OK;
1007
+
1008
+ const node = [...graph.nodes.values()].find((n) => n.path === rel);
1009
+ const dependents = node ? graph.in(node.id, 'depended_on_by').concat(graph.in(node.id, 'depends_on')) : [];
1010
+
1011
+ const lines = [`DocGov checked ${rel}:`];
1012
+ for (const f of mine.slice(0, 8)) lines.push(` ${f.severity} ${f.check}: ${f.message}${f.fix ? ` (${f.fix})` : ''}`);
1013
+ if (dependents.length) {
1014
+ lines.push(` ${dependents.length} document(s) declare a dependency on this one — check they still agree:`);
1015
+ for (const d of dependents.slice(0, 4)) lines.push(` ${graph.nodes.get(d.from)?.path || d.from}`);
1016
+ }
1017
+ hookOut('PostToolUse', { additionalContext: lines.join('\n') });
1018
+ return EXIT.OK;
1019
+ }
1020
+
1021
+ function hookSessionStart(input) {
1022
+ const { root, cfg, initialized } = cfgmod.load(input.cwd || process.cwd());
1023
+ if (!initialized) return EXIT.OK;
1024
+ const registry = reg.load(root);
1025
+ const ids = Object.entries(registry.documents);
1026
+ const canonical = ids.filter(([, e]) => e.authority === 'canonical' || e.authority === 'constitution');
1027
+
1028
+ const L = [];
1029
+ L.push(`DocGov is active in this repository (mode: ${cfg.project.mode}, layout: ${cfg.project.layout}).`);
1030
+ L.push(`${ids.length} governed document(s). Before creating documentation, run \`docgov whatis --path <file>\`;`);
1031
+ L.push('before editing code in a governed domain, run `docgov brief <domain>`.');
1032
+ if (canonical.length) {
1033
+ L.push('');
1034
+ L.push('Authoritative documents (nothing may contradict these):');
1035
+ for (const [id, e] of canonical.slice(0, 12)) L.push(` ${id.padEnd(28)} ${e.path}`);
1036
+ }
1037
+ const domains = Object.keys(cfg.domains || {});
1038
+ if (domains.length) L.push(`\nGoverned domains: ${domains.join(', ')}`);
1039
+ L.push('');
1040
+ L.push('Rules: .claude/rules/documentation.md');
1041
+ hookOut('SessionStart', { additionalContext: L.join('\n') });
1042
+ return EXIT.OK;
1043
+ }
1044
+
1045
+ /**
1046
+ * The cheapest high-value hook in the product: before an agent edits governed
1047
+ * code, hand it the invariants that constrain that code (PRD §24).
1048
+ */
1049
+ function hookPreCodeEdit(input) {
1050
+ const abs = targetPathOf(input);
1051
+ if (!abs) return EXIT.OK;
1052
+ const { root, cfg, initialized } = cfgmod.load(input.cwd || process.cwd());
1053
+ if (!initialized) return EXIT.OK;
1054
+ const rel = toPosix(path.relative(root, abs));
1055
+ if (rel.startsWith('..') || /\.mdx?$/.test(rel)) return EXIT.OK;
1056
+
1057
+ const i = inv.inventory(root, cfg);
1058
+ const set = invariantsmod.collect(i.documents, cfg);
1059
+ const applicable = invariantsmod.applicable(set, [rel], cfg);
1060
+
1061
+ const { registry } = reg.build(i.documents, i.contracts);
1062
+ const graph = graphmod.build(i.documents, registry, cfg, i.contracts);
1063
+ const docsClaiming = graphmod.codeNodesFor(graph, rel)
1064
+ .flatMap((n) => graph.in(n.id, 'documents').map((e) => graph.nodes.get(e.from)))
1065
+ .filter(Boolean);
1066
+
1067
+ if (!applicable.length && !docsClaiming.length) return EXIT.OK;
1068
+ const L = [];
1069
+ if (applicable.length) L.push(invariantsmod.render(applicable));
1070
+ if (docsClaiming.length) {
1071
+ L.push('');
1072
+ L.push('Documents that describe this code and may need updating in the same change:');
1073
+ for (const d of docsClaiming) L.push(` ${d.path} [${d.authority}]`);
1074
+ }
1075
+ hookOut('PreToolUse', { additionalContext: L.join('\n') });
1076
+ return EXIT.OK;
1077
+ }
1078
+
1079
+ function hookStop(input) {
1080
+ const { root, cfg, initialized } = cfgmod.load(input.cwd || process.cwd());
1081
+ if (!initialized || !git.isRepo(root)) return EXIT.OK;
1082
+ const i = inv.inventory(root, cfg);
1083
+ const { registry } = reg.build(i.documents, i.contracts);
1084
+ const graph = graphmod.build(i.documents, registry, cfg, i.contracts);
1085
+ const impact = impactmod.analyze({ root, cfg, docs: i.documents, graph, base: 'HEAD' });
1086
+ const m = impactmod.checklist(impact);
1087
+ if (!m.docs.outstanding.length) return EXIT.OK;
1088
+ hookOut('Stop', {
1089
+ additionalContext: [
1090
+ 'DocGov: this change has outstanding documentation obligations.',
1091
+ ...m.docs.outstanding.map((id) => ` ${id} — ${registry.documents[id]?.path || '(unregistered)'}`),
1092
+ 'Either update them, or say explicitly why they do not need updating.',
1093
+ 'Full picture: docgov affected',
1094
+ ].join('\n'),
1095
+ });
1096
+ return EXIT.OK;
1097
+ }
1098
+
1099
+ // ───────────────────────────── misc ─────────────────────────────
1100
+
1101
+ function inferMode(root, i) {
1102
+ if (exists(path.join(root, 'LICENSE')) || exists(path.join(root, 'LICENSE.md'))) {
1103
+ if (exists(path.join(root, 'CONTRIBUTING.md')) || exists(path.join(root, '.github'))) return 'open-source';
1104
+ }
1105
+ if (exists(path.join(root, 'CODEOWNERS')) || exists(path.join(root, '.github', 'CODEOWNERS'))) return 'team';
1106
+ if (i.documents.length > 40 || i.stack.some((s) => s.id === 'kubernetes' || s.id === 'terraform')) return 'team';
1107
+ return 'solo';
1108
+ }
1109
+
1110
+ function projectName(root) {
1111
+ const pkg = path.join(root, 'package.json');
1112
+ if (exists(pkg)) { try { return JSON.parse(read(pkg)).name || path.basename(root); } catch { /* fall through */ } }
1113
+ return path.basename(root);
1114
+ }
1115
+
1116
+ function packageVersion() {
1117
+ try { return JSON.parse(read(new URL('../package.json', import.meta.url))).version; } catch { return '0.0.0'; }
1118
+ }
1119
+
1120
+ function agentRules(cfg) {
1121
+ return read(new URL('../policy/documentation.md', import.meta.url))
1122
+ .replace(/\{\{MODE\}\}/g, cfg.project.mode)
1123
+ .replace(/\{\{LAYOUT\}\}/g, cfg.project.layout)
1124
+ .replace(/\{\{BLOCKING\}\}/g, (cfg.governance.enforce || []).join(', '));
1125
+ }
1126
+
1127
+ function usage() {
1128
+ console.log(`docgov ${packageVersion()} — your AI writes docs faster than anyone can check them. DocGov checks them.
1129
+
1130
+ Start here
1131
+ setup [--mode M] [--layout full|compact] turn DocGov on in this repo
1132
+ review look at the docs you already have, write a fix plan
1133
+ fix [--dry-run] [--include split,..] run that plan on a branch; reverts itself if it breaks
1134
+
1135
+ Every day
1136
+ create <type> "<name>" [--domain D] new doc: right place, right template, already wired up
1137
+ check [--changed] [--base REF] [--all] the checks that can block you; exit 1 blocks, 2 needs a look
1138
+ affected [--base REF] [paths...] which docs your change affects
1139
+ checklist [--pr] those docs as a checklist; --pr prints the PR comment
1140
+ find "<query>" search your docs, best source first
1141
+ whatis [--path P] what is this document, and where does it belong
1142
+
1143
+ When something is off
1144
+ stale [--base REF] docs the code moved out from under
1145
+ health score your docs out of 100, and what is missing
1146
+ inspect <stale|contradictions|quality> build a review packet for an agent to read
1147
+ tag [--apply] [--path P] add missing frontmatter
1148
+ rules [--for paths] the rules your docs declare, and the code they govern
1149
+ ignore <ID> --reason "..." [--expires D] record a deliberate exception
1150
+ publish [--target DIR] what is safe to publish, and what would leak
1151
+
1152
+ Plumbing
1153
+ types [filter] registry [--rebuild] graph [--dot] [--save] tools
1154
+ hook <pre-tool|pre-write|post-write|session-start|pre-code-edit|stop>
1155
+ version help
1156
+
1157
+ Every command takes --json.
1158
+ Exit codes: 0 fine · 1 blocked · 2 needs a look · 3 config error.`);
1159
+ return EXIT.OK;
1160
+ }