docguard-cli 0.35.0 → 0.36.1

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 (55) hide show
  1. package/README.md +8 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/README.md +6 -6
  42. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  43. package/extensions/spec-kit-docguard/extension.yml +3 -4
  44. package/extensions/spec-kit-docguard/scripts/bash/common.sh +9 -17
  45. package/extensions/spec-kit-docguard/scripts/bash/docguard-check-docs.sh +18 -11
  46. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +3 -3
  47. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  51. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  52. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  53. package/package.json +1 -1
  54. package/schemas/docguard-config.schema.json +43 -1
  55. package/templates/ci/github-actions.yml +51 -11
@@ -1,3 +1,5 @@
1
+ import { describeCheckCoverage, summarizeCheckCoverage } from '../validator-coverage.mjs';
2
+ import { applyDocRoles } from '../shared-doc-roles.mjs';
1
3
  /**
2
4
  * Guard Command — Validate project against its canonical documentation
3
5
  * Runs all enabled validators and reports results.
@@ -8,7 +10,7 @@
8
10
  */
9
11
 
10
12
  import { c, resolveSeverity, loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
11
- import { walkFiles } from '../shared-ignore.mjs';
13
+ import { walkFiles, buildIgnoreFilter } from '../shared-ignore.mjs';
12
14
  import { mkFinding, resultFromFindings } from '../findings.mjs';
13
15
  import { loadValidatorSuppressions } from '../validator-markers.mjs';
14
16
  import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
@@ -239,28 +241,29 @@ function collectMarkdown(projectDir) {
239
241
  /**
240
242
  * Classify every discoverable Markdown file into a validation tier:
241
243
  * canonical — in requiredFiles.canonical (structure + review-gated)
242
- * tracked — under a doc home or root-level (claim/freshness checks reach it)
244
+ * tracked — inventoried under a doc home or at root; individual detector scopes differ
243
245
  * ignored — matched by .docguardignore
244
246
  * unclassified — under NO tier; drift here is invisible (the Gap-1 trap)
245
247
  */
246
248
  function computeDocCoverage(projectDir, config) {
247
- const isIgnored = loadIgnorePatterns(projectDir);
249
+ const fileIgnored = loadIgnorePatterns(projectDir);
250
+ const configIgnored = buildIgnoreFilter(config.ignore || []);
251
+ const isIgnored = path => fileIgnored(path) || configIgnored(path);
248
252
  const canonical = new Set(
249
253
  ((config.requiredFiles && config.requiredFiles.canonical) || []).map(p => p.replace(/\\/g, '/'))
250
254
  );
251
255
  // Any path declared in documentTypes is a KNOWN doc (even if optional) — not
252
256
  // "untracked." This keeps the warning specific to genuinely-unenrolled files.
253
257
  const known = new Set(Object.keys(config.documentTypes || {}).map(p => p.replace(/\\/g, '/')));
254
- // Same doc-home set the claim scanner uses so "tracked" provably means
255
- // "actually scanned," never a label the scanner ignores. With trailing slash
256
- // for prefix matching.
258
+ // This is a document inventory, not a claim that every detector checks
259
+ // each tracked file. Keep individual check coverage separate.
257
260
  const docHomePrefixes = resolveDocDirs(projectDir, config).map(d => d.replace(/\/?$/, '/'));
258
261
  const all = collectMarkdown(projectDir);
259
262
  let canonicalCount = 0, tracked = 0, ignored = 0;
260
263
  const unclassified = [];
261
264
  for (const rel of all) {
262
- if (canonical.has(rel)) { canonicalCount++; continue; }
263
265
  if (isIgnored(rel) || DOCGUARD_OWN_DOC_RE.test(rel)) { ignored++; continue; }
266
+ if (canonical.has(rel)) { canonicalCount++; continue; }
264
267
  const inHome = docHomePrefixes.some(h => rel.startsWith(h));
265
268
  const atRoot = !rel.includes('/');
266
269
  if (inHome || atRoot || known.has(rel)) { tracked++; continue; }
@@ -270,6 +273,7 @@ function computeDocCoverage(projectDir, config) {
270
273
  }
271
274
 
272
275
  export function runGuardInternal(projectDir, config) {
276
+ config = applyDocRoles(projectDir, config);
273
277
  const validators = config.validators || {};
274
278
  const results = [];
275
279
 
@@ -286,8 +290,7 @@ export function runGuardInternal(projectDir, config) {
286
290
  { key: 'freshness', name: 'Freshness', fn: () => {
287
291
  // v0.29: adapter now emits structured findings (FRS001–FRS005). The
288
292
  // validator keeps its array-of-{status, code, doc, message} contract;
289
- // messages are byte-identical (the sweep-needed nudge below regex-matches
290
- // them), so counts/exit codes are unchanged.
293
+ // messages describe review signals rather than asserting semantic drift.
291
294
  const freshnessResults = validateFreshness(projectDir, config);
292
295
  const findings = [];
293
296
  let passed = 0;
@@ -298,14 +301,14 @@ export function runGuardInternal(projectDir, config) {
298
301
  code: r.code || null,
299
302
  validator: 'freshness',
300
303
  severity: r.status === 'fail' ? 'error' : 'warn',
304
+ confidence: r.confidence || 'low',
301
305
  message: r.message,
302
306
  location: r.doc || null,
303
- suggestion: r.code === 'FRS001'
304
- ? { kind: 'fix', text: 'Commit the doc, or stamp it reviewed', pragma: '<!-- docguard:last-reviewed YYYY-MM-DD -->' }
305
- : { kind: 'fix', text: 'Refresh the stale code-truth sections', command: 'docguard sync --write' },
307
+ suggestion: r.suggestion || { kind: 'review', text: 'Review the document against its intended scope. A history signal does not establish which side should change.' },
306
308
  }));
307
309
  }
308
- return resultFromFindings(findings, { passed, total: passed + findings.length });
310
+ const skipped = freshnessResults.filter(r => r.status === 'skip');
311
+ return { ...resultFromFindings(findings, { passed, total: passed + findings.length }), ...(passed + findings.length === 0 && skipped.length ? { applicability: { status: 'no-matches', reason: skipped.map(r => r.message).join('; ') } } : {}) };
309
312
  }},
310
313
  { key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
311
314
  { key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
@@ -358,7 +361,7 @@ export function runGuardInternal(projectDir, config) {
358
361
  results.push({ ...result, name, key, durationMs, ...classifyResult(result) });
359
362
  } catch (err) {
360
363
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
361
- results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
364
+ results.push({ name, key, status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
362
365
  }
363
366
  }
364
367
 
@@ -376,7 +379,7 @@ export function runGuardInternal(projectDir, config) {
376
379
  results.push({ ...result, name: 'Canonical-Sync', key: 'canonicalSync', durationMs, ...classifyResult(result) });
377
380
  } catch (err) {
378
381
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
379
- results.push({ name: 'Canonical-Sync', key: 'canonicalSync', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
382
+ results.push({ name: 'Canonical-Sync', key: 'canonicalSync', status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
380
383
  }
381
384
  }
382
385
 
@@ -391,7 +394,7 @@ export function runGuardInternal(projectDir, config) {
391
394
  results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', durationMs, ...classifyResult(result) });
392
395
  } catch (err) {
393
396
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
394
- results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
397
+ results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
395
398
  }
396
399
  }
397
400
 
@@ -431,6 +434,11 @@ export function runGuardInternal(projectDir, config) {
431
434
  }
432
435
  }
433
436
 
437
+ for (const key of ['canonicalSync', 'metricsConsistency']) {
438
+ if (validators[key] === false && !results.some(r => r.key === key)) results.push({ key, name: key === 'canonicalSync' ? 'Canonical-Sync' : 'Metrics-Consistency', status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0, durationMs: 0 });
439
+ }
440
+ for (const result of results) result.applicability = describeCheckCoverage(projectDir, config, result);
441
+ const checkCoverage = summarizeCheckCoverage(results);
434
442
  const activeResults = results.filter(r => r.status !== 'skipped');
435
443
  const totalErrors = activeResults.reduce((sum, r) => sum + r.errors.length, 0);
436
444
  const totalWarnings = activeResults.reduce((sum, r) => sum + r.warnings.length, 0);
@@ -504,6 +512,7 @@ export function runGuardInternal(projectDir, config) {
504
512
  effectiveWarnings,
505
513
  baselineSuppressed,
506
514
  coverage,
515
+ checkCoverage,
507
516
  semanticClaims,
508
517
  validators: results,
509
518
  // Unknown keys in `docguard:validator … n/a` markers — typo protection so
@@ -671,8 +680,9 @@ export function runGuard(projectDir, config, flags) {
671
680
  // Not applicable — nothing to validate. Render neutrally (NOT a green pass)
672
681
  // so the reader can tell "checked and clean" apart from "nothing checked".
673
682
  if (v.status === 'na') {
674
- const reason = v.note ? ` ${c.dim}(${v.note})${c.reset}` : ` ${c.dim}(nothing to validate)${c.reset}`;
675
- console.log(` ${c.dim}${v.name}${c.reset} ${c.dim}[N/A]${c.reset}${reason}`);
683
+ const showReason = flags.verbose || ['unsupported', 'partial', 'missing-prerequisite', 'error'].includes(v.applicability.status);
684
+ const reason = showReason ? ` ${c.dim}(${v.applicability.reason})${c.reset}` : '';
685
+ console.log(` ${c.dim}➖ ${v.name}${c.reset} ${c.dim}[${v.applicability.status}]${c.reset}${reason}`);
676
686
  continue;
677
687
  }
678
688
 
@@ -770,6 +780,8 @@ export function runGuard(projectDir, config, flags) {
770
780
  if (Array.isArray(data.reportable) && data.reportable.length > 0) {
771
781
  const n = data.reportable.length;
772
782
  console.log(` ${c.dim}↪ ${n} finding(s) look uncertain (possible false positives). Review or report: ${c.cyan}${skill('feedback')}${c.reset}`);
783
+ } else if (Array.isArray(data.findings) && data.findings.length > 0) {
784
+ console.log(` ${c.dim}Disagree with a finding? Review a contribution: ${c.cyan}docguard feedback --code <CODE> --preview${c.reset}`);
773
785
  }
774
786
 
775
787
  // Read-only skills nudge (never writes — that's `init`'s job). If the agent
@@ -801,9 +813,14 @@ export function runGuard(projectDir, config, flags) {
801
813
  }
802
814
  }
803
815
  }
816
+ if (data.checkCoverage) {
817
+ const counts = data.checkCoverage.counts;
818
+ console.log(' Check coverage: ' + Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => count + ' ' + status.replaceAll('-', ' ')).join(' · '));
819
+ console.log(' Document inventory and passing checks do not establish factual accuracy.');
820
+ }
804
821
  if (data.semanticClaims && data.semanticClaims.count > 0) {
805
822
  console.log(`\n ${c.cyan}🔍 ${data.semanticClaims.count} documented claim(s) (counts/limits/enums) are unverified against code.${c.reset}`);
806
- console.log(` ${c.dim}A green guard means the structure is sound — NOT that these values still match the code.${c.reset}`);
823
+ console.log(` ${c.dim}A passing guard means configured gates passed; these values remain unverified.${c.reset}`);
807
824
  console.log(` ${c.dim}Confirm them: ${c.cyan}${skill('verify')} --semantic${c.reset}`);
808
825
  }
809
826
 
@@ -873,7 +890,7 @@ export function runGuard(projectDir, config, flags) {
873
890
  if (freshness && freshness.warnings) {
874
891
  const staleDocs = freshness.warnings.filter(w => /\d+ code commits since/.test(w));
875
892
  if (staleDocs.length >= 2) {
876
- console.log(`\n ${c.yellow}↻ ${staleDocs.length} docs are stale (10+ commits since last update). Run ${c.cyan}docguard sync --write${c.yellow} to refresh code-truth sections in one pass.${c.reset}`);
893
+ console.log(`\n ${c.yellow}↻ ${staleDocs.length} documents have repository-history review signals. Review their scope and intended behavior before changing documentation or code.${c.reset}`);
877
894
  }
878
895
  }
879
896
  }
@@ -3,7 +3,7 @@
3
3
  * Creates git hooks that run guard/score before commits.
4
4
  */
5
5
 
6
- import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
7
7
 
8
8
  // v0.16-P3: managed-block markers. Letting users extend the hook with their
9
9
  // own commands (data-file guards, lint checks, etc.) without us clobbering
@@ -56,9 +56,28 @@ function spliceManagedBlock(existing, newBody) {
56
56
  }
57
57
  import { resolve, relative, basename } from 'node:path';
58
58
  import { c } from '../shared.mjs';
59
+ import { safeWrite } from '../writers/generate-io.mjs';
59
60
  import { getHooksDir } from '../shared-git.mjs';
60
61
  import { listCanonicalDocs } from '../shared-ignore.mjs';
61
62
 
63
+ // Git enforcement is offline and fail-closed; agent nudges stay best-effort.
64
+ const ENFORCEMENT_RUNTIME = `
65
+ if ! command -v node >/dev/null 2>&1; then
66
+ echo "❌ Node.js runtime not found — operation blocked" >&2
67
+ exit 1
68
+ fi
69
+
70
+ # Git runs these hooks from the worktree root. Prefer its installed version.
71
+ if [ -x "./node_modules/.bin/docguard" ]; then
72
+ DOCGUARD="./node_modules/.bin/docguard"
73
+ elif command -v docguard >/dev/null 2>&1; then
74
+ DOCGUARD="docguard"
75
+ else
76
+ echo "❌ DocGuard not found locally or on PATH — operation blocked" >&2
77
+ exit 1
78
+ fi
79
+ `;
80
+
62
81
  const HOOKS = {
63
82
  'pre-commit': {
64
83
  description: 'Run docguard guard before every commit',
@@ -70,20 +89,11 @@ const HOOKS = {
70
89
 
71
90
  echo "🛡️ Running DocGuard guard..."
72
91
 
73
- # Check if docguard is available
74
- if command -v npx &> /dev/null; then
75
- npx docguard-cli guard
76
- EXIT_CODE=$?
77
- elif command -v docguard &> /dev/null; then
78
- docguard guard
79
- EXIT_CODE=$?
80
- else
81
- echo "⚠️ DocGuard not found. Skipping guard check."
82
- echo " Install: npm install -g docguard"
83
- exit 0
84
- fi
92
+ ${ENFORCEMENT_RUNTIME}
93
+ "$DOCGUARD" guard
94
+ EXIT_CODE=$?
85
95
 
86
- if [ $EXIT_CODE -eq 1 ]; then
96
+ if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 2 ]; then
87
97
  echo ""
88
98
  echo "❌ DocGuard guard FAILED — commit blocked"
89
99
  echo " Fix the errors above, then try again."
@@ -110,22 +120,28 @@ MIN_SCORE=60
110
120
 
111
121
  echo "📊 Running DocGuard score check (minimum: $MIN_SCORE)..."
112
122
 
113
- # Get score as JSON
114
- if command -v npx &> /dev/null; then
115
- RESULT=$(npx docguard-cli score --format json 2>/dev/null)
116
- elif command -v docguard &> /dev/null; then
117
- RESULT=$(docguard score --format json 2>/dev/null)
118
- else
119
- echo "⚠️ DocGuard not found. Skipping score check."
120
- exit 0
121
- fi
123
+ ${ENFORCEMENT_RUNTIME}
122
124
 
123
- # Parse score from JSON
124
- SCORE=$(echo "$RESULT" | grep -o '"score":[0-9]*' | head -1 | cut -d: -f2)
125
+ # Score has no warning exit status: any command failure blocks the push.
126
+ RESULT=$("$DOCGUARD" score --format json)
127
+ EXIT_CODE=$?
128
+ if [ "$EXIT_CODE" -ne 0 ]; then
129
+ echo "❌ DocGuard score failed (exit $EXIT_CODE) — push blocked" >&2
130
+ exit 1
131
+ fi
125
132
 
126
- if [ -z "$SCORE" ]; then
127
- echo "⚠️ Could not determine CDD score. Push allowed."
128
- exit 0
133
+ # Parse the complete JSON document, never a substring or a nested score.
134
+ SCORE=$(printf '%s' "$RESULT" | node -e '
135
+ try {
136
+ const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
137
+ if (!result || Array.isArray(result) || !Number.isInteger(result.score) ||
138
+ result.score < 0 || result.score > 100) process.exit(1);
139
+ process.stdout.write(String(result.score));
140
+ } catch { process.exit(1); }
141
+ ')
142
+ if [ "$?" -ne 0 ] || [ -z "$SCORE" ]; then
143
+ echo "❌ Could not determine a valid CDD score — push blocked" >&2
144
+ exit 1
129
145
  fi
130
146
 
131
147
  echo " CDD Score: $SCORE/100"
@@ -188,24 +204,29 @@ const PRE_COMMIT_AUTOFIX = `#!/bin/sh
188
204
  # Install: docguard hooks --type pre-commit --auto-fix
189
205
  # Remove: rm .git/hooks/pre-commit
190
206
 
191
- RUN="npx docguard-cli"
192
- if command -v docguard >/dev/null 2>&1; then RUN="docguard"; fi
207
+ ${ENFORCEMENT_RUNTIME}
193
208
 
194
209
  echo "🛡️ DocGuard: applying mechanical fixes…"
195
210
  # 1. Deterministically remove stale documented endpoints (safe, no AI).
196
- $RUN fix --write
211
+ if ! "$DOCGUARD" fix --write; then
212
+ echo "❌ DocGuard fix failed — commit blocked" >&2
213
+ exit 1
214
+ fi
197
215
  # 2. Re-stage anything DocGuard rewrote so the fix is part of THIS commit.
198
- git add docs-canonical/ 2>/dev/null
216
+ if ! git add docs-canonical/; then
217
+ echo "❌ Could not stage DocGuard fixes — commit blocked" >&2
218
+ exit 1
219
+ fi
199
220
 
200
221
  # 3. Validate.
201
- $RUN guard
222
+ "$DOCGUARD" guard
202
223
  EXIT_CODE=$?
203
224
 
204
- if [ $EXIT_CODE -eq 1 ]; then
225
+ if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 2 ]; then
205
226
  echo ""
206
227
  echo "❌ DocGuard guard FAILED — commit blocked."
207
228
  echo " Remaining issues need an AI agent (content rewrites, not mechanical):"
208
- echo " Run: $RUN diagnose (emits ready-to-paste agent fix prompts)"
229
+ echo " Run: $DOCGUARD diagnose (emits ready-to-paste agent fix prompts)"
209
230
  echo " To skip: git commit --no-verify"
210
231
  exit 1
211
232
  elif [ $EXIT_CODE -eq 2 ]; then
@@ -306,7 +327,7 @@ export function runHooks(projectDir, config, flags) {
306
327
  // re-install.
307
328
  const spliced = spliceManagedBlock(existing, newContent);
308
329
  if (spliced !== null) {
309
- writeFileSync(hookPath, spliced, 'utf-8');
330
+ safeWrite(hookPath, spliced);
310
331
  chmodSync(hookPath, 0o755);
311
332
  console.log(` ${c.green}↻ ${name}${c.reset}: updated DocGuard managed block (preserved user content around it)`);
312
333
  installed++;
@@ -330,7 +351,7 @@ export function runHooks(projectDir, config, flags) {
330
351
  // --force path: write fresh managed-block version
331
352
  }
332
353
 
333
- writeFileSync(hookPath, newContent, 'utf-8');
354
+ safeWrite(hookPath, newContent);
334
355
  chmodSync(hookPath, 0o755);
335
356
  console.log(` ${c.green}✅ ${name}${c.reset}: ${desc}`);
336
357
  installed++;
@@ -402,7 +423,7 @@ export function installClaudeNudge(projectDir, { remove = false } = {}) {
402
423
  settings.hooks.PostToolUse = groups.filter(g => !isOurNudgeGroup(g));
403
424
  if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
404
425
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
405
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
426
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
406
427
  console.log(` ${c.yellow}🗑️ Removed the DocGuard nudge hook from .claude/settings.json${c.reset} ${c.dim}(everything else preserved)${c.reset}\n`);
407
428
  return;
408
429
  }
@@ -420,7 +441,7 @@ export function installClaudeNudge(projectDir, { remove = false } = {}) {
420
441
  });
421
442
 
422
443
  if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
423
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
444
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
424
445
  console.log(` ${c.green}✅ Installed the DocGuard nudge hook${c.reset} → .claude/settings.json (PostToolUse)`);
425
446
  console.log(` ${c.dim}After an agent edits a canonical doc (or code the docs reference), it is${c.reset}`);
426
447
  console.log(` ${c.dim}nudged toward docguard guard --changed-only / docguard impact.${c.reset}`);
@@ -472,7 +493,7 @@ export function runNudgeHook(projectDir) {
472
493
  state[rel] = now;
473
494
  try {
474
495
  mkdirSync(resolve(projectDir, '.docguard'), { recursive: true });
475
- writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
496
+ safeWrite(statePath, JSON.stringify(state, null, 2) + '\n');
476
497
  } catch { /* state is best-effort; still nudge */ }
477
498
 
478
499
  process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
@@ -1,3 +1,4 @@
1
+ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Init Command — Initialize CDD documentation from templates
3
4
  *
@@ -9,7 +10,7 @@
9
10
  * with a warning suggesting spec-kit installation.
10
11
  */
11
12
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, lstatSync, realpathSync, copyFileSync } from 'node:fs';
13
14
  import { resolve, dirname } from 'node:path';
14
15
  import { listCanonicalDocs } from '../shared-ignore.mjs';
15
16
  import { fileURLToPath } from 'node:url';
@@ -17,14 +18,15 @@ import { createInterface } from 'node:readline';
17
18
  import { execSync } from 'node:child_process';
18
19
  import { c, PROFILES, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
19
20
  import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
21
+ import { safeWrite } from '../writers/generate-io.mjs';
20
22
 
21
23
  // v0.20: scaffolder names that can be passed via `init --with <name>` and
22
- // dispatched to the corresponding standalone runner. Each name maps to its
23
- // canonical command module. Keep in sync with cli/docguard.mjs router.
24
+ // dispatched to their writers. CI scaffolding is distinct from the standalone
25
+ // `ci` validation gate; it copies the maintained workflow starter.
24
26
  const SCAFFOLDER_DISPATCH = {
25
27
  agents: async (dir, cfg, flags) => (await import('./agents.mjs')).runAgents(dir, cfg, flags),
26
28
  hooks: async (dir, cfg, flags) => (await import('./hooks.mjs')).runHooks(dir, cfg, flags),
27
- ci: async (dir, cfg, flags) => (await import('./ci.mjs')).runCI(dir, cfg, flags),
29
+ ci: (dir, cfg, flags) => scaffoldCI(dir, flags),
28
30
  badge: async (dir, cfg, flags) => (await import('./badge.mjs')).runBadge(dir, cfg, flags),
29
31
  llms: async (dir, cfg, flags) => (await import('./llms.mjs')).runLlms(dir, cfg, flags),
30
32
  publish: async (dir, cfg, flags) => (await import('./publish.mjs')).runPublish(dir, cfg, flags),
@@ -79,6 +81,43 @@ const __filename = fileURLToPath(import.meta.url);
79
81
  const __dirname = dirname(__filename);
80
82
  const TEMPLATES_DIR = resolve(__dirname, '../../templates');
81
83
 
84
+ function scaffoldCI(projectDir, flags) {
85
+ // Resolve the user's root once (e.g. macOS /tmp), then refuse symlinks in
86
+ // every destination component, including the backup safeWrite will use.
87
+ const root = realpathSync(projectDir);
88
+ const target = resolve(root, '.github/workflows/docguard.yml');
89
+ let existing;
90
+ for (const [path, directory] of [
91
+ [resolve(root, '.github'), true],
92
+ [dirname(target), true],
93
+ [target, false],
94
+ [target + '.bak', false],
95
+ ]) {
96
+ let stat;
97
+ try { stat = lstatSync(path); } catch (err) {
98
+ if (err.code === 'ENOENT') continue;
99
+ throw err;
100
+ }
101
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) {
102
+ throw new Error(`Unsafe CI scaffold path: ${path} (expected a real ${directory ? 'directory' : 'file'}, not a symlink)`);
103
+ }
104
+ if (!directory && stat.nlink > 1) throw new Error(`Unsafe CI scaffold path: ${path} (hard-linked file)`);
105
+ if (path === target) existing = stat;
106
+ }
107
+ if (existing && !flags.force) {
108
+ console.log(` ${c.dim}⏭️ .github/workflows/docguard.yml exists; use --force to overwrite${c.reset}`);
109
+ return;
110
+ }
111
+ // Copy literally: the starter's fixed package pin is release-tested, and
112
+ // GitHub expressions must not go through canonical-doc interpolation.
113
+ const content = readFileSync(resolve(TEMPLATES_DIR, 'ci/github-actions.yml'), 'utf-8');
114
+ // safeWrite's generic backup is best-effort and skips empty files. Here a
115
+ // forced replacement requires a successful backup, including empty files.
116
+ if (existing) copyFileSync(target, target + '.bak');
117
+ safeWrite(target, content);
118
+ console.log(` ${c.green}✅ .github/workflows/docguard.yml ${existing ? 'replaced (backup: docguard.yml.bak)' : 'created'}${c.reset}`);
119
+ }
120
+
82
121
  /**
83
122
  * v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
84
123
  * marker right after the first H1, so a canonical doc has a freshness signal the
@@ -160,6 +199,7 @@ function shouldRunGenerate(projectDir, flags) {
160
199
  }
161
200
 
162
201
  export async function runInit(projectDir, config, flags) {
202
+ if (true) assertDefaultDocWrites(config);
163
203
  // v0.20: `--wizard` dispatches to the full interactive onboarding (formerly
164
204
  // `docguard setup`). Done before profile validation so the wizard can ask
165
205
  // for the profile itself if needed.
@@ -178,7 +218,13 @@ export async function runInit(projectDir, config, flags) {
178
218
  console.log(`${c.dim} canonical docs from your code instead of dumping a blank skeleton.${c.reset}`);
179
219
  console.log(`${c.dim} (Opt out: ${c.cyan}docguard init --skeleton${c.dim} for the blank-template path.)${c.reset}\n`);
180
220
  const { runGenerate } = await import('./generate.mjs');
181
- return runGenerate(projectDir, config, { ...flags, plan: true });
221
+ const result = await runGenerate(projectDir, config, { ...flags, plan: true });
222
+ // Smart init still honors explicit workflow/scaffolder requests after the
223
+ // plan, with the same ordering and stop-on-failure semantics as skeletons.
224
+ if (Array.isArray(flags.with) && flags.with.length > 0) {
225
+ await runScaffolders(projectDir, config, flags, flags.with);
226
+ }
227
+ return result;
182
228
  }
183
229
 
184
230
  const profileName = flags.profile || 'standard';
@@ -21,10 +21,12 @@
21
21
  * Zero NPM dependencies. Pure orchestration of existing diff helpers.
22
22
  */
23
23
 
24
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
24
+ import { existsSync } from 'node:fs';
25
25
  import { resolve } from 'node:path';
26
26
  import { c } from '../shared.mjs';
27
- import { listCanonicalDocs } from '../shared-ignore.mjs';
27
+ import { createEvidenceReader, evidenceDocs, extractSemanticClaims, contentHash, gitEvidence, SEMANTIC_COVERAGE_LIMITATION } from '../scanners/semantic-claims.mjs';
28
+ import { safeWrite } from '../writers/generate-io.mjs';
29
+ import { buildScoreAssurance } from './score.mjs';
28
30
  import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
29
31
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
30
32
  import { runGuardInternal } from './guard.mjs';
@@ -75,13 +77,17 @@ function extractConventions(agentsMd, capLines = 60) {
75
77
 
76
78
  /**
77
79
  * `docguard memory --pack` — write .docguard/context-pack.md: a compact,
78
- * code-truth-stamped session-start context for an AI agent. Everything in it
79
- * is derived from scanners (buildMemoryPlan) and guard — numbers, not prose
80
- * so it can't hallucinate and is always regenerable.
80
+ * session-start snapshot for an AI agent. Scanner output and copied prose
81
+ * remain unverified; hashes identify captured inputs, not factual accuracy.
81
82
  */
82
83
  function runMemoryPack(projectDir, config, flags) {
83
- const plan = buildMemoryPlan(projectDir, config);
84
+ const plan = buildMemoryPlan(projectDir, { ...config, diskCache: false });
84
85
  const guard = runGuardInternal(projectDir, config);
86
+ const read = createEvidenceReader(projectDir);
87
+ const git = gitEvidence(projectDir);
88
+ const assurance = buildScoreAssurance(projectDir, config);
89
+ let claims = null;
90
+ try { claims = extractSemanticClaims(projectDir, config, read); } catch { /* unknown, not an empty verified set */ }
85
91
  const lines = [];
86
92
 
87
93
  lines.push(`# Context Pack — ${config.projectName}`);
@@ -91,6 +97,15 @@ function runMemoryPack(projectDir, config, flags) {
91
97
  lines.push(`**Guard:** ${guard.status} — ${guard.passed}/${guard.total} checks (${guard.errors} error(s), ${guard.warnings} warning(s))`);
92
98
  lines.push('');
93
99
 
100
+ lines.push('**Provenance:** snapshot only — not reviewed or verified');
101
+ lines.push(`- Git revision: ${git.revision ?? 'unknown'} · dirty: ${git.dirty ?? 'unknown'}`);
102
+ lines.push(`- Assurance: structural-only · factual accuracy: unknown · status: ${assurance.status}`);
103
+ lines.push(`- Unverified claims: ${assurance.unverifiedClaims ?? 'unknown'} extracted candidates (heuristic and capped; zero does not establish prose correctness)`);
104
+ lines.push(`- Claim evidence fingerprint: ${claims ? contentHash(JSON.stringify(claims.map(c => [c.stableId, c.evidence.snapshotHash]).sort())) : 'unknown'}`);
105
+ lines.push(`- Coverage limitation: ${SEMANTIC_COVERAGE_LIMITATION}`);
106
+ lines.push('Hashes capture current document and cited-source content. Regenerate after changes; timestamps and last-reviewed labels do not establish accuracy.');
107
+ lines.push('');
108
+
94
109
  lines.push('## Code-truth surface');
95
110
  lines.push('');
96
111
  lines.push(`- Stack: ${plan.profile.languages.join(', ') || 'unknown'}${plan.profile.frameworks.length ? ` · ${plan.profile.frameworks.join(', ')}` : ''} · kind: ${plan.profile.kind}`);
@@ -98,17 +113,18 @@ function runMemoryPack(projectDir, config, flags) {
98
113
  lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
99
114
  lines.push('');
100
115
 
101
- const canonicalDocs = listCanonicalDocs(projectDir);
116
+ const canonicalDocs = evidenceDocs(projectDir, config);
102
117
  if (canonicalDocs.length > 0) {
103
118
  lines.push('## Canonical docs');
104
119
  lines.push('');
105
120
  for (const doc of canonicalDocs) {
106
121
  let reviewed = '';
107
122
  try {
108
- const m = readFileSync(doc.abs, 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
123
+ const m = (read(doc).content || '').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
109
124
  if (m) reviewed = ` (last-reviewed ${m[1]})`;
110
125
  } catch { /* ignore */ }
111
- lines.push(`- ${doc.rel}${reviewed}`);
126
+ const evidence = read(doc).evidence;
127
+ lines.push(`- ${doc}${reviewed} — content: ${evidence.hash ?? `unknown (${evidence.reason})`}`);
112
128
  }
113
129
  lines.push('');
114
130
  }
@@ -116,7 +132,7 @@ function runMemoryPack(projectDir, config, flags) {
116
132
  const agentsPath = resolve(projectDir, 'AGENTS.md');
117
133
  if (existsSync(agentsPath)) {
118
134
  let conventions = [];
119
- try { conventions = extractConventions(readFileSync(agentsPath, 'utf-8')); } catch { /* ignore */ }
135
+ try { conventions = extractConventions(read('AGENTS.md').content || ''); } catch { /* ignore */ }
120
136
  if (conventions.length > 0) {
121
137
  lines.push('## Project rules (from AGENTS.md)');
122
138
  lines.push('');
@@ -128,7 +144,7 @@ function runMemoryPack(projectDir, config, flags) {
128
144
  const driftPath = resolve(projectDir, 'DRIFT-LOG.md');
129
145
  if (existsSync(driftPath)) {
130
146
  try {
131
- const drift = readFileSync(driftPath, 'utf-8');
147
+ const drift = read('DRIFT-LOG.md').content || '';
132
148
  const entries = drift.match(/^##\s+.+$/gm) || [];
133
149
  if (entries.length > 0) {
134
150
  lines.push('## Known drift');
@@ -148,10 +164,8 @@ function runMemoryPack(projectDir, config, flags) {
148
164
  console.log(content);
149
165
  return;
150
166
  }
151
- const outDir = resolve(projectDir, '.docguard');
152
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
153
- const outPath = resolve(outDir, 'context-pack.md');
154
- writeFileSync(outPath, content, 'utf-8');
167
+ const outPath = resolve(projectDir, '.docguard/context-pack.md');
168
+ safeWrite(outPath, content);
155
169
  console.log(`${c.bold}🧠 DocGuard Context Pack${c.reset}`);
156
170
  console.log(`${c.green}✅ Wrote ${outPath}${c.reset} ${c.dim}(${lines.length} lines — load at agent session start)${c.reset}`);
157
171
  console.log('');
@@ -74,22 +74,25 @@ export function buildReport(projectDir, config) {
74
74
  // Audit-critical (H3): evidence must disclose what a committed baseline
75
75
  // is suppressing — "no findings" with a hidden baseline is false green.
76
76
  baselineSuppressed: guardData.baselineSuppressed || 0,
77
+ checkCoverage: guardData.checkCoverage,
77
78
  validators: (guardData.validators || [])
78
79
  .filter(v => v.status !== 'skipped')
79
- .map(v => ({ name: v.name, status: v.status })),
80
+ .map(v => ({ name: v.name, status: v.status, applicability: v.applicability })),
80
81
  },
81
82
  findings: findingsSummary,
82
83
  score: {
83
84
  score: scoreData.score,
84
85
  grade: scoreData.grade,
85
86
  categories: scoreData.categories,
87
+ scoreKind: scoreData.scoreKind,
88
+ assurance: scoreData.assurance,
86
89
  },
87
90
  alcoa: {
88
91
  score: alcoa.score,
89
92
  met: alcoa.met,
90
93
  total: alcoa.total,
91
94
  attributes: alcoa.attributes.map(a => ({
92
- name: a.name, met: a.met, evidence: a.evidence, gap: a.gap,
95
+ name: a.name, met: a.met, status: a.status || (a.met ? 'met' : 'unmet'), evidence: a.evidence, gap: a.gap,
93
96
  })),
94
97
  },
95
98
  fixHistory: {
@@ -109,7 +112,7 @@ export function buildReport(projectDir, config) {
109
112
  return { ...payload, generatedAt: new Date().toISOString(), integrity };
110
113
  }
111
114
 
112
- function toMarkdown(r) {
115
+ export function toMarkdown(r) {
113
116
  const lines = [];
114
117
  const gitLine = r.git
115
118
  ? `commit \`${r.git.commit.slice(0, 12)}\`${r.git.branch ? ` (${r.git.branch})` : ' (detached HEAD)'}${r.git.dirty ? ' — **uncommitted changes present**' : ''}`
@@ -123,7 +126,8 @@ function toMarkdown(r) {
123
126
  lines.push('');
124
127
  lines.push('| Metric | Value |');
125
128
  lines.push('|--------|-------|');
126
- lines.push(`| CDD Score | ${r.score.score}/100 (${r.score.grade}) |`);
129
+ lines.push(`| CDD Score (structural maturity) | ${r.score.score}/100 (${r.score.grade}) |`);
130
+ lines.push(`| Factual accuracy | Unverified — ${r.score.assurance.unverifiedClaims ?? 'unknown number of'} extracted claim(s); discovery is heuristic |`);
127
131
  lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`);
128
132
  if (r.guard.baselineSuppressed > 0) {
129
133
  lines.push(`| Baseline | ⚠️ ${r.guard.baselineSuppressed} pre-existing finding(s) suppressed by \`.docguard.baseline.json\` — not reflected in the counts above |`);
@@ -147,7 +151,7 @@ function toMarkdown(r) {
147
151
  if (r.findings.length === 0) {
148
152
  lines.push(r.guard.baselineSuppressed > 0
149
153
  ? `No new findings beyond the ${r.guard.baselineSuppressed} suppressed by the committed baseline (run \`docguard guard --no-baseline\` for the full picture).`
150
- : 'No findings documentation matches the implementation at this commit.');
154
+ : 'No findings were emitted by the configured checks. Factual accuracy remains unverified.');
151
155
  } else {
152
156
  lines.push('| Code | Severity | Count | Example |');
153
157
  lines.push('|------|----------|------:|---------|');
@@ -159,10 +163,11 @@ function toMarkdown(r) {
159
163
 
160
164
  lines.push('## ALCOA+ Attributes');
161
165
  lines.push('');
162
- lines.push('| Attribute | Met | Evidence / Gap |');
166
+ lines.push('| Attribute | Status | Evidence / Gap |');
163
167
  lines.push('|-----------|-----|----------------|');
164
168
  for (const a of r.alcoa.attributes) {
165
- lines.push(`| ${a.name} | ${a.met ? '' : ''} | ${(a.met ? a.evidence : a.gap) || '—'} |`);
169
+ const status = a.status === 'unverified' ? '🔍 unverified' : a.met ? '✅ met' : '❌ unmet';
170
+ lines.push(`| ${a.name} | ${status} | ${(a.met ? a.evidence : a.gap) || '—'} |`);
166
171
  }
167
172
  lines.push('');
168
173