docguard-cli 0.38.0 → 0.40.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 (63) hide show
  1. package/README.md +45 -22
  2. package/cli/commands/agent.mjs +47 -1
  3. package/cli/commands/explain.mjs +16 -0
  4. package/cli/commands/feedback.mjs +147 -6
  5. package/cli/commands/fix.mjs +13 -11
  6. package/cli/commands/generate.mjs +52 -18
  7. package/cli/commands/guard.mjs +13 -2
  8. package/cli/commands/mcp.mjs +22 -2
  9. package/cli/commands/score.mjs +13 -1
  10. package/cli/commands/specs.mjs +21 -2
  11. package/cli/commands/sync.mjs +20 -7
  12. package/cli/commands/verify.mjs +65 -2
  13. package/cli/config.mjs +3 -0
  14. package/cli/docguard.mjs +48 -16
  15. package/cli/evidence/adapters.mjs +200 -0
  16. package/cli/evidence/evaluate.mjs +185 -0
  17. package/cli/evidence/manifest.mjs +194 -0
  18. package/cli/evidence/markdown.mjs +107 -0
  19. package/cli/feedback-fixture.mjs +188 -0
  20. package/cli/findings.mjs +31 -0
  21. package/cli/repository-root.mjs +159 -0
  22. package/cli/scanners/py-ast.mjs +39 -2
  23. package/cli/scanners/task-context.mjs +312 -0
  24. package/cli/shared-doc-roles.mjs +44 -1
  25. package/cli/shared-source.mjs +101 -28
  26. package/cli/validators/architecture.mjs +186 -13
  27. package/cli/validators/environment.mjs +14 -1
  28. package/cli/validators/evidence.mjs +52 -0
  29. package/cli/validators/security.mjs +5 -4
  30. package/cli/validators/todo-tracking.mjs +45 -2
  31. package/cli/writers/doc-generators.mjs +31 -17
  32. package/cli/writers/mechanical.mjs +44 -14
  33. package/cli/writers/sections.mjs +31 -3
  34. package/docs/ai-integration.md +31 -6
  35. package/docs/commands.md +43 -5
  36. package/docs/configuration.md +11 -3
  37. package/docs/quickstart.md +1 -1
  38. package/extensions/spec-kit-docguard/commands/fix.md +4 -2
  39. package/extensions/spec-kit-docguard/commands/generate.md +6 -1
  40. package/extensions/spec-kit-docguard/commands/guard.md +3 -2
  41. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  42. package/extensions/spec-kit-docguard/extension.yml +1 -1
  43. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
  44. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
  45. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
  46. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
  49. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
  50. package/package.json +3 -1
  51. package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
  52. package/schemas/docguard-agent-context-result.schema.json +95 -0
  53. package/schemas/docguard-benchmark.schema.json +84 -0
  54. package/schemas/docguard-config.schema.json +1 -0
  55. package/schemas/docguard-evidence.schema.json +169 -0
  56. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  57. package/schemas/docguard-task-context.schema.json +144 -0
  58. package/templates/AGENTS.md.template +9 -4
  59. package/templates/ci/github-actions.yml +4 -4
  60. package/templates/commands/docguard.guard.md +5 -1
  61. package/templates/commands/docguard.review.md +6 -1
  62. package/templates/evidence-manifest.json +21 -0
  63. package/templates/feedback-fixture.json +18 -0
@@ -1,4 +1,9 @@
1
- import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
1
+ /**
2
+ * @implements docguard.language-repository-coverage#FR-009
3
+ * @implements docguard.language-repository-coverage#FR-010
4
+ * @implements docguard.language-repository-coverage#FR-011
5
+ */
6
+ import { assertMappedFullDocumentWrites, docRolePath, isMappedDocPath, mappedRolesForPath, resolveDocRole } from '../shared-doc-roles.mjs';
2
7
  /**
3
8
  * Generate Command — Reverse-engineer canonical docs from an existing codebase
4
9
  * Scans source code and creates documentation templates pre-filled with project data.
@@ -6,7 +11,7 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
6
11
  * This is the "killer feature" — take any project and auto-generate CDD docs.
7
12
  */
8
13
 
9
- import { existsSync, readFileSync, mkdirSync } from 'node:fs';
14
+ import { existsSync, readFileSync } from 'node:fs';
10
15
  import { resolve, extname, basename, relative } from 'node:path';
11
16
  import { c } from '../shared.mjs';
12
17
  import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
@@ -14,7 +19,7 @@ import { detectDocTools } from '../scanners/doc-tools.mjs';
14
19
  import { scanRoutesDeep } from '../scanners/routes.mjs';
15
20
  import { scanSchemasDeep } from '../scanners/schemas.mjs';
16
21
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
17
- import { upsertSection } from '../writers/sections.mjs';
22
+ import { assertOwnedCodeSection, replaceSection, upsertSection } from '../writers/sections.mjs';
18
23
  import { safeWrite, registerGeneratedCanonicalDocs, surfaceConfidence } from '../writers/generate-io.mjs';
19
24
  import {
20
25
  generateArchitecture, generateApiReference, generateDataModel,
@@ -40,11 +45,32 @@ const CODE_EXTENSIONS = new Set([
40
45
  * inserted as agent-task placeholders), respecting human prose via markers.
41
46
  */
42
47
  export function runGeneratePlan(projectDir, config, flags) {
43
- if (flags.write) assertDefaultDocWrites(config);
44
48
  // `--profile <name>` previews a profile's doc set without needing `init` first.
45
49
  if (flags.profile) config = { ...config, profile: flags.profile };
46
50
  const plan = buildMemoryPlan(projectDir, config);
47
51
 
52
+ // Existing mapped human documents grant ownership section-by-section. Check
53
+ // every target before the first write so one malformed later file cannot
54
+ // leave an earlier document partially updated.
55
+ if (flags.write) {
56
+ for (const doc of plan.docs) {
57
+ const full = resolve(projectDir, doc.path);
58
+ if (!isMappedDocPath(config, doc.path)) continue;
59
+ if (!existsSync(full)) {
60
+ if (mappedRolesForPath(config, doc.path).length > 1) {
61
+ throw new Error(`Mapped document ${doc.path} serves multiple roles; whole-document scaffolding is unavailable.`);
62
+ }
63
+ continue;
64
+ }
65
+ const content = readFileSync(full, 'utf8');
66
+ const fullyOwned = /^[ \t]*<!--\s*docguard:generated\s+true\s*-->[ \t]*$/mi.test(content);
67
+ if (fullyOwned) assertMappedFullDocumentWrites(projectDir, config, mappedRolesForPath(config, doc.path));
68
+ else for (const sec of doc.sections.filter(item => item.source === 'code')) {
69
+ assertOwnedCodeSection(content, sec.id, doc.path);
70
+ }
71
+ }
72
+ }
73
+
48
74
  if (flags.format === 'json') {
49
75
  console.log(JSON.stringify({
50
76
  project: config.projectName,
@@ -80,8 +106,6 @@ export function runGeneratePlan(projectDir, config, flags) {
80
106
 
81
107
  // --write: scaffold the skeleton docs with code sections + agent-task placeholders.
82
108
  if (flags.write) {
83
- const docsDir = resolve(projectDir, 'docs-canonical');
84
- if (!existsSync(docsDir)) mkdirSync(docsDir, { recursive: true });
85
109
  let wrote = 0;
86
110
  for (const doc of plan.docs) {
87
111
  const full = resolve(projectDir, doc.path);
@@ -89,11 +113,16 @@ export function runGeneratePlan(projectDir, config, flags) {
89
113
  let content = existsSync(full)
90
114
  ? readFileSync(full, 'utf-8')
91
115
  : `# ${title}\n\n<!-- docguard:generated true -->\n`;
116
+ const boundedMapped = isMappedDocPath(config, doc.path) && existsSync(full)
117
+ && !/^[ \t]*<!--\s*docguard:generated\s+true\s*-->[ \t]*$/mi.test(content);
92
118
  for (const sec of doc.sections) {
119
+ if (boundedMapped && sec.source !== 'code') continue;
93
120
  const body = sec.source === 'code'
94
121
  ? sec.body
95
122
  : `> **AI task:** ${sec.task}\n<!-- docguard:pending agent writes this section -->`;
96
- content = upsertSection(content, sec.id, body, { source: sec.source }).content;
123
+ content = boundedMapped
124
+ ? replaceSection(content, sec.id, body).content
125
+ : upsertSection(content, sec.id, body, { source: sec.source }).content;
97
126
  }
98
127
  // Route through safeWrite: creates the parent dir (docs-implementation/ may
99
128
  // not exist yet — was an ENOENT crash) and snapshots a .bak before writing.
@@ -139,7 +168,6 @@ export function runGeneratePlan(projectDir, config, flags) {
139
168
  }
140
169
 
141
170
  export function runGenerate(projectDir, config, flags) {
142
- if (!flags.plan || flags.write) assertDefaultDocWrites(config);
143
171
  // --plan: emit the AI-powered "memory plan" — the agent task manifest. The CLI
144
172
  // builds the code-truth skeleton (marked sections) + tells the agent exactly
145
173
  // what prose to write per section. This is the language-aware Generate path.
@@ -191,17 +219,23 @@ export function runGenerate(projectDir, config, flags) {
191
219
  console.log('');
192
220
 
193
221
  // ── 6. Generate Documents ──
194
- const docsDir = resolve(projectDir, 'docs-canonical');
195
- if (!existsSync(docsDir)) {
196
- mkdirSync(docsDir, { recursive: true });
197
- }
222
+ // Preflight every mapped whole-document target before the first generator
223
+ // writes. Existing targets are skipped unless --force, so they do not need
224
+ // ownership merely to run ordinary generation.
225
+ const candidateRoles = ['architecture', ...(deepRoutes.length > 0 ? ['apiReference'] : []),
226
+ 'dataModel', 'environment', 'testSpec', 'security'];
227
+ const writableRoles = candidateRoles.filter(role => {
228
+ const target = resolveDocRole(projectDir, config, role);
229
+ return !existsSync(target) || flags.force;
230
+ });
231
+ assertMappedFullDocumentWrites(projectDir, config, writableRoles);
198
232
 
199
233
  // ── Safety: warn if --force will overwrite existing files ──
200
234
  if (flags.force) {
201
235
  const targetFiles = [
202
- 'docs-canonical/ARCHITECTURE.md', 'docs-canonical/API-REFERENCE.md',
203
- 'docs-canonical/DATA-MODEL.md', 'docs-canonical/ENVIRONMENT.md',
204
- 'docs-canonical/TEST-SPEC.md', 'docs-canonical/SECURITY.md',
236
+ docRolePath(config, 'architecture'), docRolePath(config, 'apiReference'),
237
+ docRolePath(config, 'dataModel'), docRolePath(config, 'environment'),
238
+ docRolePath(config, 'testSpec'), docRolePath(config, 'security'),
205
239
  'AGENTS.md', 'CHANGELOG.md', 'DRIFT-LOG.md',
206
240
  ];
207
241
  const existing = targetFiles.filter(f => existsSync(resolve(projectDir, f)));
@@ -248,9 +282,9 @@ export function runGenerate(projectDir, config, flags) {
248
282
  // B7: keep guard coherent — register the canonical docs we emitted so the
249
283
  // traceability validator doesn't flag the generator's own output.
250
284
  const registered = registerGeneratedCanonicalDocs(projectDir, [
251
- 'docs-canonical/ARCHITECTURE.md', 'docs-canonical/API-REFERENCE.md',
252
- 'docs-canonical/DATA-MODEL.md', 'docs-canonical/ENVIRONMENT.md',
253
- 'docs-canonical/TEST-SPEC.md', 'docs-canonical/SECURITY.md',
285
+ docRolePath(config, 'architecture'), docRolePath(config, 'apiReference'),
286
+ docRolePath(config, 'dataModel'), docRolePath(config, 'environment'),
287
+ docRolePath(config, 'testSpec'), docRolePath(config, 'security'),
254
288
  ]);
255
289
 
256
290
  console.log(`\n${c.bold} ─────────────────────────────────────${c.reset}`);
@@ -1,6 +1,7 @@
1
1
  import { describeCheckCoverage, summarizeCheckCoverage } from '../validator-coverage.mjs';
2
2
  import { applyDocRoles } from '../shared-doc-roles.mjs';
3
3
  /**
4
+ * @implements docguard.evidence-scoped-verification#FR-010
4
5
  * Guard Command — Validate project against its canonical documentation
5
6
  * Runs all enabled validators and reports results.
6
7
  *
@@ -157,6 +158,8 @@ import { validateReferenceExistence } from '../validators/reference-existence.mj
157
158
  import { validateApiDocSmells } from '../validators/api-doc-smells.mjs';
158
159
  import { validateDocumentLifecycle } from '../validators/document-lifecycle.mjs';
159
160
  import { validateSpecRegistry } from '../validators/spec-registry.mjs';
161
+ import { validateEvidence } from '../validators/evidence.mjs';
162
+ import { coverSemanticClaims } from '../evidence/evaluate.mjs';
160
163
 
161
164
  /**
162
165
  * Internal guard — returns structured data, no console output, no process.exit.
@@ -323,6 +326,7 @@ export function runGuardInternal(projectDir, config) {
323
326
  { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
324
327
  { key: 'documentLifecycle', name: 'Document-Lifecycle', fn: () => validateDocumentLifecycle(projectDir, config) },
325
328
  { key: 'specRegistry', name: 'Spec-Registry', fn: () => validateSpecRegistry(projectDir, config) },
329
+ { key: 'evidence', name: 'Evidence', fn: () => validateEvidence(projectDir, config) },
326
330
  { key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
327
331
  { key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
328
332
  { key: 'surfaceSync', name: 'Surface-Sync', fn: () => validateSurfaceSync(projectDir, config) },
@@ -492,9 +496,14 @@ export function runGuardInternal(projectDir, config) {
492
496
  const lite = Array.isArray(config.changedFiles);
493
497
  let coverage = null;
494
498
  let semanticClaims = null;
499
+ const evidence = results.find(result => result.key === 'evidence')?.evidence || null;
495
500
  if (!lite) {
496
501
  try { coverage = computeDocCoverage(projectDir, config); } catch { coverage = null; }
497
- try { semanticClaims = { count: extractSemanticClaims(projectDir, config).length }; }
502
+ try {
503
+ const claims = extractSemanticClaims(projectDir, config);
504
+ const scoped = coverSemanticClaims(claims, evidence);
505
+ semanticClaims = { count: scoped.unverified, discovered: scoped.total, verifiedWithinScope: scoped.verifiedWithinScope, coveredClaimIds: scoped.covered };
506
+ }
498
507
  catch { semanticClaims = null; }
499
508
  }
500
509
 
@@ -518,6 +527,7 @@ export function runGuardInternal(projectDir, config) {
518
527
  coverage,
519
528
  checkCoverage,
520
529
  semanticClaims,
530
+ evidence,
521
531
  validators: results,
522
532
  // Unknown keys in `docguard:validator … n/a` markers — typo protection so
523
533
  // a mistyped key doesn't silently fail to suppress. Surfaced by runGuard.
@@ -539,7 +549,7 @@ export function runGuardInternal(projectDir, config) {
539
549
  * Freshness (git log), Traceability (REQ scan), Doc-Quality (prose lint) —
540
550
  * stay off for speed.
541
551
  */
542
- export const CHANGED_ONLY_VALIDATORS = ['docsSync', 'environment', 'apiSurface', 'drift', 'todoTracking'];
552
+ export const CHANGED_ONLY_VALIDATORS = ['docsSync', 'environment', 'apiSurface', 'drift', 'todoTracking', 'evidence'];
543
553
 
544
554
  /**
545
555
  * Build a validators map that enables the pre-commit-lite set — PLUS any
@@ -560,6 +570,7 @@ export function liteValidatorsConfig(config = {}) {
560
570
  'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
561
571
  'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
562
572
  'canonicalSync', 'metricsConsistency',
573
+ 'evidence',
563
574
  ];
564
575
  const userValidators = (config && config.validators) || {};
565
576
  const out = {};
@@ -1,8 +1,10 @@
1
1
  /**
2
+ * @implements docguard.evidence-scoped-verification#FR-009
3
+ * @implements docguard.evidence-scoped-verification#FR-012
2
4
  * MCP Command — DocGuard as a Model Context Protocol server (stdio).
3
5
  *
4
6
  * `docguard mcp` exposes the read-only core (guard / score / explain /
5
- * verify-claims / diagnose) as MCP tools any MCP client (Claude, Cursor,
7
+ * verify-evidence / verify-claims / diagnose) as MCP tools any MCP client (Claude, Cursor,
6
8
  * agent SDKs) can call over stdio. JSON-RPC 2.0, newline-delimited, per the
7
9
  * MCP stdio transport (protocol revision 2024-11-05).
8
10
  *
@@ -31,6 +33,7 @@ import { buildReport } from './report.mjs';
31
33
  import { loadConfig } from '../config.mjs';
32
34
  import { CODES } from '../findings.mjs';
33
35
  import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
36
+ import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
34
37
 
35
38
  const _PKG = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8'));
36
39
 
@@ -101,6 +104,16 @@ const TOOLS = [
101
104
  },
102
105
  annotations: READONLY_ANNOTATIONS,
103
106
  },
107
+ {
108
+ name: 'docguard_verify_evidence',
109
+ title: 'Verify declared evidence',
110
+ description: 'Evaluate `.docguard-evidence.json` against bounded local sources. Returns explicit verified-within-scope, contradicted, stale, inconclusive, and unsupported states; verification applies only to each selected statement.',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: { ...PROJECT_DIR_PROP },
114
+ },
115
+ annotations: READONLY_ANNOTATIONS,
116
+ },
104
117
  {
105
118
  name: 'docguard_verify_claims',
106
119
  title: 'Extract claims to verify',
@@ -173,13 +186,20 @@ const TOOL_HANDLERS = {
173
186
  docguard_verify_claims(args, defaultDir) {
174
187
  const { dir, config } = resolveTarget(args, defaultDir);
175
188
  const claims = extractSemanticClaims(dir, config);
189
+ const coverage = coverSemanticClaims(claims, evaluateEvidence(dir, config));
176
190
  return {
177
191
  claimCount: claims.length,
192
+ verifiedWithinScope: coverage.verifiedWithinScope,
178
193
  note: 'Deterministic discovery, LLM judgment — the caller verifies each claim against the code and reports any mismatch with both values.',
179
- tasks: buildSemanticVerifyTasks(claims),
194
+ tasks: buildSemanticVerifyTasks(coverage.remaining),
180
195
  };
181
196
  },
182
197
 
198
+ docguard_verify_evidence(args, defaultDir) {
199
+ const { dir, config } = resolveTarget(args, defaultDir);
200
+ return evaluateEvidence(dir, config);
201
+ },
202
+
183
203
  docguard_report(args, defaultDir) {
184
204
  const { dir, config } = resolveTarget(args, defaultDir);
185
205
  return buildReport(dir, config);
@@ -1,5 +1,6 @@
1
1
  import { applyDocRoles, remapDocPath, resolveDocRole } from '../shared-doc-roles.mjs';
2
2
  /**
3
+ * @implements docguard.evidence-scoped-verification#FR-012
3
4
  * Score Command — Calculate CDD maturity score (0-100)
4
5
  * Shows category breakdown with weighted scoring.
5
6
  */
@@ -14,6 +15,7 @@ import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
14
15
  import { assessAgentReadability } from '../scanners/agent-readability.mjs';
15
16
  import { loadHistory, sparkline } from '../writers/history.mjs';
16
17
  import { listCanonicalDocs } from '../shared-ignore.mjs';
18
+ import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
17
19
 
18
20
  /**
19
21
  * Detect whether the project configures a test runner (the "Check 3" of the
@@ -415,11 +417,21 @@ export function runScoreInternal(projectDir, config) {
415
417
  /** Evidence boundary shared by human, CI, report, and MCP score consumers. */
416
418
  export function buildScoreAssurance(projectDir, config) {
417
419
  let unverifiedClaims = null;
418
- try { unverifiedClaims = extractSemanticClaims(projectDir, config).length; } catch { /* unknown, never zero on failure */ }
420
+ let declaredEvidence = null;
421
+ try {
422
+ const claims = extractSemanticClaims(projectDir, config);
423
+ const evaluated = evaluateEvidence(projectDir, config);
424
+ unverifiedClaims = coverSemanticClaims(claims, evaluated).unverified;
425
+ } catch { /* unknown, never zero on failure */ }
426
+ try {
427
+ const evaluated = evaluateEvidence(projectDir, config);
428
+ declaredEvidence = { configured: evaluated.exists, status: evaluated.status, summary: evaluated.summary };
429
+ } catch { /* unknown, never clean on failure */ }
419
430
  return {
420
431
  status: 'unverified',
421
432
  factualAccuracy: null,
422
433
  unverifiedClaims,
434
+ declaredEvidence,
423
435
  limitation: 'Structural maturity is not factual accuracy. Claim discovery is heuristic; uncaptured prose remains unverified.',
424
436
  };
425
437
  }
@@ -56,6 +56,13 @@ function archiveReadiness(spec, targetVerified = false) {
56
56
  return { status: 'READY', reason: 'Run the reviewed spec retirement flow after the verified state is committed and retained.' };
57
57
  }
58
58
 
59
+ function completionTransition(spec) {
60
+ if (spec?.reviewed.lifecycle.delivery === 'verified') return 'verified→verified';
61
+ return spec?.reviewed.lifecycle.delivery === 'in_progress'
62
+ ? 'in_progress→implemented→verified'
63
+ : 'implemented→verified';
64
+ }
65
+
59
66
  export function planSpecCompletion(projectDir, config, flags, options = {}) {
60
67
  const projection = projectSpecRegistry(projectDir, config);
61
68
  const loaded = readSpecRegistry(projectDir);
@@ -70,8 +77,12 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
70
77
 
71
78
  let reconcile = null;
72
79
  if (spec) {
80
+ const maintenance = spec.reviewed.lifecycle.delivery === 'verified'
81
+ && spec.reviewed.lifecycle.persistenceModel === 'living';
73
82
  if (spec.reviewed.lifecycle.approval !== 'approved') blockers.push({ code: 'SPC002', message: 'Only an approved spec can become verified.' });
74
- if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery)) blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress or implemented, found ${spec.reviewed.lifecycle.delivery}.` });
83
+ if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery) && !maintenance) {
84
+ blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress, implemented, or verified with persistenceModel=living; found ${spec.reviewed.lifecycle.delivery}/${spec.reviewed.lifecycle.persistenceModel || 'unset'}.` });
85
+ }
75
86
  const tasks = spec.observed.taskCompletion;
76
87
  if (!tasks.total || tasks.checked !== tasks.total) blockers.push({ code: 'SPC003', message: `All tasks must be checked (${tasks.checked}/${tasks.total}).` });
77
88
  if (spec.observed.implementationEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified source implementation annotation is required.' });
@@ -92,6 +103,14 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
92
103
  if (reconcile.status === 'UNSUPPORTED' || reconcile.status === 'BLOCKED') blockers.push({ code: 'SPC006', message: 'Reconciliation coverage is unsupported or blocked.' });
93
104
  const unresolved = reconcile.classifications.filter(item => item.disposition === 'unsupported_or_ambiguous');
94
105
  if (unresolved.length) blockers.push({ code: 'SPC006', message: `Unresolved changed files: ${unresolved.map(item => item.path).join(', ')}.` });
106
+ if (maintenance) {
107
+ const reviewable = reconcile.classifications.filter(item =>
108
+ item.specs.includes(spec.specId)
109
+ && ['source', 'test', 'canonical_doc', 'decision'].includes(item.kind));
110
+ if (revision === spec.reviewed.reconciliation.lastReviewedRevision || reviewable.length === 0) {
111
+ blockers.push({ code: 'SPC006', message: 'Living-spec maintenance requires a new linked source, test, canonical-document, or decision change since the last reviewed revision.' });
112
+ }
113
+ }
95
114
  }
96
115
  }
97
116
  const guard = options.guardResult || runGuardInternal(projectDir, config);
@@ -100,7 +119,7 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
100
119
  status: blockers.length ? 'BLOCKED' : 'READY',
101
120
  specId: flags.id || null,
102
121
  revision,
103
- transition: spec?.reviewed.lifecycle.delivery === 'in_progress' ? 'in_progress→implemented→verified' : 'implemented→verified',
122
+ transition: completionTransition(spec),
104
123
  blockers,
105
124
  reconciliation: reconcile,
106
125
  evidence: spec ? [...new Set([
@@ -1,4 +1,4 @@
1
- import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
1
+ import { isMappedDocPath } from '../shared-doc-roles.mjs';
2
2
  /**
3
3
  * Sync Command — keep the documentation memory ALWAYS UP TO DATE.
4
4
  *
@@ -15,12 +15,13 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
15
15
  * @implements docguard.document-lifecycle#FR-010
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { resolve } from 'node:path';
20
20
  import { execFileSync } from 'node:child_process';
21
21
  import { c } from '../shared.mjs';
22
22
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
23
- import { getSection, replaceSection } from '../writers/sections.mjs';
23
+ import { assertOwnedCodeSection, getSection, inspectSections, replaceSection } from '../writers/sections.mjs';
24
+ import { safeWrite } from '../writers/generate-io.mjs';
24
25
  import { hasGeneratedMarker } from '../writers/api-reference.mjs';
25
26
  import { runSyncTests } from './sync-tests.mjs';
26
27
  import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
@@ -48,7 +49,6 @@ function gitChangedFiles(projectDir, since) {
48
49
  */
49
50
 
50
51
  export function runSync(projectDir, config, flags) {
51
- if (flags.write) assertDefaultDocWrites(config);
52
52
  // v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
53
53
  // Source-to-Test Map from disk (ghost-source removal + new co-located pairs) —
54
54
  // a distinct path from the generated code-truth section refresh below.
@@ -62,6 +62,7 @@ export function runSync(projectDir, config, flags) {
62
62
  const updates = []; // { doc, section, status }
63
63
  const reviews = []; // { doc, section, reason }
64
64
  const skipped = []; // { doc, reason }
65
+ const pendingWrites = [];
65
66
 
66
67
  for (const doc of plan.docs) {
67
68
  const full = resolve(projectDir, doc.path);
@@ -70,7 +71,11 @@ export function runSync(projectDir, config, flags) {
70
71
  continue;
71
72
  }
72
73
  let content = readFileSync(full, 'utf-8');
73
- if (!hasGeneratedMarker(content) && !flags.force) {
74
+ const mapped = isMappedDocPath(config, doc.path);
75
+ if (apply && mapped && inspectSections(content).issues.length) {
76
+ throw new Error(`${doc.path}: malformed or duplicate docguard:section markers; no write was applied.`);
77
+ }
78
+ if (!hasGeneratedMarker(content) && !flags.force && !mapped) {
74
79
  skipped.push({ doc: doc.path, reason: 'not marked docguard:generated (use --force to sync anyway)' });
75
80
  continue;
76
81
  }
@@ -98,7 +103,11 @@ export function runSync(projectDir, config, flags) {
98
103
  }
99
104
  codeSectionChanged = true;
100
105
  updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
101
- if (apply) { content = replaceSection(content, sec.id, sec.body).content; docChanged = true; }
106
+ if (apply) {
107
+ if (mapped) assertOwnedCodeSection(content, sec.id, doc.path);
108
+ content = replaceSection(content, sec.id, sec.body).content;
109
+ docChanged = true;
110
+ }
102
111
  }
103
112
 
104
113
  // If code changed, the prose around it may need an agent's eyes.
@@ -110,9 +119,13 @@ export function runSync(projectDir, config, flags) {
110
119
  }
111
120
  }
112
121
 
113
- if (apply && docChanged) writeFileSync(full, content, 'utf-8');
122
+ if (apply && docChanged) pendingWrites.push({ full, content });
114
123
  }
115
124
 
125
+ // Authorization for every mapped target completed above. Only now expose
126
+ // writes, preserving backup behavior for both default and mapped layouts.
127
+ for (const pending of pendingWrites) safeWrite(pending.full, pending.content);
128
+
116
129
  const result = {
117
130
  project: config.projectName,
118
131
  since: flags.since || null,
@@ -20,6 +20,7 @@
20
20
  * text is the human summary.
21
21
  *
22
22
  * docguard verify [--semantic | --instructions] [--format json]
23
+ * @implements docguard.evidence-scoped-verification#FR-009
23
24
  */
24
25
 
25
26
  import { basename } from 'node:path';
@@ -29,6 +30,7 @@ import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/sem
29
30
  import { auditInstructions } from '../scanners/instruction-audit.mjs';
30
31
  import { isGitRepo, getDiffText } from '../shared-git.mjs';
31
32
  import { parseUnifiedDiff, activityLabeledDiff } from '../shared-diff.mjs';
33
+ import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
32
34
 
33
35
  const CHANGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
34
36
 
@@ -68,14 +70,30 @@ function taskTouchesChange(task, changedSet, changedBasenames) {
68
70
  }
69
71
 
70
72
  export function runVerify(projectDir, config, flags) {
73
+ const selectedModes = ['semantic', 'instructions', 'evidence'].filter(mode => flags[mode]);
74
+ if (selectedModes.length > 1) {
75
+ const message = `Choose exactly one verify mode; --${selectedModes.join(', --')} cannot be combined.`;
76
+ if (flags.format === 'json') {
77
+ console.log(JSON.stringify({ command: 'verify', status: 'error', error: { code: 'VERIFY_MODE_CONFLICT', message } }, null, 2));
78
+ } else {
79
+ console.error(`${c.red}Error: ${message}${c.reset}`);
80
+ }
81
+ process.exitCode = 1;
82
+ return;
83
+ }
71
84
  if (flags.instructions) {
72
85
  runInstructionAudit(projectDir, config, flags);
73
86
  return;
74
87
  }
88
+ if (flags.evidence) {
89
+ runEvidenceVerification(projectDir, config, flags);
90
+ return;
91
+ }
75
92
 
76
93
  const isJson = flags.format === 'json';
77
94
  const claims = extractSemanticClaims(projectDir, config);
78
- const tasks = buildSemanticVerifyTasks(claims);
95
+ const evidenceCoverage = coverSemanticClaims(claims, evaluateEvidence(projectDir, config));
96
+ const tasks = buildSemanticVerifyTasks(evidenceCoverage.remaining);
79
97
 
80
98
  // Change-aware staging (feat 6): if --since given, attach the structured diff
81
99
  // and flag which claims are about just-changed code (verify those first).
@@ -92,6 +110,9 @@ export function runVerify(projectDir, config, flags) {
92
110
  console.log(JSON.stringify({
93
111
  command: 'verify --semantic',
94
112
  project: config.projectName,
113
+ discoveredClaimCount: evidenceCoverage.total,
114
+ verifiedWithinScope: evidenceCoverage.verifiedWithinScope,
115
+ coveredClaimIds: evidenceCoverage.covered,
95
116
  claimCount: tasks.length,
96
117
  // How to act on this: each task is a claim to confirm against the code.
97
118
  howToVerify: changeContext
@@ -107,7 +128,10 @@ export function runVerify(projectDir, config, flags) {
107
128
  console.log(`${c.dim} ${config.projectName} · documented numbers / limits / enums to check against code${c.reset}\n`);
108
129
 
109
130
  if (tasks.length === 0) {
110
- console.log(` ${c.green}✅ No semantic claims found in the canonical docs.${c.reset}`);
131
+ const message = evidenceCoverage.total > 0
132
+ ? `${evidenceCoverage.verifiedWithinScope} discovered claim(s) are already covered by unique current evidence declarations.`
133
+ : 'No semantic claims found in the canonical docs.';
134
+ console.log(` ${c.green}✅ ${message}${c.reset}`);
111
135
  console.log(` ${c.dim}(Looks for numbers with units — days/ms/req-s/GSIs/roles/… — and status/enum lists.)${c.reset}\n`);
112
136
  return;
113
137
  }
@@ -120,6 +144,9 @@ export function runVerify(projectDir, config, flags) {
120
144
  }
121
145
 
122
146
  console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
147
+ if (evidenceCoverage.verifiedWithinScope > 0) {
148
+ console.log(` ${c.green}✓ ${evidenceCoverage.verifiedWithinScope} additional discovered claim(s) have unique verified-within-scope declarations.${c.reset}\n`);
149
+ }
123
150
  if (changeContext) {
124
151
  const nChanged = tasks.filter(t => t.aboutChangedCode).length;
125
152
  console.log(` ${c.cyan}⚡ ${nChanged} claim(s) are about code changed since ${flags.since}${c.reset} ${c.dim}— verify these first (structured diff in --format json).${c.reset}\n`);
@@ -140,6 +167,42 @@ export function runVerify(projectDir, config, flags) {
140
167
  console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
141
168
  }
142
169
 
170
+ function runEvidenceVerification(projectDir, config, flags) {
171
+ const evaluation = evaluateEvidence(projectDir, config);
172
+ if (flags.format === 'json') {
173
+ console.log(JSON.stringify(evaluation, null, 2));
174
+ return;
175
+ }
176
+ console.log(`${c.bold}🔬 DocGuard Verify — declared evidence${c.reset}`);
177
+ console.log(`${c.dim} ${config.projectName} · exact statement-to-source checks${c.reset}\n`);
178
+ if (!evaluation.exists) {
179
+ console.log(` ${c.dim}No .docguard-evidence.json manifest is configured.${c.reset}`);
180
+ console.log(` ${c.dim}Start from templates/evidence-manifest.json; heuristic discovery remains available with docguard verify --semantic.${c.reset}\n`);
181
+ return;
182
+ }
183
+ if (evaluation.errors.length) {
184
+ console.log(` ${c.red}Invalid evidence manifest:${c.reset}`);
185
+ for (const error of evaluation.errors) console.log(` ${c.red}✗${c.reset} ${error.message}`);
186
+ console.log('');
187
+ return;
188
+ }
189
+ const symbols = {
190
+ 'verified-within-scope': `${c.green}✓${c.reset}`,
191
+ contradicted: `${c.red}✗${c.reset}`,
192
+ stale: `${c.yellow}↻${c.reset}`,
193
+ inconclusive: `${c.yellow}?${c.reset}`,
194
+ unsupported: `${c.yellow}◇${c.reset}`,
195
+ };
196
+ for (const state of ['contradicted', 'stale', 'inconclusive', 'unsupported', 'verified-within-scope']) {
197
+ const results = evaluation.results.filter(result => result.state === state);
198
+ if (!results.length) continue;
199
+ console.log(` ${c.bold}${state}${c.reset} (${results.length})`);
200
+ for (const result of results) console.log(` ${symbols[state]} ${result.declarationId} · ${result.location} · ${result.message}`);
201
+ console.log('');
202
+ }
203
+ console.log(` ${c.dim}${evaluation.scopeLimitation}${c.reset}\n`);
204
+ }
205
+
143
206
  // ── verify --instructions: agent-instruction drift/conflict audit ───────────
144
207
 
145
208
  function runInstructionAudit(projectDir, config, flags) {
package/cli/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { hasWorkerConfig } from './shared-source.mjs';
2
2
  import { applyDocRoles } from './shared-doc-roles.mjs';
3
3
  /**
4
+ * @implements docguard.evidence-scoped-verification#FR-010
4
5
  * DocGuard — configuration loading.
5
6
  *
6
7
  * Extracted from docguard.mjs (v0.23.0) to break the demo.mjs → docguard.mjs
@@ -81,6 +82,7 @@ export function loadConfig(projectDir) {
81
82
  freshness: true,
82
83
  documentLifecycle: true,
83
84
  specRegistry: true,
85
+ evidence: true,
84
86
  // v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
85
87
  // heuristic (field cases require ongoing precision checks), and quiet when
86
88
  // not applicable (no diff / no API-reference doc). api-doc-smells is
@@ -225,6 +227,7 @@ const _KNOWN_VALIDATORS = [
225
227
  'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
226
228
  'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
227
229
  'canonicalSync', 'surfaceSync', 'metricsConsistency',
230
+ 'evidence',
228
231
  ];
229
232
 
230
233
  function _kebabToCamel(k) {