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
@@ -0,0 +1,77 @@
1
+ /** Manage and preflight the deterministic spec lifecycle registry. */
2
+
3
+ import { resolve } from 'node:path';
4
+ import { safeWrite } from '../writers/generate-io.mjs';
5
+ import { preflightSpec, projectSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
6
+
7
+ function printIssues(issues) {
8
+ for (const issue of issues) console.log(` ${issue.code} ${issue.path}: ${issue.message}`);
9
+ }
10
+
11
+ function printResult(result) {
12
+ if (result.command === 'preflight') {
13
+ console.log(`Spec preflight: ${result.status}`);
14
+ console.log(`Current specs: ${result.briefing.length}`);
15
+ for (const spec of result.briefing) {
16
+ console.log(` ${spec.specId} — ${spec.approval}/${spec.delivery}; tasks ${spec.taskCompletion.checked}/${spec.taskCompletion.total}; test evidence ${spec.testEvidence}`);
17
+ }
18
+ if (result.blockers.length) {
19
+ console.log('Blockers:');
20
+ printIssues(result.blockers);
21
+ }
22
+ if (result.overlaps.length) {
23
+ console.log('Review-only semantic overlap:');
24
+ for (const overlap of result.overlaps) console.log(` ${(overlap.similarity * 100).toFixed(0)}% ${overlap.specId} (${overlap.path})`);
25
+ }
26
+ return;
27
+ }
28
+ console.log(`Spec registry: ${result.status}`);
29
+ console.log(`Registry: ${SPEC_REGISTRY_PATH}`);
30
+ console.log(`Specs: ${result.specs}; tombstones: ${result.tombstones}`);
31
+ if (result.issues.length) printIssues(result.issues);
32
+ }
33
+
34
+ export function runSpecs(projectDir, config, flags = {}) {
35
+ try {
36
+ const action = flags.args?.[0] || null;
37
+ if (action && action !== 'preflight') throw new Error(`Unknown specs action: ${action}`);
38
+ if (flags.check && flags.write) throw new Error('Use either --check or --write, not both.');
39
+
40
+ if (action === 'preflight') {
41
+ if (flags.write) throw new Error('Specs preflight is read-only.');
42
+ const result = { command: 'preflight', ...preflightSpec(projectDir, config, flags.path || null) };
43
+ if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
44
+ else printResult(result);
45
+ if (result.status === 'BLOCKED') process.exitCode = 2;
46
+ return result;
47
+ }
48
+
49
+ const projection = projectSpecRegistry(projectDir, config);
50
+ if (flags.write && projection.issues.length > 0) {
51
+ throw new Error(`Registry refresh refused: ${projection.issues.map(issue => issue.message).join(' ')}`);
52
+ }
53
+ let status = projection.current ? 'CURRENT' : projection.exists ? 'STALE' : 'MISSING';
54
+ if (flags.write && !projection.current) {
55
+ safeWrite(resolve(projectDir, SPEC_REGISTRY_PATH), projection.serialized);
56
+ status = 'WRITTEN';
57
+ }
58
+ const result = {
59
+ command: 'specs',
60
+ status,
61
+ registry: SPEC_REGISTRY_PATH,
62
+ specs: projection.registry.specs.length,
63
+ tombstones: projection.registry.tombstones.length,
64
+ issues: projection.issues,
65
+ };
66
+ if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
67
+ else printResult(result);
68
+ if (flags.check && (status !== 'CURRENT' || result.issues.length > 0)) process.exitCode = 2;
69
+ return result;
70
+ } catch (error) {
71
+ const result = { command: 'specs', status: 'ERROR', error: error.message };
72
+ if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
73
+ else console.error(`Error: ${error.message}`);
74
+ process.exitCode = 1;
75
+ return result;
76
+ }
77
+ }
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, extname, basename, relative, dirname } from 'node:path';
11
11
  import { c } from '../shared.mjs';
12
12
  import { detectSpecKit } from '../scanners/speckit.mjs';
13
+ import { scanTestFilesForReferences, collectRequirementIds, resolveRequirementReferences } from '../validators/traceability.mjs';
13
14
  import { listCanonicalDocs } from '../shared-ignore.mjs';
14
15
 
15
16
  const IGNORE_DIRS = new Set([
@@ -378,7 +379,7 @@ function scanDir(rootDir, dir, files) {
378
379
  // repo-wide scores that `docguard score` produces. Deterministic signals only —
379
380
  // no LLM judgment:
380
381
  //
381
- // reqCoverage 40% FR-/SC- IDs in spec.md referenced by any test file
382
+ // reqCoverage 40% FR-/SC- IDs in spec.md explicitly annotated or labeled in test sources
382
383
  // taskCompletion 25% checked/total `- [x]` tasks in tasks.md
383
384
  // taskEvidence 20% checked tasks whose line names an existing file
384
385
  // artifactCompleteness 15% spec.md (40%) + plan.md (30%) + tasks.md (30%)
@@ -425,34 +426,31 @@ function featureBar(score) {
425
426
  }
426
427
 
427
428
  /**
428
- * Collect every FR-/SC- ID referenced anywhere in a test file, once for the
429
- * whole project. Test-file discovery mirrors the traceability validator's
430
- * scanTestFilesForReferences(): TEST_PATTERNS __tests__/ tests?/ dirs, and
431
- * any occurrence of the ID in file content counts (not just @req lines).
429
+ * Collect declared FR-/SC- test links once for the whole project, using the
430
+ * validator's source eligibility and annotation/label parser. This is positive
431
+ * linkage evidence, independent of validator findings or suppression policy.
432
432
  */
433
433
  function collectTestReferencedIds(projectDir) {
434
434
  const projectFiles = [];
435
435
  scanDir(projectDir, projectDir, projectFiles);
436
- const testFiles = projectFiles.filter(f =>
437
- TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
438
- );
439
-
440
- const ids = new Set();
441
- for (const rel of testFiles) {
442
- let content;
443
- try { content = readFileSync(resolve(projectDir, rel), 'utf-8'); } catch { continue; }
444
- FEATURE_REQ_RE.lastIndex = 0;
445
- let m;
446
- while ((m = FEATURE_REQ_RE.exec(content)) !== null) ids.add(m[0]);
436
+ const references = scanTestFilesForReferences(projectDir, projectFiles, [FEATURE_REQ_RE]);
437
+ // Feature completion evidence must survive spec retirement and ID reuse.
438
+ // Bare FR-001 references can be useful navigation for the repo-wide matrix,
439
+ // but they can rebind to a different feature after the original spec leaves
440
+ // the working tree. Keep only document-qualified identities here.
441
+ const qualified = new Map();
442
+ for (const [id, refs] of references) {
443
+ const scoped = refs.filter(ref => ref.scope);
444
+ if (scoped.length > 0) qualified.set(id, scoped);
447
445
  }
448
- return ids;
446
+ return qualified;
449
447
  }
450
448
 
451
449
  /**
452
450
  * Compute the four adherence signals for one detected spec-kit feature.
453
451
  * Each signal: { applicable, value (0..1 | null), ...n/m detail fields }.
454
452
  */
455
- function computeFeatureSignals(projectDir, feature, testRefIds) {
453
+ function computeFeatureSignals(projectDir, feature, testRefIds, definitions) {
456
454
  // ── artifactCompleteness — always measurable ──
457
455
  const artifactValue = (feature.hasSpec ? 0.4 : 0)
458
456
  + (feature.hasPlan ? 0.3 : 0)
@@ -483,21 +481,11 @@ function computeFeatureSignals(projectDir, feature, testRefIds) {
483
481
  }
484
482
  }
485
483
 
486
- // ── reqCoverage — spec.md IDs that appear in ANY test file ──
487
- const specIds = [];
488
- if (feature.hasSpec && feature.specPath) {
489
- try {
490
- const spec = readFileSync(feature.specPath, 'utf-8');
491
- const seen = new Set();
492
- FEATURE_REQ_RE.lastIndex = 0;
493
- let m;
494
- while ((m = FEATURE_REQ_RE.exec(spec)) !== null) {
495
- if (!seen.has(m[0])) { seen.add(m[0]); specIds.push(m[0]); }
496
- }
497
- } catch { /* unreadable spec → no IDs */ }
498
- }
499
- const covered = specIds.filter(id => testRefIds.has(id));
500
- const uncovered = specIds.filter(id => !testRefIds.has(id));
484
+ // ── reqCoverage — spec.md IDs declared in eligible test annotations or labels ──
485
+ const specPath = feature.specPath ? relative(projectDir, feature.specPath).replaceAll('\\', '/') : null;
486
+ const specIds = [...definitions.values()].filter(def => def.file === specPath).map(def => def.id);
487
+ const covered = specIds.filter(id => testRefIds.has(`${specPath}#${id}`));
488
+ const uncovered = specIds.filter(id => !testRefIds.has(`${specPath}#${id}`));
501
489
 
502
490
  return {
503
491
  reqCoverage: {
@@ -606,10 +594,11 @@ export function runTraceFeatures(projectDir, config, flags) {
606
594
  return;
607
595
  }
608
596
 
609
- const testRefIds = collectTestReferencedIds(projectDir);
597
+ const definitions = collectRequirementIds(projectDir, config, [FEATURE_REQ_RE]);
598
+ const testRefIds = resolveRequirementReferences(definitions, collectTestReferencedIds(projectDir));
610
599
 
611
600
  const features = speckit.specs.map(f => {
612
- const signals = computeFeatureSignals(projectDir, f, testRefIds);
601
+ const signals = computeFeatureSignals(projectDir, f, testRefIds, definitions);
613
602
  const score = scoreFromSignals(signals);
614
603
  const weakest = weakestSignal(signals);
615
604
  const needsFix = weakest !== null && signals[weakest].value < 1;
package/cli/config.mjs CHANGED
@@ -79,6 +79,8 @@ export function loadConfig(projectDir) {
79
79
  security: false,
80
80
  environment: true,
81
81
  freshness: true,
82
+ documentLifecycle: true,
83
+ specRegistry: true,
82
84
  // v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
83
85
  // heuristic (field cases require ongoing precision checks), and quiet when
84
86
  // not applicable (no diff / no API-reference doc). api-doc-smells is
package/cli/docguard.mjs CHANGED
@@ -51,6 +51,8 @@ import { runMemory } from './commands/memory.mjs';
51
51
  import { runDemo } from './commands/demo.mjs';
52
52
  import { runAgent } from './commands/agent.mjs';
53
53
  import { runMcp } from './commands/mcp.mjs';
54
+ import { runArchive } from './commands/retire.mjs';
55
+ import { runSpecs } from './commands/specs.mjs';
54
56
  import { ensureSkills } from './ensure-skills.mjs';
55
57
 
56
58
  // ── Shared constants (imported to break circular dependencies) ──────────
@@ -97,6 +99,8 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
97
99
  ${c.green}report${c.reset} Compliance-evidence bundle — guard + score + ALCOA+ + integrity hash (${c.cyan}--format json${c.reset}, ${c.cyan}--out <file>${c.reset})
98
100
  ${c.green}ci${c.reset} Pipeline gate: guard + score in one command (${c.cyan}--threshold <n>${c.reset}, ${c.cyan}--fail-on-warning${c.reset}, ${c.cyan}--format json${c.reset}; records score history)
99
101
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
102
+ ${c.green}retire${c.reset} Remove reviewed docs from active AI context (${c.cyan}--plan${c.reset}; explicit ${c.cyan}--write --path${c.reset})
103
+ ${c.green}specs${c.reset} Track spec lifecycle and evidence (${c.cyan}--check|--write${c.reset}; ${c.cyan}preflight --path <spec>${c.reset})
100
104
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
101
105
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
102
106
  ${c.green}watch${c.reset} Live mode: re-run guard on file changes
@@ -109,12 +113,12 @@ ${c.bold}init --with <name>${c.reset} ${c.dim}— optional scaffolders, picked a
109
113
  ${c.dim}llms${c.reset} llms.txt generation
110
114
  ${c.dim}publish${c.reset} External doc-site scaffold (Mintlify) ${c.dim}— experimental${c.reset}
111
115
 
112
- ${c.bold}Deprecation aliases${c.reset} ${c.dim}— still work in v0.20.x with a yellow warning${c.reset}
116
+ ${c.bold}Deprecation aliases${c.reset} ${c.dim}— supported until v1.0 with a yellow warning${c.reset}
113
117
  ${c.dim}setup${c.reset} → ${c.cyan}init --wizard${c.reset}
114
118
  ${c.dim}agents · hooks · badge · llms · publish${c.reset} → ${c.cyan}init --with <name>${c.reset}
115
119
  ${c.dim}impact${c.reset} → ${c.cyan}diff --since <ref>${c.reset}
116
120
  ${c.dim}audit${c.reset} → ${c.green}guard${c.reset} ${c.dim}(permanent — no warning, no removal planned)${c.reset}
117
- ${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full timeline.${c.reset}
121
+ ${c.dim}Run the legacy form to see its replacement.${c.reset}
118
122
 
119
123
  ${c.bold}Options:${c.reset}
120
124
  --dir <path> Project directory (default: current directory)
@@ -129,9 +133,9 @@ ${c.bold}Options:${c.reset}
129
133
  --threshold <n> Minimum score for CI pass (used with ci command)
130
134
  --fail-on-warning Fail CI on warnings (used with ci command)
131
135
  --auto Auto-fix what's possible (used with fix command)
132
- --write Apply deterministic fixes in place (fix command): removes
133
- documented endpoints the OpenAPI spec confirms are gone.
134
- Only edits docguard:generated docs unless --force.
136
+ --write Apply a command's explicit deterministic write path. For fix,
137
+ only edits docguard:generated docs unless --force; specs
138
+ refreshes observed registry evidence; retire requires --path.
135
139
  --plan AI-powered Generate (generate command): scan any project
136
140
  (JS/Python/Rust/Go/Java/…), emit the agent task manifest +
137
141
  code-truth skeleton. Add --write to scaffold, --format json
@@ -308,6 +312,35 @@ const COMMAND_HELP = {
308
312
  ],
309
313
  examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
310
314
  },
315
+ retire: {
316
+ summary: 'Remove reviewed docs from active AI context while preserving recovery from a retained Git ref.',
317
+ usage: 'docguard retire [--plan|--check] | --write --path <document> [--path <document>...] --reason <text> [--superseded-by <document>] [--evidence <document>...] [--retention-ref <ref>]',
318
+ flags: [
319
+ ['--plan', 'Read-only lifecycle candidate inventory (default behavior)'],
320
+ ['--check', 'Exit 2 when high-confidence lifecycle candidates remain'],
321
+ ['--fail-on-warning', 'With --check: also gate low-confidence review candidates'],
322
+ ['--write', 'Retire only the explicitly selected clean tracked documents'],
323
+ ['--path <path>', 'Documentation file or document-only directory; repeatable'],
324
+ ['--reason <text>', 'Required explanation recorded in the archive manifest'],
325
+ ['--superseded-by <path>', 'Current document that replaces the archived material'],
326
+ ['--evidence <path>', 'Clean current document that contains the consolidated outcome; repeatable'],
327
+ ['--retention-ref <ref>', 'Branch ref that must retain the source revision'],
328
+ ['--format json', 'Machine-readable plan or result'],
329
+ ],
330
+ examples: ['docguard retire --plan', 'docguard retire --check --format json', 'docguard retire --write --path specs/001-done --reason "Implemented in v1.2"'],
331
+ },
332
+ specs: {
333
+ summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
334
+ usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] [--format json]',
335
+ flags: [
336
+ ['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
337
+ ['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
338
+ ['preflight', 'Brief prior specs, or gate a generated draft with --path'],
339
+ ['--path <spec>', 'Generated spec to compare against current lifecycle state'],
340
+ ['--format json', 'Machine-readable registry or preflight result'],
341
+ ],
342
+ examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight', 'docguard specs preflight --path specs/007-feature/spec.md'],
343
+ },
311
344
  };
312
345
 
313
346
  function printCommandHelp(command) {
@@ -535,8 +568,26 @@ async function main() {
535
568
  flags.apiKey = args[i + 1];
536
569
  i++;
537
570
  } else if (args[i] === '--path' && args[i + 1]) {
538
- // mcp --transport http: HTTP mount path (default /mcp).
539
- flags.path = args[i + 1];
571
+ if (command === 'retire' || command === 'archive') {
572
+ flags.paths = flags.paths || [];
573
+ flags.paths.push(args[i + 1]);
574
+ } else {
575
+ // mcp --transport http: HTTP mount path (default /mcp).
576
+ flags.path = args[i + 1];
577
+ }
578
+ i++;
579
+ } else if (args[i] === '--reason' && args[i + 1]) {
580
+ flags.reason = args[i + 1];
581
+ i++;
582
+ } else if (args[i] === '--superseded-by' && args[i + 1]) {
583
+ flags.supersededBy = args[i + 1];
584
+ i++;
585
+ } else if (args[i] === '--evidence' && args[i + 1]) {
586
+ flags.evidencePaths = flags.evidencePaths || [];
587
+ flags.evidencePaths.push(args[i + 1]);
588
+ i++;
589
+ } else if (args[i] === '--retention-ref' && args[i + 1]) {
590
+ flags.retentionRef = args[i + 1];
540
591
  i++;
541
592
  } else if (args[i] === '--code') {
542
593
  flags.code = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
@@ -624,7 +675,7 @@ async function main() {
624
675
  // `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
625
676
  const READ_ONLY_COMMANDS = new Set([
626
677
  'guard', 'audit', 'score', 'diff', 'impact',
627
- 'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
678
+ 'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent', 'retire', 'archive', 'specs',
628
679
  // feedback only writes its own .docguard/feedback/ — it must NOT scaffold
629
680
  // skills or touch source, so it's gated out of ensureSkills like the rest.
630
681
  'feedback',
@@ -652,15 +703,16 @@ async function main() {
652
703
  command !== 'setup' &&
653
704
  command !== 'init' &&
654
705
  !READ_ONLY_COMMANDS.has(command) &&
706
+ // Agent-family staleness checks must not bootstrap skills or Spec Kit.
707
+ !(command === 'agents' && flags.check) &&
655
708
  !headless
656
709
  ) {
657
710
  ensureSkills(projectDir, flags);
658
711
  }
659
712
 
660
- // v0.20: deprecation aliases. The legacy command keeps working through v0.20
713
+ // v0.20: deprecation aliases. The legacy command keeps working until v1.0
661
714
  // and emits a yellow stderr warning suggesting the new shape. Quiet mode
662
715
  // (e.g. inside hooks) suppresses the warning so CI output stays clean.
663
- // The full deprecation timeline is in docs-implementation/MIGRATION-v0.20.md.
664
716
  const DEPRECATED_COMMANDS = {
665
717
  setup: { since: '0.20', replacement: 'docguard init --wizard' },
666
718
  agents: { since: '0.20', replacement: 'docguard init --with agents' },
@@ -692,7 +744,7 @@ async function main() {
692
744
  if (DROPPED_ALIASES[command]) {
693
745
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
694
746
  console.error(`${c.yellow}Hint: this alias was removed in v0.20. Try ${c.cyan}docguard ${DROPPED_ALIASES[command]}${c.yellow}.${c.reset}`);
695
- console.error(`${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full list.${c.reset}`);
747
+ console.error(`${c.dim}Use the replacement shown above; removed aliases are not restored.${c.reset}`);
696
748
  process.exit(1);
697
749
  }
698
750
 
@@ -701,7 +753,7 @@ async function main() {
701
753
  if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
702
754
  const { since, replacement } = DEPRECATED_COMMANDS[command];
703
755
  console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
704
- console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
756
+ console.error(`${c.dim} The old form remains compatible until v1.0.${c.reset}`);
705
757
  }
706
758
 
707
759
  switch (command) {
@@ -733,8 +785,9 @@ async function main() {
733
785
  }
734
786
  break;
735
787
  case 'agents':
736
- // v0.20: deprecated dispatches through init --with
737
- await runInit(projectDir, config, { ...flags, with: ['agents'], skipPrompts: true });
788
+ // A staleness check must bypass init's scaffolding and skill installation.
789
+ if (flags.check) runAgents(projectDir, config, flags);
790
+ else await runInit(projectDir, config, { ...flags, with: ['agents'], skipPrompts: true });
738
791
  break;
739
792
  case 'generate':
740
793
  runGenerate(projectDir, config, flags);
@@ -827,6 +880,13 @@ async function main() {
827
880
  case 'memory':
828
881
  runMemory(projectDir, config, flags);
829
882
  break;
883
+ case 'retire':
884
+ case 'archive': // Development alias; `retire` avoids collision with Spec Kit Archive.
885
+ runArchive(projectDir, config, flags);
886
+ break;
887
+ case 'specs':
888
+ runSpecs(projectDir, config, flags);
889
+ break;
830
890
  case 'demo':
831
891
  // v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
832
892
  // fixture (templates/demo-fixture/) and prints curated drift findings
package/cli/findings.mjs CHANGED
@@ -486,6 +486,60 @@ export const CODES = {
486
486
  help: 'Guard reports at most 10 phantom-completion findings (SPK008) per run to avoid noise; this line counts the remainder. Fix or uncheck the reported tasks and re-run guard to surface more, or set `"specKit": { "phantomCheck": false }` in .docguard.json to disable the check.',
487
487
  suppress: null,
488
488
  },
489
+ DLC001: {
490
+ validator: 'documentLifecycle',
491
+ title: 'Terminal document remains in active context',
492
+ help: 'A tracked Markdown file declares itself superseded, deprecated, obsolete, or archived but remains searchable as current repository context. Confirm its outcomes are represented in current docs, then use `docguard retire --write --path <path> --reason <reason>`.',
493
+ suppress: null,
494
+ },
495
+ DLC002: {
496
+ validator: 'documentLifecycle',
497
+ title: 'Completed task list needs lifecycle review',
498
+ help: 'A spec has checked tasks and no open tasks. This is a review signal, not proof that the feature shipped. Confirm release evidence and current documentation before archiving the spec.',
499
+ suppress: null,
500
+ },
501
+ DLC003: {
502
+ validator: 'documentLifecycle',
503
+ title: 'Document lifecycle coverage incomplete',
504
+ help: 'DocGuard could not enumerate or read every tracked Markdown document. Fix Git access or file readability and rerun guard; an incomplete lifecycle scan must not be interpreted as a clean result.',
505
+ suppress: null,
506
+ },
507
+ DLC004: {
508
+ validator: 'documentLifecycle',
509
+ title: 'Retired document remains active',
510
+ help: 'The retirement manifest records this path, but the document still exists in the working tree. Remove it through the reviewed retirement transaction or remove the incorrect manifest event; the two states must agree.',
511
+ suppress: null,
512
+ },
513
+ SPR001: {
514
+ validator: 'specRegistry',
515
+ title: 'Spec registry missing or stale',
516
+ help: 'The committed `.docguard-specs.json` does not match the deterministic projection of current specs, tasks, tests, and archive tombstones. Run `docguard specs --write`, review the diff, and commit it with the behavior change.',
517
+ suppress: null,
518
+ },
519
+ SPR002: {
520
+ validator: 'specRegistry',
521
+ title: 'Spec identity missing or reused',
522
+ help: 'Every active spec needs one immutable lowercase Spec ID in authoritative metadata. IDs survive moves and retirement and may never be reused. Add `**Spec ID**: ` followed by a unique namespaced ID in backticks.',
523
+ suppress: null,
524
+ },
525
+ SPR003: {
526
+ validator: 'specRegistry',
527
+ title: 'Spec registry is invalid',
528
+ help: 'The registry is malformed or contains an unsupported lifecycle value. DocGuard refuses to overwrite invalid reviewed state. Repair the named field, then run `docguard specs --write`.',
529
+ suppress: null,
530
+ },
531
+ SPR004: {
532
+ validator: 'specRegistry',
533
+ title: 'Spec lifecycle contradicts storage',
534
+ help: 'Working-tree presence, lifecycle context, storage state, and the recovery archive disagree. Active specs must be current/working_tree; retired specs must be retired/git_history with a reason and matching archive event.',
535
+ suppress: null,
536
+ },
537
+ SPR005: {
538
+ validator: 'specRegistry',
539
+ title: 'New spec overlaps prior intent',
540
+ help: 'Preflight found lexical overlap with a prior spec. This is a review hint, never proof of duplication or delivery. Inspect the prior lifecycle and evidence before adding an explicit supersedes or related relationship.',
541
+ suppress: null,
542
+ },
489
543
  XRF001: {
490
544
  validator: 'crossReference',
491
545
  title: 'Broken doc link',
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Read-only document lifecycle signals shared by `retire` and guard.
3
+ * Signals identify review candidates; they never authorize deletion.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { shouldIgnore } from '../shared-ignore.mjs';
10
+
11
+ const RETIRED_STATUSES = new Set(['archived', 'deprecated', 'obsolete', 'superseded']);
12
+ const COMPLETION_STATUSES = new Set(['complete', 'completed']);
13
+ const EXCLUDED_PREFIXES = ['.git', '.local', '.docguard'];
14
+ const MANIFEST_PATH = '.docguard-archive.json';
15
+
16
+ export function readRetirementManifest(projectDir) {
17
+ const path = resolve(projectDir, MANIFEST_PATH);
18
+ if (!existsSync(path)) return { ok: true, paths: new Set(), entries: [], retention: null, error: null };
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
+ if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
22
+ throw new Error('unsupported schema');
23
+ }
24
+ const globalRecovery = parsed.retention?.recoverability === 'verified'
25
+ && typeof parsed.retention?.ref === 'string'
26
+ && /^(?:sha1|sha256)$/.test(parsed.retention?.objectFormat || '');
27
+ for (const entry of parsed.entries) {
28
+ const path = entry?.path;
29
+ const entryRecovery = entry?.recoverability === 'verified'
30
+ && typeof entry?.retentionRef === 'string'
31
+ && /^(?:sha1|sha256)$/.test(entry?.objectFormat || '');
32
+ if (typeof path !== 'string' || !path || path.startsWith('/')
33
+ || path.replaceAll('\\', '/').split('/').includes('..')
34
+ || typeof entry?.archivedFrom !== 'string'
35
+ || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.archivedFrom)
36
+ || typeof entry?.blob !== 'string'
37
+ || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.blob)
38
+ || typeof entry?.reason !== 'string' || !entry.reason.trim()
39
+ || (entry?.requirementIds !== undefined && (!Array.isArray(entry.requirementIds)
40
+ || entry.requirementIds.some(id => typeof id !== 'string' || !id || id.length > 128 || /[\s#\0]/.test(id))))
41
+ || (entry?.specId !== undefined && (typeof entry.specId !== 'string'
42
+ || !/^[a-z0-9][a-z0-9._-]{2,127}$/.test(entry.specId)))
43
+ || (!globalRecovery && !entryRecovery)) {
44
+ throw new Error(`invalid recovery entry for ${path || '<unknown path>'}`);
45
+ }
46
+ }
47
+ return {
48
+ ok: true,
49
+ paths: new Set(parsed.entries.map(entry => entry.path)),
50
+ entries: parsed.entries,
51
+ retention: parsed.retention || null,
52
+ error: null,
53
+ };
54
+ } catch (error) {
55
+ return { ok: false, paths: new Set(), entries: [], retention: null, error: `${MANIFEST_PATH}: ${error.message}` };
56
+ }
57
+ }
58
+
59
+ function trackedFiles(projectDir) {
60
+ const result = spawnSync('git', ['ls-files', '-z'], {
61
+ cwd: projectDir,
62
+ encoding: 'utf8',
63
+ maxBuffer: 8 * 1024 * 1024,
64
+ });
65
+ if (result.status !== 0) {
66
+ const error = result.error?.message || result.stderr || result.stdout || 'git ls-files failed';
67
+ return {
68
+ ok: false,
69
+ files: [],
70
+ reason: /not a git repository|must be run in a work tree/i.test(error)
71
+ ? 'not-git'
72
+ : 'git-unavailable',
73
+ error: error.trim(),
74
+ };
75
+ }
76
+ return { ok: true, files: result.stdout.split('\0').filter(Boolean), reason: null, error: null };
77
+ }
78
+
79
+ export function parseLifecycleStatus(content) {
80
+ const header = content.split('\n').slice(0, 40).join('\n');
81
+ const match = header.match(/(?:^|\n)\s*(?:#{1,6}\s*)?(?:\*\*)?Status(?::)?(?:\*\*)?\s*:?\s*(?:\*\*)?([^*\n]+)/i);
82
+ return match?.[1]?.trim() || null;
83
+ }
84
+
85
+ function completedTaskSignal(projectDir, path, content, tracked) {
86
+ let taskContent = content;
87
+ if (!/(?:^|\n)\s*- \[[ xX]\]/.test(taskContent) && /(?:^|\/)spec\.md$/i.test(path)) {
88
+ const taskRel = `${dirname(path)}/tasks.md`;
89
+ const taskPath = resolve(projectDir, taskRel);
90
+ if (tracked.has(taskRel) && existsSync(taskPath)) taskContent = readFileSync(taskPath, 'utf8');
91
+ }
92
+ const checked = taskContent.match(/(?:^|\n)\s*- \[[xX]\]/g)?.length || 0;
93
+ const open = taskContent.match(/(?:^|\n)\s*- \[ \]/g)?.length || 0;
94
+ return checked > 0 && open === 0 ? { checked, open } : null;
95
+ }
96
+
97
+ export function scanDocumentLifecycle(projectDir, config = {}) {
98
+ const inventory = trackedFiles(projectDir);
99
+ const retired = readRetirementManifest(projectDir);
100
+ if (!inventory.ok || !retired.ok) {
101
+ return {
102
+ scanned: 0,
103
+ candidates: [],
104
+ coverage: {
105
+ status: 'unavailable',
106
+ reason: inventory.ok ? 'manifest-invalid' : inventory.reason,
107
+ error: inventory.error || retired.error,
108
+ unreadable: [],
109
+ },
110
+ };
111
+ }
112
+ const tracked = new Set(inventory.files);
113
+ const markdown = inventory.files.filter(path => /\.md$/i.test(path));
114
+ const candidates = [];
115
+ const unreadable = [];
116
+ let scanned = 0;
117
+ for (const path of retired.paths) {
118
+ if (existsSync(resolve(projectDir, path))) {
119
+ candidates.push({
120
+ code: 'DLC004',
121
+ path,
122
+ confidence: 'high',
123
+ status: 'retired',
124
+ reason: 'retirement manifest records this path but it remains in the working tree',
125
+ });
126
+ }
127
+ }
128
+ for (const path of markdown) {
129
+ if (EXCLUDED_PREFIXES.some(prefix => path === prefix || path.startsWith(`${prefix}/`))) continue;
130
+ if (shouldIgnore(path, config, 'documentLifecycle')) continue;
131
+ if (retired.paths.has(path) && !existsSync(resolve(projectDir, path))) continue;
132
+ let content;
133
+ try { content = readFileSync(resolve(projectDir, path), 'utf8'); } catch {
134
+ unreadable.push(path);
135
+ continue;
136
+ }
137
+ scanned++;
138
+ const status = parseLifecycleStatus(content);
139
+ const normalizedStatus = status?.trim().toLowerCase() || null;
140
+ if (normalizedStatus && RETIRED_STATUSES.has(normalizedStatus)) {
141
+ candidates.push({
142
+ code: 'DLC001',
143
+ path,
144
+ confidence: 'high',
145
+ status,
146
+ reason: `explicit lifecycle status: ${status}`,
147
+ });
148
+ continue;
149
+ }
150
+ if (normalizedStatus && COMPLETION_STATUSES.has(normalizedStatus)) {
151
+ candidates.push({
152
+ code: 'DLC002',
153
+ path: /(?:^|\/)spec\.md$/i.test(path) ? dirname(path) : path,
154
+ confidence: 'review',
155
+ status,
156
+ reason: `artifact maturity is ${status}; verify persistence policy and backreferences before retirement`,
157
+ });
158
+ continue;
159
+ }
160
+ if (path.startsWith('specs/') && /(?:^|\/)spec\.md$/i.test(path)) {
161
+ const tasks = completedTaskSignal(projectDir, path, content, tracked);
162
+ if (tasks) {
163
+ candidates.push({
164
+ code: 'DLC002',
165
+ path: dirname(path),
166
+ confidence: 'review',
167
+ status,
168
+ reason: `${tasks.checked} checked tasks and no open tasks; confirm the outcome is documented`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return {
174
+ scanned,
175
+ candidates: [...new Map(candidates.map(candidate => [candidate.path, candidate])).values()]
176
+ .sort((a, b) => a.path.localeCompare(b.path)),
177
+ coverage: {
178
+ status: unreadable.length > 0 ? 'partial' : 'complete',
179
+ reason: unreadable.length > 0 ? 'unreadable-files' : null,
180
+ error: null,
181
+ unreadable,
182
+ },
183
+ };
184
+ }