docguard-cli 0.30.1 β 0.32.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.
- package/README.md +28 -9
- package/cli/commands/explain.mjs +34 -0
- package/cli/commands/guard.mjs +8 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +300 -11
- package/cli/commands/mcp.mjs +179 -53
- package/cli/commands/verify.mjs +63 -1
- package/cli/config.mjs +7 -0
- package/cli/docguard.mjs +46 -3
- package/cli/findings.mjs +32 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-diff.mjs +209 -0
- package/cli/shared-git.mjs +93 -0
- package/cli/shared-ir.mjs +81 -0
- package/cli/validators/api-doc-smells.mjs +143 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/diff-suspicion.mjs +178 -0
- package/cli/validators/reference-existence.mjs +311 -0
- package/cli/validators/traceability.mjs +107 -4
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ DocGuard enforces **Canonical-Driven Development (CDD)** β a methodology where
|
|
|
62
62
|
|
|
63
63
|
DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) community extension. It validates the artifacts that Spec Kit creates, ensuring your specs stay high-quality throughout the development lifecycle.
|
|
64
64
|
|
|
65
|
-
π **[Philosophy](PHILOSOPHY.md)** Β· π **[CDD Standard](STANDARD.md)** Β· βοΈ **[Comparisons](COMPARISONS.md)** Β· πΊοΈ **[Roadmap](ROADMAP.md)**
|
|
65
|
+
π **[Philosophy](PHILOSOPHY.md)** Β· π **[CDD Standard](STANDARD.md)** Β· βοΈ **[Comparisons](COMPARISONS.md)** Β· π¬ **[Validation](VALIDATION.md)** Β· πΊοΈ **[Roadmap](ROADMAP.md)**
|
|
66
66
|
|
|
67
67
|
### Architecture
|
|
68
68
|
|
|
@@ -76,7 +76,7 @@ graph TD
|
|
|
76
76
|
Commands --> setup["setup wizard"]
|
|
77
77
|
Commands --> other["diff Β· init Β· fix Β· trace Β· impact Β· sync<br/>explain Β· memory Β· upgrade Β· agents Β· hooks Β· badge Β· ci Β· watch"]
|
|
78
78
|
|
|
79
|
-
guard --> Validators["Validators (
|
|
79
|
+
guard --> Validators["Validators (27)"]
|
|
80
80
|
generate --> Scanners["Scanners (4)<br/>routes Β· schemas Β· doc-tools Β· speckit"]
|
|
81
81
|
score --> Scoring["Weighted Scoring<br/>8 categories"]
|
|
82
82
|
diagnose --> Validators
|
|
@@ -254,7 +254,7 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
|
|
|
254
254
|
| Command | What It Does |
|
|
255
255
|
|:--------|:-------------|
|
|
256
256
|
| `init` | Bootstrap a project (`--wizard` for interactive Β· `--with <name>` for scaffolders) |
|
|
257
|
-
| `guard` | Validate against canonical docs β
|
|
257
|
+
| `guard` | Validate against canonical docs β 27 validators |
|
|
258
258
|
| `diff` | Show gaps between docs and code (`--since <ref>` for impact mode) |
|
|
259
259
|
| `sync` | Refresh code-truth doc sections β keeps memory always up to date |
|
|
260
260
|
| `score` | CDD maturity score (0-100; `--diff` for delta between refs) |
|
|
@@ -274,7 +274,7 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
|
|
|
274
274
|
| `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code β the semantic-drift class regex/AST can't see |
|
|
275
275
|
| `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands β plus clustered rule pairs as agent judgment tasks |
|
|
276
276
|
| `feedback` | Report likely false positives back to DocGuard β local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
|
|
277
|
-
| `mcp` | MCP server
|
|
277
|
+
| `mcp` | MCP server β exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
|
|
278
278
|
| `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
|
|
279
279
|
| `memory --diff` | Drill into which specific claims don't match code |
|
|
280
280
|
| `memory --pack` | Write `.docguard/context-pack.md` β compact, code-truth-stamped session-start context for AI agents |
|
|
@@ -325,6 +325,9 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
|
|
|
325
325
|
| `--apply` | Actually run the migration | upgrade |
|
|
326
326
|
| `--pr` | Open a PR with the migration | upgrade |
|
|
327
327
|
| `--reverse <file>` | Reverse traceability (code β docs) | trace |
|
|
328
|
+
| `--no-indirect` | Skip the reverse-import-graph analysis (docs about modules that import a changed file) | impact, diff --since |
|
|
329
|
+
| `--prs` | Open-PR doc-conflict analysis β two PRs impacting the same canonical doc = merge-order risk (needs the `gh` CLI) | impact |
|
|
330
|
+
| `--transport http` `--port` `--host` `--api-key` `--path` | Serve MCP over Streamable HTTP instead of stdio (team-shared server; loopback-only unless an api-key is set) | mcp |
|
|
328
331
|
| `--history` | Show fix audit log | fix |
|
|
329
332
|
|
|
330
333
|
### Example Output
|
|
@@ -360,7 +363,7 @@ $ npx docguard-cli generate
|
|
|
360
363
|
|
|
361
364
|
## π Validators
|
|
362
365
|
|
|
363
|
-
DocGuard runs **
|
|
366
|
+
DocGuard runs **27 automated validators** on every `guard` check. Every one is **language-aware** as of v0.16 β patterns for Python (`test_*.py`), Rust (`tests/*.rs`), Go (`*_test.go`), Java (`*Test.java`), Ruby (`*_spec.rb`), PHP, and JS/TS all match.
|
|
364
367
|
|
|
365
368
|
| # | Validator | What It Checks | Default |
|
|
366
369
|
|:--|:----------|:--------------|:--------|
|
|
@@ -383,11 +386,14 @@ DocGuard runs **24 automated validators** on every `guard` check. Every one is *
|
|
|
383
386
|
| 17 | **TODO-Tracking** | Untracked TODOs/FIXMEs and skipped tests (skips test files by default) | β
On |
|
|
384
387
|
| 18 | **Schema-Sync** | Database models documented in DATA-MODEL.md | β
On |
|
|
385
388
|
| 19 | **Spec-Kit** | Spec quality validation (FR-IDs, mandatory sections, phased tasks) | β
On |
|
|
386
|
-
| 20 | **Cross-Reference** | Internal markdown links + anchors resolve (with "did you mean?" hints) | β
On |
|
|
389
|
+
| 20 | **Cross-Reference** | Internal markdown links + anchors resolve (with "did you mean?" hints); Obsidian wikilinks validated when the repo uses them as file links (`.obsidian` present or a target resolves) | β
On |
|
|
387
390
|
| 21 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | β
On |
|
|
388
391
|
| 22 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only β N/A elsewhere) | β
On |
|
|
389
392
|
| 23 | **Metrics-Consistency** | Hardcoded numbers match actual counts | β
On |
|
|
390
|
-
| 24 | **Surface-Sync** | Item-level enumerable drift β names in doc tables/lists (commands,
|
|
393
|
+
| 24 | **Surface-Sync** | Item-level enumerable drift β names in doc tables/lists (commands, checks, etc.) match code-truth (opt-in via `surfaceSync.surfaces`; N/A unless configured) | β
On |
|
|
394
|
+
| 25 | **Diff-Suspicion** | Change-driven: a doc/agent-instruction file that references code changed since the ref AND shares removed domain symbols is flagged for review (arXiv 2010.01625, F1 74.7) | β
On |
|
|
395
|
+
| 26 | **Reference-Existence** | Two-revision check: a backticked code symbol present when the doc was last updated but gone at HEAD is flagged as outdated (arXiv 2212.01479) | β
On |
|
|
396
|
+
| 27 | **API-Doc-Smells** | Bloated (β₯300 words) / Lazy (β€6 prose words) API documentation units, keyed on signature-headed sections (F1 0.90/0.95) | β
On |
|
|
391
397
|
|
|
392
398
|
**Per-validator controls** (in `.docguard.json`):
|
|
393
399
|
```json
|
|
@@ -447,6 +453,19 @@ DocGuard works with **every major AI coding agent**. All canonical docs are plai
|
|
|
447
453
|
| Google Gemini CLI | β
| `docguard agents --agent gemini` |
|
|
448
454
|
| Kiro (AWS) | β
| β |
|
|
449
455
|
|
|
456
|
+
### Always-on nudge hook (Claude Code)
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
docguard hooks --claude # install (remove: docguard hooks --claude --remove)
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Registers a `PostToolUse` hook in the project's `.claude/settings.json`. After the
|
|
463
|
+
agent edits a canonical doc it is nudged to run `docguard guard --changed-only`;
|
|
464
|
+
after it edits a code file the docs reference, it is nudged toward `docguard impact`.
|
|
465
|
+
Merge-safe (only DocGuard's own entry is ever added/removed), throttled to one nudge
|
|
466
|
+
per file per 30 minutes, and the hook runtime can never break a session (errors are
|
|
467
|
+
silent by contract). Explicit opt-in β `init` never installs it for you.
|
|
468
|
+
|
|
450
469
|
---
|
|
451
470
|
|
|
452
471
|
## β‘ Slash Commands
|
|
@@ -456,7 +475,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
|
|
|
456
475
|
| Command | What It Does |
|
|
457
476
|
|:--------|:-------------|
|
|
458
477
|
| `/docguard.init` | Initialize Canonical-Driven Development in a new or existing project |
|
|
459
|
-
| `/docguard.guard` | Run quality validation β check all
|
|
478
|
+
| `/docguard.guard` | Run quality validation β check all 27 validators |
|
|
460
479
|
| `/docguard.review` | Analyze doc quality and suggest improvements |
|
|
461
480
|
| `/docguard.fix` | Generate targeted fix prompts for specific issues |
|
|
462
481
|
| `/docguard.update` | Update canonical docs after code changes β detect drift and sync documentation |
|
|
@@ -615,7 +634,7 @@ Two ready-to-use templates ship with the Spec Kit extension and as standalone fi
|
|
|
615
634
|
Recent highlights across the v0.16 β v0.19 line:
|
|
616
635
|
|
|
617
636
|
- **`docguard explain <validator>`** β `docguard explain freshness` prints purpose, rules, common
|
|
618
|
-
failures, and fix recipes for any of the
|
|
637
|
+
failures, and fix recipes for any of the 27 validators. No need to dig into source.
|
|
619
638
|
- **`docguard memory --diff`** β surface what changed in your canonical docs between two refs
|
|
620
639
|
(`HEAD~10..HEAD` by default). Great for code review and changelog drafting.
|
|
621
640
|
- **`docguard score --diff`** β see exactly which validators moved the score up or down between
|
package/cli/commands/explain.mjs
CHANGED
|
@@ -178,6 +178,37 @@ const EXPLAINERS = {
|
|
|
178
178
|
example: '`cli/commands/demo.mjs` exists and `| `demo` | Zero-install preview |` appears in README.md\'s commands table',
|
|
179
179
|
standard: 'CDD principle: documented surfaces match implemented surfaces',
|
|
180
180
|
},
|
|
181
|
+
diffSuspicion: {
|
|
182
|
+
title: 'Diff-Suspicion β docs describing code that just changed (DSP001)',
|
|
183
|
+
what: 'Change-driven. For each code file changed since the ref (`config.changedSinceRef`, else HEAD~1), flags a canonical doc or agent-instruction file that BOTH references the file (path/`module`) AND shares domain tokens that were removed/replaced in the diff. Deterministic diff-overlap rule (arXiv 2010.01625, F1 74.7); confidence:low.',
|
|
184
|
+
why: 'A doc that talks about a symbol the code just deleted is the highest-signal, lowest-cost drift class β and it is exactly what whole-repo scans miss but a diff makes obvious.',
|
|
185
|
+
triggers: [
|
|
186
|
+
['describes X, which just had β¦ removed/changed', 'Re-read the doc against the current file; the removed symbols may now be wrong. Suppress the pairing if it is a false positive.'],
|
|
187
|
+
],
|
|
188
|
+
example: 'AUTH.md references `src/auth.ts` and `validateToken` was renamed away in the last commit',
|
|
189
|
+
standard: 'Just-in-time comment-code inconsistency detection (AAAI 2021)',
|
|
190
|
+
},
|
|
191
|
+
referenceExistence: {
|
|
192
|
+
title: 'Reference-Existence β doc names a code symbol that no longer exists (REF001)',
|
|
193
|
+
what: 'Two-revision check. A compound code identifier backticked in a doc that existed in the source when the doc was last updated but has ZERO whole-word matches at HEAD is flagged as outdated. Deterministic (arXiv 2212.01479); confidence:low.',
|
|
194
|
+
why: 'References go stale silently for years; the two-revision gate distinguishes "renamed away" from "never existed", keeping false positives near zero.',
|
|
195
|
+
triggers: [
|
|
196
|
+
['references `X`, which existed β¦ but has ZERO matches at HEAD', 'Update or remove the reference. Suppress with `<!-- docguard:ignore REF001 -->` if it is a still-relevant user-facing name.'],
|
|
197
|
+
],
|
|
198
|
+
example: 'API-REFERENCE.md backticks `getUserById` which was deleted three commits ago',
|
|
199
|
+
standard: 'Outdated code-element reference detection (EMSE 2022)',
|
|
200
|
+
},
|
|
201
|
+
apiDocSmells: {
|
|
202
|
+
title: 'API-Doc-Smells β Bloated / Lazy API documentation (APS001/APS002)',
|
|
203
|
+
what: 'Length signals on doc units whose heading is a code signature (HTTP endpoint / function / backticked symbol). Lazy = β€6 prose words (documented in name only); Bloated = β₯300 words for one unit. Deterministic (F1 0.90/0.95); confidence:low. OFF by default β enable with `validators.apiDocSmells: true`.',
|
|
204
|
+
why: 'An endpoint documented in name only, or buried in 400 words, both fail the reader β and both are detectable without understanding the prose.',
|
|
205
|
+
triggers: [
|
|
206
|
+
['documented in name only', 'Describe what it does, its params, return, and errors β not just the signature.'],
|
|
207
|
+
['is N words for one unit β Bloated', 'Trim to the essential contract; move examples/edge-cases elsewhere.'],
|
|
208
|
+
],
|
|
209
|
+
example: '`#### GET /api/health` with an empty body β Lazy',
|
|
210
|
+
standard: 'API documentation smell taxonomy',
|
|
211
|
+
},
|
|
181
212
|
crossReference: {
|
|
182
213
|
title: 'Cross-Reference β internal markdown links resolve',
|
|
183
214
|
what: 'Scans canonical docs for `[text](./OTHER.md#anchor)` and `#anchor` links. Verifies the target file exists and the anchor matches a heading.',
|
|
@@ -351,6 +382,9 @@ const DISPLAY_NAMES = {
|
|
|
351
382
|
surfaceSync: 'Surface-Sync',
|
|
352
383
|
canonicalSync: 'Canonical-Sync',
|
|
353
384
|
metricsConsistency: 'Metrics-Consistency',
|
|
385
|
+
diffSuspicion: 'Diff-Suspicion',
|
|
386
|
+
referenceExistence: 'Reference-Existence',
|
|
387
|
+
apiDocSmells: 'API-Doc-Smells',
|
|
354
388
|
};
|
|
355
389
|
|
|
356
390
|
/** Collapse a key / display name to a comparable form: lowercase, alnum only. */
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -147,6 +147,10 @@ import { validateSchemaSync } from '../validators/schema-sync.mjs';
|
|
|
147
147
|
import { validateSpecKitIntegration } from '../validators/spec-kit.mjs';
|
|
148
148
|
import { validateCanonicalSync } from '../validators/canonical-sync.mjs';
|
|
149
149
|
import { validateSurfaceSync } from '../validators/surface-sync.mjs';
|
|
150
|
+
// v0.31.0 change-driven + smell detectors
|
|
151
|
+
import { validateDiffSuspicion } from '../validators/diff-suspicion.mjs';
|
|
152
|
+
import { validateReferenceExistence } from '../validators/reference-existence.mjs';
|
|
153
|
+
import { validateApiDocSmells } from '../validators/api-doc-smells.mjs';
|
|
150
154
|
|
|
151
155
|
/**
|
|
152
156
|
* Internal guard β returns structured data, no console output, no process.exit.
|
|
@@ -313,6 +317,10 @@ export function runGuardInternal(projectDir, config) {
|
|
|
313
317
|
{ key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
|
|
314
318
|
{ key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
|
|
315
319
|
{ key: 'surfaceSync', name: 'Surface-Sync', fn: () => validateSurfaceSync(projectDir, config) },
|
|
320
|
+
// v0.31.0 β change-driven + smell detectors (all confidence:low / soft).
|
|
321
|
+
{ key: 'diffSuspicion', name: 'Diff-Suspicion', fn: () => validateDiffSuspicion(projectDir, config) },
|
|
322
|
+
{ key: 'referenceExistence', name: 'Reference-Existence', fn: () => validateReferenceExistence(projectDir, config) },
|
|
323
|
+
{ key: 'apiDocSmells', name: 'API-Doc-Smells', fn: () => validateApiDocSmells(projectDir, config) },
|
|
316
324
|
// Metrics-Consistency runs post-loop (needs guard results)
|
|
317
325
|
];
|
|
318
326
|
|
package/cli/commands/hooks.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Creates git hooks that run guard/score before commits.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
// v0.16-P3: managed-block markers. Letting users extend the hook with their
|
|
9
9
|
// own commands (data-file guards, lint checks, etc.) without us clobbering
|
|
@@ -54,7 +54,7 @@ function spliceManagedBlock(existing, newBody) {
|
|
|
54
54
|
const bodyNoShebang = newBody.replace(/^#!.*\n/, '');
|
|
55
55
|
return `${before}${BEGIN_MARKER}\n${bodyNoShebang.replace(/\n+$/, '')}\n${END_MARKER}${after}`;
|
|
56
56
|
}
|
|
57
|
-
import { resolve } from 'node:path';
|
|
57
|
+
import { resolve, relative, basename } from 'node:path';
|
|
58
58
|
import { c } from '../shared.mjs';
|
|
59
59
|
import { getHooksDir } from '../shared-git.mjs';
|
|
60
60
|
|
|
@@ -217,6 +217,15 @@ export function runHooks(projectDir, config, flags) {
|
|
|
217
217
|
console.log(`${c.bold}πͺ DocGuard Hooks β ${config.projectName}${c.reset}`);
|
|
218
218
|
console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
|
|
219
219
|
|
|
220
|
+
// ββ Claude Code agent nudge: `docguard hooks --claude` ββ
|
|
221
|
+
// Separate path from git hooks: it edits .claude/settings.json, needs no
|
|
222
|
+
// git repo, and is explicitly opt-in (writing agent config unasked is a
|
|
223
|
+
// trust break β same class as the ensureSkills READ_ONLY_COMMANDS rule).
|
|
224
|
+
if (flags.claude) {
|
|
225
|
+
installClaudeNudge(projectDir, { remove: !!flags.remove });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
220
229
|
// Resolve the real hooks dir via git β NOT `<projectDir>/.git/hooks`, which
|
|
221
230
|
// is wrong inside a linked worktree (where `.git` is a file, not a dir) and
|
|
222
231
|
// ignores a custom core.hooksPath.
|
|
@@ -337,3 +346,159 @@ export function runHooks(projectDir, config, flags) {
|
|
|
337
346
|
|
|
338
347
|
console.log('');
|
|
339
348
|
}
|
|
349
|
+
|
|
350
|
+
// ββ Claude Code agent nudge βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
351
|
+
//
|
|
352
|
+
// `docguard hooks --claude` registers a PostToolUse hook in the PROJECT's
|
|
353
|
+
// .claude/settings.json. After the agent edits a canonical doc (or a code
|
|
354
|
+
// file the docs reference), the hook nudges it toward the right DocGuard
|
|
355
|
+
// command β the graphify "query-first hook" distribution pattern, pointed at
|
|
356
|
+
// doc integrity instead of graph queries.
|
|
357
|
+
//
|
|
358
|
+
// Trust rules:
|
|
359
|
+
// - Explicit opt-in only (never installed by ensureSkills/init).
|
|
360
|
+
// - Merge-safe: parses the existing settings.json and adds/removes ONLY the
|
|
361
|
+
// entry whose command contains the NUDGE_HOOK_COMMAND marker. A file that
|
|
362
|
+
// doesn't parse is never touched.
|
|
363
|
+
// - The runtime (`docguard nudge-hook`) is throttled and can never break an
|
|
364
|
+
// agent session: any internal error exits 0 with no output.
|
|
365
|
+
|
|
366
|
+
const NUDGE_HOOK_COMMAND = 'docguard nudge-hook';
|
|
367
|
+
const NUDGE_THROTTLE_MS = 30 * 60 * 1000; // one nudge per file per 30 min
|
|
368
|
+
const NUDGE_STATE_PATH = '.docguard/nudge-state.json';
|
|
369
|
+
const NUDGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/;
|
|
370
|
+
const NUDGE_AGENT_FILES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
|
|
371
|
+
|
|
372
|
+
function isOurNudgeGroup(group) {
|
|
373
|
+
return Array.isArray(group?.hooks) &&
|
|
374
|
+
group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(NUDGE_HOOK_COMMAND));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function installClaudeNudge(projectDir, { remove = false } = {}) {
|
|
378
|
+
const settingsDir = resolve(projectDir, '.claude');
|
|
379
|
+
const settingsPath = resolve(settingsDir, 'settings.json');
|
|
380
|
+
|
|
381
|
+
let settings = {};
|
|
382
|
+
if (existsSync(settingsPath)) {
|
|
383
|
+
try {
|
|
384
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
385
|
+
} catch {
|
|
386
|
+
console.log(` ${c.red}β .claude/settings.json exists but is not valid JSON β refusing to touch it.${c.reset}`);
|
|
387
|
+
console.log(` ${c.dim}Fix the file, then re-run docguard hooks --claude.${c.reset}\n`);
|
|
388
|
+
process.exitCode = 1;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const groups = Array.isArray(settings.hooks?.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
394
|
+
const present = groups.some(isOurNudgeGroup);
|
|
395
|
+
|
|
396
|
+
if (remove) {
|
|
397
|
+
if (!present) {
|
|
398
|
+
console.log(` ${c.dim}βοΈ No DocGuard nudge hook found in .claude/settings.json β nothing to remove.${c.reset}\n`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
settings.hooks.PostToolUse = groups.filter(g => !isOurNudgeGroup(g));
|
|
402
|
+
if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
|
|
403
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
404
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
405
|
+
console.log(` ${c.yellow}ποΈ Removed the DocGuard nudge hook from .claude/settings.json${c.reset} ${c.dim}(everything else preserved)${c.reset}\n`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (present) {
|
|
410
|
+
console.log(` ${c.green}β
DocGuard nudge hook already installed${c.reset} ${c.dim}(.claude/settings.json β idempotent)${c.reset}\n`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!settings.hooks) settings.hooks = {};
|
|
415
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
|
|
416
|
+
settings.hooks.PostToolUse.push({
|
|
417
|
+
matcher: 'Edit|Write|MultiEdit',
|
|
418
|
+
hooks: [{ type: 'command', command: NUDGE_HOOK_COMMAND }],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
|
|
422
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
423
|
+
console.log(` ${c.green}β
Installed the DocGuard nudge hook${c.reset} β .claude/settings.json (PostToolUse)`);
|
|
424
|
+
console.log(` ${c.dim}After an agent edits a canonical doc (or code the docs reference), it is${c.reset}`);
|
|
425
|
+
console.log(` ${c.dim}nudged toward docguard guard --changed-only / docguard impact.${c.reset}`);
|
|
426
|
+
console.log(` ${c.dim}Throttled: one nudge per file per 30 minutes. Remove: docguard hooks --claude --remove${c.reset}\n`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Runtime for the PostToolUse hook (`docguard nudge-hook`). Reads the Claude
|
|
431
|
+
* Code hook payload from stdin, classifies the edited file, and emits a
|
|
432
|
+
* `{"decision":"block","reason":β¦}` nudge on stdout when β and only when β
|
|
433
|
+
* DocGuard has something specific to say. stdout is the machine channel:
|
|
434
|
+
* nothing else may be printed. Errors NEVER propagate (exit 0, silent) β a
|
|
435
|
+
* broken nudge must not degrade the agent session.
|
|
436
|
+
*/
|
|
437
|
+
export function runNudgeHook(projectDir) {
|
|
438
|
+
try {
|
|
439
|
+
const payload = JSON.parse(readFileSync(0, 'utf-8'));
|
|
440
|
+
const toolInput = payload.tool_input || payload.toolInput || {};
|
|
441
|
+
const filePath = toolInput.file_path || toolInput.filePath;
|
|
442
|
+
if (!filePath) return;
|
|
443
|
+
|
|
444
|
+
const rel = relative(projectDir, resolve(projectDir, filePath)).replace(/\\/g, '/');
|
|
445
|
+
if (!rel || rel.startsWith('..')) return;
|
|
446
|
+
|
|
447
|
+
let reason = null;
|
|
448
|
+
const base = basename(rel);
|
|
449
|
+
const isCanonicalDoc =
|
|
450
|
+
(rel.startsWith('docs-canonical/') && rel.endsWith('.md')) ||
|
|
451
|
+
(NUDGE_AGENT_FILES.has(base) && !rel.includes('/'));
|
|
452
|
+
|
|
453
|
+
if (isCanonicalDoc) {
|
|
454
|
+
reason = `DocGuard: you edited ${rel} β a canonical/agent doc that gates this repo. ` +
|
|
455
|
+
`Before finishing, run \`docguard guard --changed-only\` to confirm links, anchors, and count claims still hold.`;
|
|
456
|
+
} else if (NUDGE_CODE_EXT.test(rel)) {
|
|
457
|
+
const referencingDocs = docsReferencingFile(projectDir, base);
|
|
458
|
+
if (referencingDocs.length > 0) {
|
|
459
|
+
reason = `DocGuard: ${referencingDocs.slice(0, 3).join(', ')} reference${referencingDocs.length === 1 ? 's' : ''} ` +
|
|
460
|
+
`\`${base}\` β after your edits, run \`docguard impact\` to see which doc sections to re-read or update.`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!reason) return;
|
|
464
|
+
|
|
465
|
+
// Throttle per file β iterative edits must not spam the agent.
|
|
466
|
+
const statePath = resolve(projectDir, NUDGE_STATE_PATH);
|
|
467
|
+
let state = {};
|
|
468
|
+
try { state = JSON.parse(readFileSync(statePath, 'utf-8')); } catch { /* first run */ }
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
if (state[rel] && now - state[rel] < NUDGE_THROTTLE_MS) return;
|
|
471
|
+
state[rel] = now;
|
|
472
|
+
try {
|
|
473
|
+
mkdirSync(resolve(projectDir, '.docguard'), { recursive: true });
|
|
474
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
475
|
+
} catch { /* state is best-effort; still nudge */ }
|
|
476
|
+
|
|
477
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
|
|
478
|
+
} catch {
|
|
479
|
+
// Silent by contract.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Which canonical/agent docs mention this basename? Cheap line scan. */
|
|
484
|
+
function docsReferencingFile(projectDir, base) {
|
|
485
|
+
const docs = [];
|
|
486
|
+
const check = (name, full) => {
|
|
487
|
+
try {
|
|
488
|
+
if (readFileSync(full, 'utf-8').includes(base)) docs.push(name);
|
|
489
|
+
} catch { /* unreadable */ }
|
|
490
|
+
};
|
|
491
|
+
const dir = resolve(projectDir, 'docs-canonical');
|
|
492
|
+
if (existsSync(dir)) {
|
|
493
|
+
try {
|
|
494
|
+
for (const f of readdirSync(dir)) {
|
|
495
|
+
if (f.endsWith('.md')) check(f, resolve(dir, f));
|
|
496
|
+
}
|
|
497
|
+
} catch { /* unreadable dir */ }
|
|
498
|
+
}
|
|
499
|
+
for (const a of NUDGE_AGENT_FILES) {
|
|
500
|
+
const p = resolve(projectDir, a);
|
|
501
|
+
if (existsSync(p)) check(a, p);
|
|
502
|
+
}
|
|
503
|
+
return docs;
|
|
504
|
+
}
|