docguard-cli 0.36.1 → 0.37.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 (39) hide show
  1. package/README.md +23 -18
  2. package/cli/commands/diagnose.mjs +3 -22
  3. package/cli/commands/explain.mjs +28 -0
  4. package/cli/commands/guard.mjs +4 -0
  5. package/cli/commands/llms.mjs +3 -2
  6. package/cli/commands/retire.mjs +352 -0
  7. package/cli/commands/specs.mjs +77 -0
  8. package/cli/commands/trace.mjs +24 -35
  9. package/cli/config.mjs +2 -0
  10. package/cli/docguard.mjs +74 -14
  11. package/cli/findings.mjs +54 -0
  12. package/cli/scanners/document-lifecycle.mjs +184 -0
  13. package/cli/scanners/requirement-evidence.mjs +126 -0
  14. package/cli/scanners/spec-registry.mjs +517 -0
  15. package/cli/shared-requirements.mjs +91 -0
  16. package/cli/validators/docs-coverage.mjs +111 -61
  17. package/cli/validators/document-lifecycle.mjs +51 -0
  18. package/cli/validators/schema-sync.mjs +16 -11
  19. package/cli/validators/spec-registry.mjs +47 -0
  20. package/cli/validators/traceability.mjs +73 -199
  21. package/docs/ai-integration.md +18 -5
  22. package/docs/commands.md +45 -0
  23. package/docs/configuration.md +15 -0
  24. package/docs/quickstart.md +1 -1
  25. package/extensions/spec-kit-docguard/README.md +15 -5
  26. package/extensions/spec-kit-docguard/commands/brief.md +34 -0
  27. package/extensions/spec-kit-docguard/commands/preflight.md +52 -0
  28. package/extensions/spec-kit-docguard/extension.yml +18 -5
  29. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  31. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  32. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  33. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  34. package/extensions/spec-kit-docguard/templates/extensions.yml +13 -6
  35. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  36. package/package.json +1 -1
  37. package/schemas/docguard-config.schema.json +2 -0
  38. package/schemas/docguard-specs.schema.json +162 -0
  39. package/templates/ci/github-actions.yml +1 -1
package/README.md CHANGED
@@ -69,15 +69,15 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
69
69
 
70
70
  ```mermaid
71
71
  graph TD
72
- CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (20)"]
72
+ CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (22)"]
73
73
  Commands --> guard["guard"]
74
74
  Commands --> generate["generate"]
75
75
  Commands --> score["score"]
76
76
  Commands --> diagnose["diagnose"]
77
77
  Commands --> setup["setup wizard"]
78
- Commands --> other["diff · init · fix · trace · impact · sync<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
78
+ Commands --> other["diff · init · fix · trace · impact · sync · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
79
79
 
80
- guard --> Validators["Validators (27)"]
80
+ guard --> Validators["Validators (29)"]
81
81
  generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
82
82
  score --> Scoring["Weighted Scoring<br/>8 categories"]
83
83
  diagnose --> Validators
@@ -108,7 +108,7 @@ A guard result describes the checks performed. The CDD grade measures structural
108
108
 
109
109
  Research motivates evaluation of this approach. A 2026 study found that repository context files did not generally improve task success and increased inference cost in its evaluated settings. It also found agents generally followed the instructions. These results support testing concise, relevant context and measuring actual task outcomes; they do not establish DocGuard's effectiveness. [Evaluating AGENTS.md, revised June 2026](https://arxiv.org/abs/2602.11988v2).
110
110
 
111
- The development plan prioritizes accurate detection, reproducible evidence, and contributor-supplied regression cases. See [the trust roadmap](docs-implementation/TRUST-ROADMAP.md) for implementation status, proposed experiments, and acceptance criteria.
111
+ The [current roadmap](ROADMAP.md) prioritizes accurate detection, reproducible evidence, document lifecycle management, and contributor-supplied regression cases. Released plans and superseded specifications are removed from active AI context and remain recoverable from Git.
112
112
 
113
113
  ---
114
114
 
@@ -268,14 +268,14 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
268
268
 
269
269
  ## Usage
270
270
 
271
- DocGuard ships **20 commands** (the "Daily 5" + 15 situational tools, including the zero-install `demo`, the `mcp` server, and the `ci` pipeline gate). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Seven v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
271
+ DocGuard ships **22 commands** (the "Daily 5" + 17 situational tools, including lifecycle retirement and spec tracking, the zero-install `demo`, the `mcp` server, and the `ci` pipeline gate). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Legacy command forms remain compatible until v1.0 and print their replacements.
272
272
 
273
273
  **The Daily 5** — what you'll reach for 95% of the time:
274
274
 
275
275
  | Command | What It Does |
276
276
  |:--------|:-------------|
277
277
  | `init` | Bootstrap a project (`--wizard` for interactive · `--with <name>` for scaffolders) |
278
- | `guard` | Validate against canonical docs — 27 validators |
278
+ | `guard` | Validate against canonical docs — 29 validators |
279
279
  | `diff` | Show gaps between docs and code (`--since <ref>` for impact mode) |
280
280
  | `sync` | Refresh code-truth doc sections — keeps memory always up to date |
281
281
  | `score` | CDD maturity score (0-100; `--diff` for delta between refs) |
@@ -295,6 +295,9 @@ DocGuard ships **20 commands** (the "Daily 5" + 15 situational tools, including
295
295
  | `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 |
296
296
  | `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 |
297
297
  | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
298
+ | `retire` | Find completed or superseded planning material (`--plan`/`--check`; `--fail-on-warning` gates advisory candidates) and explicitly remove clean tracked documentation from active AI context. `.docguard-archive.json` records recovery metadata and retired requirement identities, and `--retention-ref` proves the source revision remains reachable. This is separate from the Spec Kit Archive extension, which consolidates feature documents. |
299
+ | `specs --check` / `specs --write` | Validate or refresh `.docguard-specs.json`, the byte-stable index of immutable spec IDs, reviewed lifecycle/lineage/scope, artifact digests, task state, explicitly scoped test evidence, and archive tombstones. Refreshes preserve the reviewed block. |
300
+ | `specs preflight [--path <spec>]` | Before specification, print current spec lifecycle and evidence. Before planning, check the generated draft for structural blockers and report semantic overlap as review-only evidence. |
298
301
  | `mcp` | MCP server — exposes guard/score/explain/verify/report/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`) |
299
302
  | `report` | Compliance-evidence bundle for audits — guard verdict + CDD score + ALCOA+ attributes + fix history, stamped with git commit and a tamper-evident sha256 integrity hash (`--format json`, `--out <file>`). Evidence, not a gate: always exits 0 |
300
303
  | `ci` | Pipeline gate: guard + score in one command — never scaffolds or touches source; its only write is its own `.docguard/history.jsonl` (opt out: `--no-history`). `--threshold <n>` fails below a score, `--fail-on-warning` for strict mode, `--format json` for parsers |
@@ -321,7 +324,7 @@ DocGuard ships **20 commands** (the "Daily 5" + 15 situational tools, including
321
324
 
322
325
  Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with agents,hooks,badge,ci`).
323
326
 
324
- **Deprecation aliases** — `setup` · `agents` · `hooks` · `ci` · `badge` · `llms` · `publish` · `impact` keep working in v0.20.x with a yellow stderr warning. `audit → guard` is permanent (no warning). See [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
327
+ **Deprecation aliases** — `setup` · `agents` · `hooks` · `badge` · `llms` · `publish` · `impact` remain compatible until v1.0 with a yellow stderr warning. `audit → guard` is permanent and silent; `ci` is a current first-class pipeline command.
325
328
 
326
329
  ### CLI Flags
327
330
 
@@ -330,7 +333,7 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
330
333
  | `--dir <path>` | Project directory (default: `.`) | All |
331
334
  | `--verbose` | Show detailed output | All |
332
335
  | `--quiet` / `-q` | Suppress banner — for hooks, CI loops, scripts | All |
333
- | `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain |
336
+ | `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain, retire, specs |
334
337
  | `--format sarif` | SARIF 2.1.0 output — findings as rules/results for GitHub Code Scanning and SARIF dashboards | guard |
335
338
  | `--format junit` | JUnit XML output — one testcase per validator, for GitLab CI (`artifacts:reports:junit`), Jenkins, Azure DevOps, CircleCI | guard |
336
339
  | `--update-baseline` | Adopt DocGuard on a legacy repo without a red day one: freeze today's findings into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift. Suppression is always visible ("N pre-existing finding(s) suppressed"), and `--no-baseline` shows the full picture | guard |
@@ -389,7 +392,7 @@ $ npx docguard-cli generate
389
392
 
390
393
  ## 🔍 Validators
391
394
 
392
- 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.
395
+ DocGuard runs **29 automated validators** on every `guard` check. Source-facing validators are language-aware where their evidence model applies; repository and document validators operate independently of source language.
393
396
 
394
397
  | # | Validator | What It Checks | Default |
395
398
  |:--|:----------|:--------------|:--------|
@@ -412,14 +415,16 @@ DocGuard runs **27 automated validators** on every `guard` check. Every one is *
412
415
  | 17 | **TODO-Tracking** | Untracked TODOs/FIXMEs and skipped tests (skips test files by default) | ✅ On |
413
416
  | 18 | **Schema-Sync** | Database models documented in DATA-MODEL.md | ✅ On |
414
417
  | 19 | **Spec-Kit** | Spec quality validation (FR-IDs, mandatory sections, phased tasks) | ✅ On |
415
- | 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 |
416
- | 21 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | ✅ On |
417
- | 22 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only N/A elsewhere) | ✅ On |
418
- | 23 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
419
- | 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 |
420
- | 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 |
421
- | 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 |
422
- | 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 |
418
+ | 20 | **Document-Lifecycle** | Exact terminal states, advisory completion signals, incomplete coverage, and manifest/working-tree inconsistencies | ✅ On |
419
+ | 21 | **Spec-Registry** | Immutable spec identities, byte-stable evidence projection, reviewed lifecycle preservation, and archive/storage consistency | ✅ On |
420
+ | 22 | **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 |
421
+ | 23 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | ✅ On |
422
+ | 24 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only N/A elsewhere) | ✅ On |
423
+ | 25 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
424
+ | 26 | **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 |
425
+ | 27 | **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 |
426
+ | 28 | **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 |
427
+ | 29 | **API-Doc-Smells** | Bloated (≥300 words) / Lazy (≤6 prose words) API documentation units, keyed on signature-headed sections (F1 0.90/0.95) | ✅ On |
423
428
 
424
429
  **Per-validator controls** (in `.docguard.json`):
425
430
  ```json
@@ -510,7 +515,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
510
515
  | Command | What It Does |
511
516
  |:--------|:-------------|
512
517
  | `/docguard.init` | Initialize Canonical-Driven Development in a new or existing project |
513
- | `/docguard.guard` | Run quality validation — check all 27 validators |
518
+ | `/docguard.guard` | Run quality validation — check all 29 validators |
514
519
  | `/docguard.review` | Analyze doc quality and suggest improvements |
515
520
  | `/docguard.fix` | Generate targeted fix prompts for specific issues |
516
521
  | `/docguard.update` | Update canonical docs after code changes — detect drift and sync documentation |
@@ -101,10 +101,8 @@ const FIX_INSTRUCTIONS = {
101
101
  autoFixable: false,
102
102
  },
103
103
  'Freshness': {
104
- action: 'Review stale documents',
105
- command: 'docguard fix --doc',
106
- llmCommand: '/docguard.fix --doc',
107
- description: 'Documents haven\'t been reviewed since recent code changes. Re-run fix --doc for each stale doc.',
104
+ action: 'Review document evidence against relevant changes',
105
+ description: 'Review signals do not establish incorrect documentation. Check whether the documentation or implementation needs a change; preserve approved intent.',
108
106
  autoFixable: false,
109
107
  },
110
108
  // ── Routed (Phase F): these used to fall through to a generic "Manual review needed" ──
@@ -257,23 +255,6 @@ export function runDiagnose(projectDir, config, flags) {
257
255
  }
258
256
  }
259
257
 
260
- // Detect stale docs from freshness and map to specific fix --doc targets
261
- for (const issue of issues) {
262
- if (issue.validator === 'Freshness' && !issue.docTarget) {
263
- const match = issue.message.match(/([\w-]+\.md)/i);
264
- if (match) {
265
- const docName = match[1].toLowerCase().replace('.md', '');
266
- const docMap = { 'architecture': 'architecture', 'data-model': 'data-model', 'security': 'security', 'test-spec': 'test-spec', 'environment': 'environment' };
267
- issue.docTarget = docMap[docName] || null;
268
- if (issue.docTarget) {
269
- issue.command = agentMode === 'llm'
270
- ? `/docguard.fix --doc ${issue.docTarget}`
271
- : `docguard fix --doc ${issue.docTarget}`;
272
- }
273
- }
274
- }
275
- }
276
-
277
258
  // ── Step 4: Output ──
278
259
  if (flags.format === 'json') {
279
260
  outputJSON(guardData, scoreData, issues);
@@ -497,7 +478,7 @@ function outputPrompt(projectDir, guardData, scoreData, issues, flags, agentMode
497
478
  } else {
498
479
  lines.push('After making all fixes, run: docguard guard');
499
480
  }
500
- lines.push('Expected result: All checks pass (0 errors, 0 warnings)');
481
+ lines.push('Expected result: Resolve verified defects; explain remaining review signals and unsupported checks. Do not rewrite correct documents merely to remove warnings.');
501
482
  lines.push(`Structural baseline: ${scoreData.score}/100. Resolve evidenced defects; verify material claims separately.`);
502
483
  lines.push('Preserve approved requirements when implementation disagrees. A higher score is not proof of factual correctness.');
503
484
 
@@ -252,6 +252,32 @@ const EXPLAINERS = {
252
252
  example: 'plan.md has Summary, Technical Context, Constitution Check, Project Structure',
253
253
  standard: 'GitHub Spec Kit',
254
254
  },
255
+ documentLifecycle: {
256
+ title: 'Document-Lifecycle — retired documents leave active AI context safely',
257
+ what: 'Scans Git-tracked Markdown for exact terminal lifecycle metadata, completed specs that merit review, and disagreement between the working tree and .docguard-archive.json. It does not infer that completed work is safe to retire.',
258
+ why: 'Historical specs and plans can mislead people and agents when they remain mixed with current intent. Retirement must preserve recovery evidence and avoid deleting still-referenced context.',
259
+ triggers: [
260
+ ['declares terminal lifecycle status', 'Review the document, its backreferences, and its replacement or evidence, then retire the explicit clean tracked path with `docguard retire --write`.'],
261
+ ['artifact maturity is', 'Completion is only a review signal. Confirm the implemented outcome is represented in current documentation before considering retirement.'],
262
+ ['fully checked task list', 'Checked tasks do not prove delivery or documentation reconciliation. Verify code/test evidence and current docs.'],
263
+ ['Document lifecycle coverage is', 'Restore Git access, repair the retirement manifest, or make tracked Markdown readable before relying on the lifecycle result.'],
264
+ ['recorded as retired but remains', 'Complete the recorded retirement or correct the manifest so active context and recovery metadata agree.'],
265
+ ],
266
+ example: 'A superseded plan is absent from the working tree, recorded in .docguard-archive.json with a retained Git ref, and linked to its current replacement.',
267
+ standard: 'Canonical-Driven Development lifecycle and Git-backed recovery contract',
268
+ },
269
+ specRegistry: {
270
+ title: 'Spec-Registry — one lifecycle index for intent, delivery, and evidence',
271
+ what: 'Checks that every active spec has an immutable identity and that `.docguard-specs.json` exactly reflects spec artifacts, task counts, explicit test references, and archive tombstones while preserving reviewed lifecycle, lineage, and scope fields.',
272
+ why: 'Agents need to know which intent is current and what evidence exists before they create another spec. A deterministic registry makes stale state visible without copying or rewriting requirement prose.',
273
+ triggers: [
274
+ ['registry is missing or stale', 'Run `docguard specs --write`, review the projection, and commit it with the relevant change.'],
275
+ ['identity missing or reused', 'Add one unique namespaced Spec ID to the authoritative spec metadata; never recycle a retired ID.'],
276
+ ['lifecycle contradicts storage', 'Align current/retired context and working_tree/git_history storage with the recovery archive.'],
277
+ ],
278
+ example: '`docguard specs preflight --path specs/007-feature/spec.md` blocks reused IDs and reports prior lifecycle/evidence before planning.',
279
+ standard: 'Canonical-Driven Development lifecycle registry contract',
280
+ },
255
281
 
256
282
  // ── Backfilled in v0.24 (field report, Issue A) ─────────────────────────
257
283
  // These validators were registered in guard but had no explain entry, so
@@ -377,6 +403,8 @@ const DISPLAY_NAMES = {
377
403
  todoTracking: 'TODO-Tracking',
378
404
  schemaSync: 'Schema-Sync',
379
405
  specKit: 'Spec-Kit',
406
+ documentLifecycle: 'Document-Lifecycle',
407
+ specRegistry: 'Spec-Registry',
380
408
  crossReference: 'Cross-Reference',
381
409
  generatedStaleness: 'Generated-Staleness',
382
410
  surfaceSync: 'Surface-Sync',
@@ -155,6 +155,8 @@ import { validateSurfaceSync } from '../validators/surface-sync.mjs';
155
155
  import { validateDiffSuspicion } from '../validators/diff-suspicion.mjs';
156
156
  import { validateReferenceExistence } from '../validators/reference-existence.mjs';
157
157
  import { validateApiDocSmells } from '../validators/api-doc-smells.mjs';
158
+ import { validateDocumentLifecycle } from '../validators/document-lifecycle.mjs';
159
+ import { validateSpecRegistry } from '../validators/spec-registry.mjs';
158
160
 
159
161
  /**
160
162
  * Internal guard — returns structured data, no console output, no process.exit.
@@ -319,6 +321,8 @@ export function runGuardInternal(projectDir, config) {
319
321
  { key: 'todoTracking', name: 'TODO-Tracking', fn: () => validateTodoTracking(projectDir, config) },
320
322
  { key: 'schemaSync', name: 'Schema-Sync', fn: () => validateSchemaSync(projectDir, config) },
321
323
  { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
324
+ { key: 'documentLifecycle', name: 'Document-Lifecycle', fn: () => validateDocumentLifecycle(projectDir, config) },
325
+ { key: 'specRegistry', name: 'Spec-Registry', fn: () => validateSpecRegistry(projectDir, config) },
322
326
  { key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
323
327
  { key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
324
328
  { key: 'surfaceSync', name: 'Surface-Sync', fn: () => validateSurfaceSync(projectDir, config) },
@@ -15,10 +15,11 @@
15
15
  * - `docguard guard` validates llms.txt exists and is current
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { resolve, join, basename } from 'node:path';
20
20
  import { c } from '../shared.mjs';
21
21
  import { listCanonicalDocs } from '../shared-ignore.mjs';
22
+ import { safeWrite } from '../writers/generate-io.mjs';
22
23
 
23
24
  // ──── Doc descriptions for llms.txt ────
24
25
  const DOC_DESCRIPTIONS = {
@@ -201,7 +202,7 @@ export function runLlms(projectDir, config, flags) {
201
202
 
202
203
  const fileName = full ? 'llms-full.txt' : 'llms.txt';
203
204
  const outputPath = resolve(projectDir, fileName);
204
- writeFileSync(outputPath, content, 'utf-8');
205
+ safeWrite(outputPath, content);
205
206
 
206
207
  console.log(`${c.bold}📄 DocGuard ${fileName} Generator${c.reset}`);
207
208
  console.log(`${c.green}✅ Generated ${outputPath}${c.reset}`);
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Document lifecycle retirement.
3
+ *
4
+ * Historical prose leaves the working tree so agents cannot mistake it for
5
+ * current intent. Git remains the content store; a compact manifest records
6
+ * why each path left and how to restore it.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ lstatSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ unlinkSync,
16
+ } from 'node:fs';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
19
+ import { safeWrite } from '../writers/generate-io.mjs';
20
+ import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
21
+ import { parseSpecId, readSpecRegistry } from '../scanners/spec-registry.mjs';
22
+ import {
23
+ collectRequirementIdsFromContent,
24
+ requirementPatterns,
25
+ } from '../shared-requirements.mjs';
26
+
27
+ const MANIFEST_PATH = '.docguard-archive.json';
28
+ const PROTECTED_PREFIXES = ['.git', '.local', '.docguard'];
29
+ const DOCUMENT_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.adoc']);
30
+
31
+ function git(projectDir, args) {
32
+ const result = spawnSync('git', args, {
33
+ cwd: projectDir,
34
+ encoding: 'utf8',
35
+ maxBuffer: 8 * 1024 * 1024,
36
+ });
37
+ if (result.status !== 0) {
38
+ const detail = (result.stderr || result.stdout || '').trim();
39
+ throw new Error(detail || `git ${args.join(' ')} failed`);
40
+ }
41
+ return result.stdout;
42
+ }
43
+
44
+ function repositoryRoot(projectDir) {
45
+ const root = git(projectDir, ['rev-parse', '--show-toplevel']).trim();
46
+ const requested = realpathSync(projectDir);
47
+ if (realpathSync(root) !== requested) {
48
+ throw new Error('Retirement must run from the repository root. Pass --dir <repository-root>.');
49
+ }
50
+ return requested;
51
+ }
52
+
53
+ function toPosix(path) {
54
+ return path.split(sep).join('/').replace(/^\.\//, '');
55
+ }
56
+
57
+ function validateSelection(root, input) {
58
+ if (!input || input.includes('\0')) throw new Error('Retirement paths must be non-empty.');
59
+ const absolute = resolve(root, input);
60
+ const rel = toPosix(relative(root, absolute));
61
+ if (!rel || rel === '.' || rel === '..' || rel.startsWith('../')) {
62
+ throw new Error(`Retirement path must stay inside the repository: ${input}`);
63
+ }
64
+ if (PROTECTED_PREFIXES.some(prefix => rel === prefix || rel.startsWith(`${prefix}/`))) {
65
+ throw new Error(`Retirement refuses protected path: ${rel}`);
66
+ }
67
+ if (rel === MANIFEST_PATH) throw new Error(`Retirement refuses its recovery manifest: ${rel}`);
68
+ if (!existsSync(absolute)) throw new Error(`Retirement path does not exist: ${rel}`);
69
+ if (lstatSync(absolute).isSymbolicLink()) throw new Error(`Retirement refuses symlinks: ${rel}`);
70
+ const resolved = realpathSync(absolute);
71
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
72
+ throw new Error(`Retirement path resolves outside the repository: ${rel}`);
73
+ }
74
+ return rel;
75
+ }
76
+
77
+ function trackedFiles(projectDir, selections) {
78
+ const files = new Set();
79
+ for (const selection of selections) {
80
+ const output = git(projectDir, ['ls-files', '-z', '--', selection]);
81
+ const matches = output.split('\0').filter(Boolean);
82
+ if (matches.length === 0) throw new Error(`Retirement path is not tracked by Git: ${selection}`);
83
+ for (const match of matches) files.add(toPosix(match));
84
+ }
85
+ return [...files].sort();
86
+ }
87
+
88
+ function assertSelectionsContainOnlyTrackedFiles(projectDir, selections) {
89
+ for (const selection of selections) {
90
+ const status = git(projectDir, [
91
+ 'status', '--porcelain=v1', '--untracked-files=all', '--ignored', '--', selection,
92
+ ]);
93
+ if (status.split('\n').some(line => line.startsWith('?? ') || line.startsWith('!! '))) {
94
+ throw new Error(`Retirement selection contains untracked or ignored files: ${selection}`);
95
+ }
96
+ }
97
+ }
98
+
99
+ function shellQuote(value) {
100
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
101
+ }
102
+
103
+ function requiredFiles(config) {
104
+ const required = config?.requiredFiles || {};
105
+ const values = [
106
+ ...(Array.isArray(required.canonical) ? required.canonical : []),
107
+ ...(Array.isArray(required.agentFile) ? required.agentFile : []),
108
+ required.changelog,
109
+ required.driftLog,
110
+ ];
111
+ return new Set(values.filter(value => typeof value === 'string').map(toPosix));
112
+ }
113
+
114
+ function assertArchivable(projectDir, files, config) {
115
+ const protectedFiles = requiredFiles(config);
116
+ for (const file of files) {
117
+ if (!DOCUMENT_EXTENSIONS.has(extname(file).toLowerCase())) {
118
+ throw new Error(`Retirement accepts documentation files only; refusing ${file}.`);
119
+ }
120
+ if (protectedFiles.has(file)) {
121
+ throw new Error(`Retirement refuses required file ${file}; replace and reconfigure it first.`);
122
+ }
123
+ const absolute = resolve(projectDir, file);
124
+ if (!existsSync(absolute)) throw new Error(`Retirement path disappeared: ${file}`);
125
+ if (lstatSync(absolute).isSymbolicLink()) throw new Error(`Retirement refuses symlinks: ${file}`);
126
+ const status = git(projectDir, ['status', '--porcelain=v1', '--', file]).trim();
127
+ if (status) throw new Error(`Retirement requires a clean tracked file: ${file}`);
128
+ const stage = git(projectDir, ['ls-files', '--stage', '--', file]).trim();
129
+ if (stage.startsWith('160000 ')) throw new Error(`Retirement refuses Git submodules: ${file}`);
130
+ }
131
+ }
132
+
133
+ function assertCleanTrackedDocument(projectDir, input, label) {
134
+ const rel = validateSelection(projectDir, input);
135
+ const files = trackedFiles(projectDir, [rel]);
136
+ if (files.length !== 1 || files[0] !== rel || !DOCUMENT_EXTENSIONS.has(extname(rel).toLowerCase())) {
137
+ throw new Error(`${label} must name one tracked documentation file: ${rel}`);
138
+ }
139
+ const status = git(projectDir, ['status', '--porcelain=v1', '--', rel]).trim();
140
+ if (status) throw new Error(`${label} must be clean at the recorded source revision: ${rel}`);
141
+ return rel;
142
+ }
143
+
144
+ function assertNoLiveBackreferences(projectDir, files) {
145
+ const selected = new Set(files);
146
+ const docs = trackedFiles(projectDir, ['*.md']);
147
+ for (const doc of docs) {
148
+ if (selected.has(doc)) continue;
149
+ const absolute = resolve(projectDir, doc);
150
+ if (!existsSync(absolute)) continue;
151
+ let content;
152
+ try { content = readFileSync(absolute, 'utf8'); } catch { continue; }
153
+ const link = /!?\[[^\]]*\]\((?:<([^>]+)>|([^\s)]+))(?:\s+[^)]*)?\)/g;
154
+ let match;
155
+ while ((match = link.exec(content)) !== null) {
156
+ const raw = (match[1] || match[2] || '').split('#')[0].split('?')[0];
157
+ if (!raw || raw.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;
158
+ let decoded = raw;
159
+ try { decoded = decodeURIComponent(raw); } catch { /* use literal path */ }
160
+ const target = toPosix(relative(projectDir, resolve(dirname(absolute), decoded)));
161
+ if (!selected.has(target)) continue;
162
+ const line = content.slice(0, match.index).split('\n').length;
163
+ throw new Error(`Retirement refuses ${target}; live backreference remains at ${doc}:${line}.`);
164
+ }
165
+ }
166
+ }
167
+
168
+ function assertNoCurrentRegisteredSpec(projectDir, files) {
169
+ const loaded = readSpecRegistry(projectDir);
170
+ if (loaded.error) throw new Error(`Retirement cannot verify spec lifecycle: ${loaded.error}`);
171
+ if (!loaded.exists) return;
172
+ for (const entry of loaded.value.specs) {
173
+ const lifecycle = entry.reviewed?.lifecycle || entry.lifecycle;
174
+ if (lifecycle?.context !== 'current') continue;
175
+ const specDir = dirname(entry.path);
176
+ const selected = files.find(path => path === entry.path || path.startsWith(`${specDir}/`));
177
+ if (selected) {
178
+ throw new Error(`Retirement refuses active registered spec ${entry.specId} (${selected}). Transition it through the docguard specs lifecycle before removing its artifacts.`);
179
+ }
180
+ }
181
+ }
182
+
183
+ function refExists(projectDir, ref) {
184
+ const result = spawnSync('git', ['show-ref', '--verify', '--quiet', ref], { cwd: projectDir });
185
+ return result.status === 0;
186
+ }
187
+
188
+ function retentionRef(projectDir, explicit) {
189
+ let ref = explicit?.trim() || null;
190
+ if (!ref) {
191
+ const remoteHead = spawnSync('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
192
+ cwd: projectDir,
193
+ encoding: 'utf8',
194
+ });
195
+ if (remoteHead.status === 0) ref = remoteHead.stdout.trim();
196
+ }
197
+ if (!ref && refExists(projectDir, 'refs/heads/main')) ref = 'refs/heads/main';
198
+ if (!ref && refExists(projectDir, 'refs/heads/master')) ref = 'refs/heads/master';
199
+ if (!ref) {
200
+ throw new Error('Retirement needs a retained branch ref. Pass --retention-ref <ref> after pushing the source revision.');
201
+ }
202
+ git(projectDir, ['rev-parse', '--verify', ref]);
203
+ const contains = spawnSync('git', ['merge-base', '--is-ancestor', 'HEAD', ref], { cwd: projectDir });
204
+ if (contains.status !== 0) {
205
+ throw new Error(`Retirement source HEAD is not retained by ${ref}; push or merge it before retirement.`);
206
+ }
207
+ return ref;
208
+ }
209
+
210
+ function loadManifest(projectDir) {
211
+ const path = resolve(projectDir, MANIFEST_PATH);
212
+ if (!existsSync(path)) return { schemaVersion: 1, strategy: 'git-history', entries: [] };
213
+ let parsed;
214
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch {
215
+ throw new Error(`${MANIFEST_PATH} is not valid JSON.`);
216
+ }
217
+ if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
218
+ throw new Error(`${MANIFEST_PATH} does not use the supported schema.`);
219
+ }
220
+ return parsed;
221
+ }
222
+
223
+ function writeArchive(projectDir, config, flags) {
224
+ if (!flags.paths?.length) throw new Error('Retire --write requires at least one --path <file-or-directory>.');
225
+ if (!flags.reason?.trim()) throw new Error('Retire --write requires --reason <why-this-is-no-longer-current>.');
226
+
227
+ const selections = flags.paths.map(path => validateSelection(projectDir, path));
228
+ const supersededBy = flags.supersededBy
229
+ ? assertCleanTrackedDocument(projectDir, flags.supersededBy, 'Replacement')
230
+ : null;
231
+ const evidence = (flags.evidencePaths || []).map(path =>
232
+ assertCleanTrackedDocument(projectDir, path, 'Evidence'));
233
+ assertSelectionsContainOnlyTrackedFiles(projectDir, selections);
234
+ const files = trackedFiles(projectDir, selections);
235
+ if (supersededBy) {
236
+ git(projectDir, ['ls-files', '--error-unmatch', '--', supersededBy]);
237
+ if (files.includes(supersededBy)) throw new Error('A replacement document cannot be archived in the same operation.');
238
+ }
239
+ const selectedEvidence = evidence.find(path => files.includes(path));
240
+ if (selectedEvidence) throw new Error(`Evidence cannot be retired in the same operation: ${selectedEvidence}`);
241
+ assertArchivable(projectDir, files, config);
242
+ assertNoCurrentRegisteredSpec(projectDir, files);
243
+ assertNoLiveBackreferences(projectDir, files);
244
+
245
+ const commit = git(projectDir, ['rev-parse', 'HEAD']).trim();
246
+ const retainedBy = retentionRef(projectDir, flags.retentionRef);
247
+ const objectFormat = git(projectDir, ['rev-parse', '--show-object-format']).trim();
248
+ const archivedAt = new Date().toISOString();
249
+ const manifest = loadManifest(projectDir);
250
+ const patterns = requirementPatterns(config);
251
+ const specIdsByDirectory = new Map();
252
+ for (const path of files) {
253
+ if (basename(path).toLowerCase() !== 'spec.md') continue;
254
+ const specId = parseSpecId(readFileSync(resolve(projectDir, path), 'utf8'));
255
+ if (specId) specIdsByDirectory.set(dirname(path), specId);
256
+ }
257
+ const entries = files.map(path => {
258
+ const content = readFileSync(resolve(projectDir, path), 'utf8');
259
+ const requirementIds = [...new Set(
260
+ [...collectRequirementIdsFromContent(content, path, patterns).values()]
261
+ .map(definition => definition.id),
262
+ )].sort();
263
+ return {
264
+ path,
265
+ archivedAt,
266
+ archivedFrom: commit,
267
+ blob: git(projectDir, ['rev-parse', `HEAD:${path}`]).trim(),
268
+ reason: flags.reason.trim(),
269
+ ...(specIdsByDirectory.get(dirname(path)) ? { specId: specIdsByDirectory.get(dirname(path)) } : {}),
270
+ ...(supersededBy ? { supersededBy } : {}),
271
+ ...(evidence.length > 0 ? { evidence } : {}),
272
+ ...(requirementIds.length > 0 ? { requirementIds } : {}),
273
+ retentionRef: retainedBy,
274
+ objectFormat,
275
+ recoverability: 'verified',
276
+ restore: `git restore --source=${shellQuote(commit)} -- ${shellQuote(path)}`,
277
+ };
278
+ });
279
+
280
+ const next = { ...manifest, entries: [...manifest.entries, ...entries] };
281
+ assertArchivable(projectDir, files, config);
282
+ const removed = [];
283
+ try {
284
+ for (const file of files) {
285
+ unlinkSync(resolve(projectDir, file));
286
+ removed.push(file);
287
+ }
288
+ safeWrite(resolve(projectDir, MANIFEST_PATH), `${JSON.stringify(next, null, 2)}\n`);
289
+ } catch (error) {
290
+ if (removed.length > 0) {
291
+ spawnSync('git', ['restore', `--source=${commit}`, '--', ...removed], { cwd: projectDir });
292
+ }
293
+ throw error;
294
+ }
295
+
296
+ const directories = [...new Set(files.map(dirname))]
297
+ .filter(path => path !== '.')
298
+ .sort((a, b) => b.length - a.length);
299
+ for (const directory of directories) {
300
+ try { rmSync(resolve(projectDir, directory)); } catch { /* non-empty directories remain */ }
301
+ }
302
+ return { status: 'ARCHIVED', strategy: 'git-history', manifest: MANIFEST_PATH, entries };
303
+ }
304
+
305
+ function printText(result) {
306
+ if (result.status === 'ARCHIVED') {
307
+ console.log(`Retired ${result.entries.length} tracked file(s) from the working tree.`);
308
+ console.log(`Manifest: ${result.manifest}`);
309
+ for (const entry of result.entries) console.log(` ${entry.path}`);
310
+ return;
311
+ }
312
+ console.log('DocGuard Retirement Plan');
313
+ console.log('Strategy: Git history + compact manifest (obsolete prose is not copied).');
314
+ if (result.candidates.length === 0) {
315
+ console.log('No lifecycle candidates found.');
316
+ } else {
317
+ for (const candidate of result.candidates) {
318
+ console.log(` [${candidate.confidence}] ${candidate.path} — ${candidate.reason}`);
319
+ }
320
+ }
321
+ console.log('Review candidates, then retire explicitly with --write --path <path> --reason <reason>.');
322
+ }
323
+
324
+ export function runArchive(projectDir, config, flags = {}) {
325
+ try {
326
+ const root = repositoryRoot(projectDir);
327
+ const result = flags.write
328
+ ? writeArchive(root, config, flags)
329
+ : {
330
+ status: 'PLAN',
331
+ strategy: 'git-history',
332
+ manifest: MANIFEST_PATH,
333
+ ...scanDocumentLifecycle(root, config),
334
+ };
335
+ if (result.status === 'PLAN' && result.coverage.status !== 'complete') {
336
+ throw new Error(result.coverage.error || `Lifecycle scan is ${result.coverage.status}.`);
337
+ }
338
+ if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
339
+ else printText(result);
340
+ if (flags.check && result.status === 'PLAN') {
341
+ const blocking = result.candidates.filter(candidate => candidate.confidence === 'high');
342
+ if (blocking.length > 0 || (flags.failOnWarning && result.candidates.length > 0)) process.exitCode = 2;
343
+ }
344
+ return result;
345
+ } catch (error) {
346
+ const result = { status: 'ERROR', error: error.message };
347
+ if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
348
+ else console.error(`Retirement failed: ${error.message}`);
349
+ process.exitCode = 1;
350
+ return result;
351
+ }
352
+ }