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
@@ -0,0 +1,159 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import * as yaml from './yaml.js';
4
+ import { TYPES, typeDef } from './taxonomy.js';
5
+ import { read, exists, titleCase, slug } from './util.js';
6
+
7
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
8
+ export const TEMPLATE_DIR = path.join(HERE, '..', 'templates');
9
+
10
+ /**
11
+ * Templates (PRD §12).
12
+ *
13
+ * 52 document classes do not need 52 hand-written files. A class's required
14
+ * sections already describe its shape, so templates are synthesized from the
15
+ * taxonomy and only the classes whose value is in their *prose scaffolding*
16
+ * (PRD, TRD, ADR, threat model, runbook, README) ship a hand-authored file.
17
+ */
18
+
19
+ /** Per-section prompts used when synthesizing. Keyed by lowercased section title. */
20
+ const SECTION_HINTS = {
21
+ context: 'What situation makes this necessary? Link the upstream document.',
22
+ problem: 'State the problem in terms the reader can verify, not in terms of the solution.',
23
+ goals: 'Outcomes, each one observable.',
24
+ 'non-goals': 'What this explicitly does not do, so nobody has to guess.',
25
+ scope: 'What is inside and outside this document\'s authority.',
26
+ purpose: 'One paragraph: why this exists.',
27
+ boundaries: 'What this owns, and what it explicitly delegates.',
28
+ invariants: 'Rules that must hold at all times. Give each an ID like `INV-<DOMAIN>-001`.',
29
+ interfaces: 'Inputs and outputs. Point at the machine contract rather than restating it.',
30
+ 'failure modes': 'What goes wrong, how it is detected, what happens next.',
31
+ dependencies: 'What this relies on, and what breaks if each one is unavailable.',
32
+ 'acceptance criteria': 'Verifiable conditions. If a reviewer cannot check it, rewrite it.',
33
+ 'open questions': 'Unresolved decisions, each with an owner.',
34
+ assets: 'What is worth protecting, and why.',
35
+ actors: 'Who interacts with this, including the ones you would rather not have.',
36
+ 'trust boundaries': 'Where trust changes. Name what crosses each boundary.',
37
+ threats: 'Each threat, its affected asset, and its likelihood.',
38
+ controls: 'What stops each threat. Point at the implementation.',
39
+ 'residual risks': 'What remains after the controls, and who accepted it.',
40
+ assumptions: 'What must be true for this document to remain correct.',
41
+ trigger: 'The exact signal that starts this procedure.',
42
+ preconditions: 'What must be true before you begin.',
43
+ diagnostics: 'Commands and queries that tell you what is actually happening.',
44
+ procedure: 'Numbered steps. Each step has one action and one expected result.',
45
+ validation: 'How you know it worked.',
46
+ rollback: 'How to undo this, and the point past which you cannot.',
47
+ escalation: 'Who to wake, and when.',
48
+ install: 'The shortest path from nothing to installed.',
49
+ usage: 'The smallest useful example, runnable as written.',
50
+ steps: 'Numbered, each with its expected result.',
51
+ security: 'Trust boundaries, secrets, authorization. Link the threat model.',
52
+ performance: 'Targets, measured not guessed.',
53
+ observability: 'Signals, dashboards, alerts.',
54
+ migration: 'How existing data and clients move across.',
55
+ rollout: 'Order, gates, and the blast radius at each stage.',
56
+ testing: 'What is covered, at which level, and what is deliberately not.',
57
+ status: 'proposed | accepted | superseded',
58
+ decision: 'The decision, in one sentence, in the active voice.',
59
+ alternatives: 'What else was considered and why it lost.',
60
+ consequences: 'What this makes easier, and what it makes harder.',
61
+ };
62
+
63
+ /** @returns {string|null} hand-authored template body, if one exists */
64
+ export function override(type) {
65
+ const f = path.join(TEMPLATE_DIR, `${type}.md`);
66
+ return exists(f) ? read(f) : null;
67
+ }
68
+
69
+ /**
70
+ * @param {{type:string, title?:string, id?:string, cfg:object, domain?:string|null,
71
+ * relationships?:object, owner?:string|null, visibility?:string|null}} args
72
+ */
73
+ export function create({ type, title, id, cfg, domain = null, relationships = null, owner = null, visibility = null }) {
74
+ const def = typeDef(type);
75
+ if (!TYPES[type]) throw new Error(`unknown document type: ${type}`);
76
+ const docId = id || slug(title || def.label);
77
+ const meta = {
78
+ id: docId,
79
+ type,
80
+ authority: def.authority,
81
+ audience: audienceFor(def),
82
+ visibility: visibility || def.visibility || 'internal',
83
+ status: 'draft',
84
+ };
85
+ if (domain) meta.domain = domain;
86
+ if (owner || cfg.profile?.require_owner) meta.owner = owner || 'unassigned';
87
+ if (relationships && Object.keys(relationships).length) meta.relationships = relationships;
88
+ if (def.generated) meta.generation = { mode: 'generated' };
89
+ else meta.generation = { mode: 'human-maintained' };
90
+ if (def.authority === 'canonical' || def.authority === 'constitution') meta.review = { cadence: '90d' };
91
+
92
+ const front = `---\n${yaml.stringify({ docgov: meta })}---\n`;
93
+ const body = override(type) ?? synthesize(type, title || def.label);
94
+ return front + body;
95
+ }
96
+
97
+ export function synthesize(type, title) {
98
+ const def = typeDef(type);
99
+ const L = [`# ${title}`, ''];
100
+ L.push(`> ${def.label}. ${guidanceFor(def)}`);
101
+ L.push('');
102
+ const sections = def.sections || [];
103
+ if (sections.length === 0) {
104
+ L.push('<!-- No required sections for this document class. Keep it focused on one concept. -->');
105
+ L.push('');
106
+ return L.join('\n');
107
+ }
108
+ for (const s of sections) {
109
+ L.push(`## ${s}`);
110
+ L.push('');
111
+ const hint = SECTION_HINTS[s.toLowerCase()];
112
+ L.push(`<!-- ${hint || `${s}.`} -->`);
113
+ L.push('');
114
+ }
115
+ return L.join('\n');
116
+ }
117
+
118
+ function guidanceFor(def) {
119
+ const limit = def.hard ? `Keep it under ${def.soft} lines; ${def.hard} triggers review.` : '';
120
+ const auth = {
121
+ constitution: 'Changes here should be rare and deliberate — everything else defers to this.',
122
+ canonical: 'This is the authoritative description. Lower-authority documents may not contradict it.',
123
+ requirements: 'This explains what is being built and why, not how the code happens to work today.',
124
+ decision: 'Record the decision and the alternatives. Do not rewrite history; supersede instead.',
125
+ generated: 'Generated — do not edit by hand. Change the source and regenerate.',
126
+ audience: 'Written for its reader, not for the architecture. Link depth rather than embedding it.',
127
+ historical: 'Historical record. Correct only factual errors.',
128
+ 'machine-contract': 'The contract is authoritative; prose describing it is not.',
129
+ }[def.authority] || '';
130
+ return [auth, limit].filter(Boolean).join(' ');
131
+ }
132
+
133
+ function audienceFor(def) {
134
+ return {
135
+ readme: ['everyone'], developer: ['engineering'], architecture: ['engineering', 'architecture'],
136
+ security: ['engineering', 'security'], user: ['users'], operations: ['operations'], agent: ['agents'],
137
+ }[def.lens] || ['engineering'];
138
+ }
139
+
140
+ /** Frontmatter block for an existing document that has none (used by `organize`). */
141
+ export function frontmatterFor({ type, id, title, cfg, domain = null, visibility = null, owner = null }) {
142
+ const def = typeDef(type);
143
+ const meta = {
144
+ id, type, authority: def.authority, audience: audienceFor(def),
145
+ visibility: visibility || def.visibility || 'internal', status: 'active',
146
+ };
147
+ if (domain) meta.domain = domain;
148
+ if (cfg.profile?.require_owner) meta.owner = owner || 'unassigned';
149
+ meta.generation = { mode: def.generated ? 'generated' : 'human-maintained' };
150
+ return meta;
151
+ }
152
+
153
+ export function listTypes() {
154
+ return Object.entries(TYPES).map(([id, t]) => ({
155
+ type: id, label: t.label, authority: t.authority, lens: t.lens,
156
+ soft: t.soft || '', hard: t.hard || '', sections: (t.sections || []).length,
157
+ handwritten: exists(path.join(TEMPLATE_DIR, `${id}.md`)) ? 'yes' : '',
158
+ }));
159
+ }
package/core/util.js ADDED
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export const EXIT = { OK: 0, VIOLATION: 1, REVIEW: 2, CONFIG: 3 };
6
+
7
+ export class DocGovError extends Error {
8
+ constructor(msg, code = EXIT.CONFIG) { super(msg); this.name = 'DocGovError'; this.code = code; }
9
+ }
10
+
11
+ export function read(p) { return fs.readFileSync(p, 'utf8'); }
12
+ export function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
13
+
14
+ export function write(p, content) {
15
+ fs.mkdirSync(path.dirname(p), { recursive: true });
16
+ fs.writeFileSync(p, content, 'utf8');
17
+ return p;
18
+ }
19
+
20
+ export function sha(content) {
21
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
22
+ }
23
+
24
+ const SKIP_DIRS = new Set([
25
+ 'node_modules', '.git', '.docgov', 'dist', 'build', 'out', 'target', 'vendor',
26
+ '.next', '.nuxt', '.venv', 'venv', '__pycache__', '.pytest_cache', 'coverage',
27
+ '.turbo', '.cache', '.idea', '.vscode', 'site-packages',
28
+ ]);
29
+
30
+ /**
31
+ * Depth-limited recursive walk returning repo-relative POSIX paths.
32
+ * @param {string} root
33
+ * @param {{match?:(rel:string)=>boolean, maxDepth?:number, includeDirs?:boolean}} [opts]
34
+ */
35
+ export function walk(root, opts = {}) {
36
+ const { match = () => true, maxDepth = 12, includeDirs = false } = opts;
37
+ const out = [];
38
+ const rec = (dir, depth) => {
39
+ if (depth > maxDepth) return;
40
+ let entries;
41
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
42
+ for (const e of entries) {
43
+ if (e.name.startsWith('.') && e.name !== '.github' && e.name !== '.claude') continue;
44
+ if (SKIP_DIRS.has(e.name)) continue;
45
+ const abs = path.join(dir, e.name);
46
+ const rel = toPosix(path.relative(root, abs));
47
+ if (e.isDirectory()) {
48
+ if (includeDirs && match(rel)) out.push(rel);
49
+ rec(abs, depth + 1);
50
+ } else if (e.isFile() && match(rel)) out.push(rel);
51
+ }
52
+ };
53
+ rec(root, 0);
54
+ return out.sort();
55
+ }
56
+
57
+ export function toPosix(p) { return p.split(path.sep).join('/'); }
58
+
59
+ /** Minimal glob: supports **, *, ?, and {a,b} alternation. Anchored. */
60
+ export function globToRegExp(glob) {
61
+ let re = '';
62
+ for (let i = 0; i < glob.length; i++) {
63
+ const c = glob[i];
64
+ if (c === '*') {
65
+ if (glob[i + 1] === '*') {
66
+ // "**/" matches zero or more path segments
67
+ if (glob[i + 2] === '/') { re += '(?:[^/]*\\/)*'; i += 2; }
68
+ else { re += '.*'; i += 1; }
69
+ } else re += '[^/]*';
70
+ } else if (c === '?') re += '[^/]';
71
+ else if (c === '{') {
72
+ const close = glob.indexOf('}', i);
73
+ if (close < 0) { re += '\\{'; continue; }
74
+ re += `(?:${glob.slice(i + 1, close).split(',').map(escapeRe).join('|')})`;
75
+ i = close;
76
+ } else re += escapeRe(c);
77
+ }
78
+ return new RegExp(`^${re}$`);
79
+ }
80
+
81
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
82
+
83
+ export function matchGlob(p, glob) { return globToRegExp(glob).test(p); }
84
+ export function matchAny(p, globs) { return (globs || []).some((g) => matchGlob(p, g)); }
85
+
86
+ /** Deep merge; arrays replace, objects merge, undefined ignored. */
87
+ export function merge(base, over) {
88
+ if (over === undefined || over === null) return base;
89
+ if (Array.isArray(base) || Array.isArray(over)) return over;
90
+ if (typeof base !== 'object' || typeof over !== 'object' || base === null) return over;
91
+ const out = { ...base };
92
+ for (const k of Object.keys(over)) out[k] = merge(base[k], over[k]);
93
+ return out;
94
+ }
95
+
96
+ export function slug(s) {
97
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
98
+ }
99
+
100
+ export function titleCase(s) {
101
+ return String(s).replace(/[-_/]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
102
+ }
103
+
104
+ export function pct(n, d) { return d === 0 ? 100 : Math.round((n / d) * 100); }
105
+
106
+ export function table(rows, headers) {
107
+ if (rows.length === 0) return '(none)';
108
+ const cols = headers || Object.keys(rows[0]);
109
+ const width = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
110
+ const line = (cells) => cells.map((v, i) => String(v ?? '').padEnd(width[i])).join(' ').trimEnd();
111
+ return [line(cols), line(width.map((w) => '-'.repeat(w))), ...rows.map((r) => line(cols.map((c) => r[c])))].join('\n');
112
+ }
113
+
114
+ export function plural(n, one, many = `${one}s`) { return `${n} ${n === 1 ? one : many}`; }
115
+
116
+ export function findRepoRoot(start = process.cwd()) {
117
+ let dir = path.resolve(start);
118
+ for (;;) {
119
+ if (exists(path.join(dir, '.docgov', 'config.yaml')) || exists(path.join(dir, '.git'))) return dir;
120
+ const up = path.dirname(dir);
121
+ if (up === dir) return path.resolve(start);
122
+ dir = up;
123
+ }
124
+ }
package/core/yaml.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Strict YAML subset parser + emitter. Zero dependencies.
3
+ *
4
+ * Supported: comments, nested block maps (2-space or any consistent indent),
5
+ * block sequences, scalars (string / number / bool / null), single- and
6
+ * double-quoted strings, inline flow sequences [a, b], inline flow maps {a: b}.
7
+ *
8
+ * Deliberately UNSUPPORTED — these throw rather than guess, because a governance
9
+ * engine that silently misreads its own config is worse than one that refuses to
10
+ * start: anchors (&), aliases (*), tags (!!), block scalars (| >), tabs,
11
+ * multi-document streams (--- beyond frontmatter fences), complex keys (?).
12
+ */
13
+
14
+ export class YamlError extends Error {
15
+ constructor(msg, line) {
16
+ super(line == null ? msg : `${msg} (line ${line + 1})`);
17
+ this.name = 'YamlError';
18
+ this.line = line;
19
+ }
20
+ }
21
+
22
+ const UNSUPPORTED = [
23
+ [/^\s*[?]\s/, 'complex mapping keys (?)'],
24
+ [/\t/, 'tab indentation'],
25
+ ];
26
+
27
+ function stripComment(s) {
28
+ // Remove a trailing # comment that is not inside quotes.
29
+ let q = null;
30
+ for (let i = 0; i < s.length; i++) {
31
+ const c = s[i];
32
+ if (q) {
33
+ if (c === '\\' && q === '"') i++;
34
+ else if (c === q) q = null;
35
+ } else if (c === '"' || c === "'") q = c;
36
+ else if (c === '#' && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i);
37
+ }
38
+ return s;
39
+ }
40
+
41
+ function parseScalar(raw, lineNo) {
42
+ const s = raw.trim();
43
+ if (s === '') return '';
44
+ if (s[0] === '&' || s[0] === '*') throw new YamlError('anchors and aliases are not supported', lineNo);
45
+ if (s.startsWith('!!') || s.startsWith('!')) throw new YamlError('YAML tags are not supported', lineNo);
46
+ if (s === '|' || s === '>' || s === '|-' || s === '>-' || s === '|+' || s === '>+')
47
+ throw new YamlError('block scalars (| >) are not supported; use a quoted single-line string', lineNo);
48
+ if (s[0] === '"' && s.endsWith('"') && s.length > 1) return JSON.parse(s);
49
+ if (s[0] === "'" && s.endsWith("'") && s.length > 1) return s.slice(1, -1).replace(/''/g, "'");
50
+ if (s[0] === '[') return parseFlowSeq(s, lineNo);
51
+ if (s[0] === '{') return parseFlowMap(s, lineNo);
52
+ if (s === 'null' || s === '~') return null;
53
+ if (s === 'true' || s === 'yes' || s === 'on') return true;
54
+ if (s === 'false' || s === 'no' || s === 'off') return false;
55
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
56
+ if (/^-?\d*\.\d+$/.test(s)) return parseFloat(s);
57
+ return s;
58
+ }
59
+
60
+ function splitFlow(body, lineNo) {
61
+ const parts = [];
62
+ let depth = 0, q = null, cur = '';
63
+ for (let i = 0; i < body.length; i++) {
64
+ const c = body[i];
65
+ if (q) {
66
+ cur += c;
67
+ if (c === '\\' && q === '"') { cur += body[++i] ?? ''; }
68
+ else if (c === q) q = null;
69
+ continue;
70
+ }
71
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
72
+ if (c === '[' || c === '{') depth++;
73
+ if (c === ']' || c === '}') depth--;
74
+ if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
75
+ cur += c;
76
+ }
77
+ if (q) throw new YamlError('unterminated quote in flow collection', lineNo);
78
+ if (cur.trim() !== '') parts.push(cur);
79
+ return parts;
80
+ }
81
+
82
+ function parseFlowSeq(s, lineNo) {
83
+ if (!s.endsWith(']')) throw new YamlError('unterminated flow sequence', lineNo);
84
+ const body = s.slice(1, -1).trim();
85
+ if (body === '') return [];
86
+ return splitFlow(body, lineNo).map((p) => parseScalar(p, lineNo));
87
+ }
88
+
89
+ function parseFlowMap(s, lineNo) {
90
+ if (!s.endsWith('}')) throw new YamlError('unterminated flow mapping', lineNo);
91
+ const body = s.slice(1, -1).trim();
92
+ const out = {};
93
+ if (body === '') return out;
94
+ for (const p of splitFlow(body, lineNo)) {
95
+ const i = splitKey(p);
96
+ if (i < 0) throw new YamlError('flow mapping entry missing ":"', lineNo);
97
+ out[parseScalar(p.slice(0, i), lineNo)] = parseScalar(p.slice(i + 1), lineNo);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** Index of the ":" that separates key from value, ignoring quoted regions. */
103
+ function splitKey(s) {
104
+ let q = null;
105
+ for (let i = 0; i < s.length; i++) {
106
+ const c = s[i];
107
+ if (q) {
108
+ if (c === '\\' && q === '"') i++;
109
+ else if (c === q) q = null;
110
+ continue;
111
+ }
112
+ if (c === '"' || c === "'") { q = c; continue; }
113
+ if (c === ':' && (i + 1 === s.length || /[\s]/.test(s[i + 1]))) return i;
114
+ }
115
+ return -1;
116
+ }
117
+
118
+ /** @returns {{indent:number, text:string, lineNo:number}[]} */
119
+ function tokenize(src) {
120
+ const out = [];
121
+ const lines = src.split(/\r?\n/);
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const raw = lines[i];
124
+ for (const [re, what] of UNSUPPORTED) {
125
+ if (re.test(raw)) throw new YamlError(`${what} is not supported`, i);
126
+ }
127
+ const text = stripComment(raw).replace(/\s+$/, '');
128
+ if (text.trim() === '') continue;
129
+ out.push({ indent: raw.length - raw.trimStart().length, text: text.trimStart(), lineNo: i });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * @param {string} src
136
+ * @returns {any}
137
+ */
138
+ export function parse(src) {
139
+ if (src == null) return {};
140
+ const toks = tokenize(String(src));
141
+ if (toks.length === 0) return {};
142
+ const [value, next] = parseBlock(toks, 0, toks[0].indent);
143
+ if (next < toks.length) throw new YamlError('unexpected content after document', toks[next].lineNo);
144
+ return value;
145
+ }
146
+
147
+ function parseBlock(toks, i, indent) {
148
+ if (i >= toks.length) return [null, i];
149
+ if (toks[i].text.startsWith('- ') || toks[i].text === '-') return parseSeq(toks, i, indent);
150
+ return parseMap(toks, i, indent);
151
+ }
152
+
153
+ function parseSeq(toks, i, indent) {
154
+ const out = [];
155
+ while (i < toks.length && toks[i].indent === indent) {
156
+ const t = toks[i];
157
+ if (!(t.text === '-' || t.text.startsWith('- '))) break;
158
+ const rest = t.text === '-' ? '' : t.text.slice(2).trim();
159
+ if (rest === '') {
160
+ i++;
161
+ if (i < toks.length && toks[i].indent > indent) {
162
+ const [v, n] = parseBlock(toks, i, toks[i].indent);
163
+ out.push(v); i = n;
164
+ } else out.push(null);
165
+ continue;
166
+ }
167
+ const k = splitKey(rest);
168
+ if (k >= 0) {
169
+ // "- key: value" opens an inline map whose continuation is indented past the dash
170
+ const inner = [{ indent: indent + 2, text: rest, lineNo: t.lineNo }];
171
+ let j = i + 1;
172
+ while (j < toks.length && toks[j].indent > indent) { inner.push(toks[j]); j++; }
173
+ const [v, n] = parseMap(inner, 0, indent + 2);
174
+ if (n < inner.length) throw new YamlError('unexpected content in sequence item', inner[n].lineNo);
175
+ out.push(v); i = j;
176
+ continue;
177
+ }
178
+ out.push(parseScalar(rest, t.lineNo));
179
+ i++;
180
+ }
181
+ return [out, i];
182
+ }
183
+
184
+ function parseMap(toks, i, indent) {
185
+ const out = {};
186
+ while (i < toks.length && toks[i].indent === indent) {
187
+ const t = toks[i];
188
+ if (t.text.startsWith('- ')) break;
189
+ const k = splitKey(t.text);
190
+ if (k < 0) throw new YamlError(`expected "key: value", got ${JSON.stringify(t.text)}`, t.lineNo);
191
+ const key = String(parseScalar(t.text.slice(0, k), t.lineNo));
192
+ const rest = t.text.slice(k + 1).trim();
193
+ if (rest !== '') { out[key] = parseScalar(rest, t.lineNo); i++; continue; }
194
+ // nested block, or explicit empty
195
+ if (i + 1 < toks.length && toks[i + 1].indent > indent) {
196
+ const [v, n] = parseBlock(toks, i + 1, toks[i + 1].indent);
197
+ out[key] = v; i = n;
198
+ } else if (i + 1 < toks.length && toks[i + 1].indent === indent && toks[i + 1].text.startsWith('- ')) {
199
+ const [v, n] = parseSeq(toks, i + 1, indent);
200
+ out[key] = v; i = n;
201
+ } else { out[key] = null; i++; }
202
+ }
203
+ return [out, i];
204
+ }
205
+
206
+ const PLAIN_SAFE = /^[A-Za-z0-9_][A-Za-z0-9 _\-./:+@]*$/;
207
+
208
+ function emitScalar(v) {
209
+ if (v === null || v === undefined) return 'null';
210
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
211
+ const s = String(v);
212
+ if (s === '') return "''";
213
+ if (PLAIN_SAFE.test(s) && !/^(true|false|yes|no|on|off|null|~)$/i.test(s) && !/^-?\d+(\.\d+)?$/.test(s) && !s.endsWith(' '))
214
+ return s;
215
+ return JSON.stringify(s);
216
+ }
217
+
218
+ /**
219
+ * Deterministic emitter: object keys in insertion order, 2-space indent.
220
+ * @param {any} value
221
+ * @param {number} [indent]
222
+ */
223
+ export function stringify(value, indent = 0) {
224
+ const pad = ' '.repeat(indent);
225
+ if (Array.isArray(value)) {
226
+ if (value.length === 0) return `${pad}[]\n`;
227
+ return value.map((v) => {
228
+ if (v !== null && typeof v === 'object') {
229
+ const body = stringify(v, indent + 2);
230
+ return `${pad}-${body.slice(indent + 1)}`;
231
+ }
232
+ return `${pad}- ${emitScalar(v)}\n`;
233
+ }).join('');
234
+ }
235
+ if (value !== null && typeof value === 'object') {
236
+ const keys = Object.keys(value);
237
+ if (keys.length === 0) return `${pad}{}\n`;
238
+ return keys.map((k) => {
239
+ const v = value[k];
240
+ const key = PLAIN_SAFE.test(k) ? k : JSON.stringify(k);
241
+ if (v !== null && typeof v === 'object') {
242
+ if (Array.isArray(v) && v.length === 0) return `${pad}${key}: []\n`;
243
+ if (!Array.isArray(v) && Object.keys(v).length === 0) return `${pad}${key}: {}\n`;
244
+ return `${pad}${key}:\n${stringify(v, indent + 2)}`;
245
+ }
246
+ return `${pad}${key}: ${emitScalar(v)}\n`;
247
+ }).join('');
248
+ }
249
+ return `${pad}${emitScalar(value)}\n`;
250
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "args": ["node", "${CLAUDE_PLUGIN_ROOT}/bin/docgov", "hook", "session-start"],
9
+ "timeout": 10
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "args": ["node", "${CLAUDE_PLUGIN_ROOT}/bin/docgov", "hook", "pre-tool"],
22
+ "timeout": 15
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "matcher": "Write",
28
+ "hooks": [
29
+ {
30
+ "type": "prompt",
31
+ "if": "Write(**/*.md)",
32
+ "timeout": 20,
33
+ "continueOnBlock": true,
34
+ "prompt": "You are a documentation lens reviewer for a single file write. The tool input contains `file_path` and `content`.\n\nJudge ONLY these two things, which are decidable from the content and path alone. Everything structural is already enforced deterministically elsewhere — do not repeat it.\n\n1. AUDIENCE LENS MISMATCH. Does the content match the audience its location implies?\n - a README, getting-started, tutorial or anything under a `user/`, `public/` or `08-user/` path must be task-oriented for its reader: no internal implementation dumps, no architecture essays, no unexplained internal jargon.\n - anything under an `architecture/`, `03-architecture/` or `domains/` path must describe boundaries, responsibilities, data flows and trade-offs — not installation steps or tutorials.\n - a runbook must be executable under pressure: numbered steps with expected results, not prose.\n\n2. VISIBILITY LEAK. If the path is public-facing (`README`, `SECURITY.md`, `CONTRIBUTING.md`, or anything under `public/`, `external/`, `11-external/`, `08-user/`), does the content contain internal hostnames, private IPs, credential-shaped strings, internal ticket ids, employee emails, or descriptions of unmitigated vulnerabilities?\n\nRespond with JSON only.\nIf both are fine: {\"ok\": true}\nIf not: {\"ok\": false, \"reason\": \"<which of the two problems, the specific lines or phrases at fault, and what to do instead>\"}\n\nBe conservative. A document that is merely terse, incomplete or unpolished is fine — say ok. Only flag a genuine audience mismatch or a genuine leak.\n\n$ARGUMENTS"
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+
40
+ "PostToolUse": [
41
+ {
42
+ "matcher": "Write|Edit|MultiEdit",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "args": ["node", "${CLAUDE_PLUGIN_ROOT}/bin/docgov", "hook", "post-write"],
47
+ "timeout": 20
48
+ }
49
+ ]
50
+ }
51
+ ],
52
+
53
+ "Stop": [
54
+ {
55
+ "hooks": [
56
+ {
57
+ "type": "command",
58
+ "args": ["node", "${CLAUDE_PLUGIN_ROOT}/bin/docgov", "hook", "stop"],
59
+ "timeout": 20
60
+ }
61
+ ]
62
+ }
63
+ ]
64
+ }
65
+ }
@@ -0,0 +1,38 @@
1
+ # Agent lens
2
+
3
+ **The question:** can a different agent, with no access to this conversation, act on this
4
+ document and get it right?
5
+
6
+ This is the lens that matters most in a repository most of whose authors are agents, and
7
+ the one human reviewers never apply.
8
+
9
+ ## Evaluates
10
+
11
+ - **Self-containment.** No reliance on context that lived in a chat.
12
+ - **Stated prerequisites.** An agent will not infer an unstated precondition; it will
13
+ proceed confidently without it.
14
+ - **No ambiguous authority.** It is clear whether this document is authoritative or
15
+ describes something else that is.
16
+ - **No TBD as fact.** `TODO`, `TBD`, `???` and "coming soon" will be read as current state.
17
+ Either state what is true or say explicitly that it is unknown and who owns it.
18
+ - **Verifiable claims.** Numbers, paths and versions an agent can check. An unsourced claim
19
+ gets propagated as authoritative.
20
+ - **Runnable examples.** Exactly as written, with no implied edits.
21
+ - **Declared relationships.** A document outside the graph is invisible to impact analysis,
22
+ so nothing will ever tell an agent it went stale.
23
+ - **Fits a context window.** Past roughly 1,200 lines an agent will see a truncated half of
24
+ the document and will not know which half.
25
+ - **Unambiguous terminology.** One term per concept. A document that uses "tenant",
26
+ "organization" and "account" for the same thing will produce code that uses all three.
27
+
28
+ ## Fails the lens
29
+
30
+ - "As discussed" / "per the decision" with no link.
31
+ - Two sections describing the same behaviour differently; an agent may read either.
32
+ - Aspirational present tense, which an agent will implement against.
33
+ - Examples with placeholders (`<your-key>`) and no statement that they are placeholders.
34
+
35
+ ## The test
36
+
37
+ Strip the document of everything except its own text. Would an agent reading only that make
38
+ a wrong assumption? Each wrong assumption is a finding.
@@ -0,0 +1,30 @@
1
+ # Architecture lens
2
+
3
+ **The question:** can a reader tell what this system guarantees, where its boundaries are,
4
+ and which changes are safe?
5
+
6
+ ## Evaluates
7
+
8
+ - **Boundaries.** What is inside, what is outside, what crosses. Unambiguous.
9
+ - **Responsibilities.** What each component owns. Two components owning one thing is a
10
+ finding, not a detail.
11
+ - **Data flows.** What moves where, in what shape, triggered by what.
12
+ - **Dependencies.** Direction included. Cycles are findings.
13
+ - **Invariants.** What must always hold. Given ids so they can be enforced and cited.
14
+ - **Trade-offs.** What was given up. An architecture document with no trade-offs is a sales
15
+ document.
16
+ - **ADR references.** Decisions linked rather than re-argued, so nobody reverses one by
17
+ accident.
18
+
19
+ ## Fails the lens
20
+
21
+ - Installation or usage instructions. Wrong document.
22
+ - A diagram with no text. A diagram shows structure; it cannot state a guarantee.
23
+ - "Scalable", "robust", "performant" with no number or mechanism.
24
+ - Present tense describing aspiration. If it is not built, say so and link the PRD.
25
+ - Restating a schema that lives in a machine contract, creating a second source of truth.
26
+
27
+ ## The test
28
+
29
+ Could a reader use this document to decide whether a proposed change is safe? If not, it
30
+ describes the system without governing it.