docguard-cli 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/cli/commands/reconcile.mjs +42 -0
- package/cli/commands/retire.mjs +11 -15
- package/cli/commands/specs.mjs +179 -3
- package/cli/commands/sync.mjs +16 -44
- package/cli/docguard.mjs +46 -9
- package/cli/scanners/document-lifecycle.mjs +19 -43
- package/cli/scanners/lifecycle-context.mjs +50 -0
- package/cli/scanners/reconciliation.mjs +141 -0
- package/cli/scanners/requirement-evidence.mjs +41 -8
- package/cli/scanners/retirement-manifest.mjs +52 -0
- package/cli/scanners/spec-registry.mjs +36 -9
- package/cli/shared-sync-scope.mjs +35 -0
- package/cli/validators/traceability.mjs +2 -1
- package/cli/writers/file-transaction.mjs +85 -0
- package/cli/writers/spec-outcomes.mjs +27 -0
- package/extensions/spec-kit-docguard/README.md +6 -2
- package/extensions/spec-kit-docguard/commands/complete.md +38 -0
- package/extensions/spec-kit-docguard/extension.yml +20 -4
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +1 -1
- package/schemas/docguard-specs.schema.json +19 -2
- package/templates/ci/github-actions.yml +1 -1
package/README.md
CHANGED
|
@@ -69,13 +69,13 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
|
|
|
69
69
|
|
|
70
70
|
```mermaid
|
|
71
71
|
graph TD
|
|
72
|
-
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (
|
|
72
|
+
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (23)"]
|
|
73
73
|
Commands --> guard["guard"]
|
|
74
74
|
Commands --> generate["generate"]
|
|
75
75
|
Commands --> score["score"]
|
|
76
76
|
Commands --> diagnose["diagnose"]
|
|
77
77
|
Commands --> setup["setup wizard"]
|
|
78
|
-
Commands --> other["diff · init · fix · trace · impact · sync · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
|
|
78
|
+
Commands --> other["diff · init · fix · trace · impact · sync · reconcile · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
|
|
79
79
|
|
|
80
80
|
guard --> Validators["Validators (29)"]
|
|
81
81
|
generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
|
|
@@ -268,7 +268,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
|
|
|
268
268
|
|
|
269
269
|
## Usage
|
|
270
270
|
|
|
271
|
-
DocGuard ships **
|
|
271
|
+
DocGuard ships **23 commands** (the "Daily 5" + 18 situational tools, including lifecycle reconciliation, retirement and spec tracking, the zero-install `demo`, the `mcp` server, and the `ci` pipeline gate). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Legacy command forms remain compatible until v1.0 and print their replacements.
|
|
272
272
|
|
|
273
273
|
**The Daily 5** — what you'll reach for 95% of the time:
|
|
274
274
|
|
|
@@ -296,6 +296,8 @@ DocGuard ships **22 commands** (the "Daily 5" + 17 situational tools, including
|
|
|
296
296
|
| `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
|
|
297
297
|
| `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
|
|
298
298
|
| `retire` | Find completed or superseded planning material (`--plan`/`--check`; `--fail-on-warning` gates advisory candidates) and explicitly remove clean tracked documentation from active AI context. `.docguard-archive.json` records recovery metadata and retired requirement identities, and `--retention-ref` proves the source revision remains reachable. This is separate from the Spec Kit Archive extension, which consolidates feature documents. |
|
|
299
|
+
| `reconcile` | Build a read-only code↔spec review graph since a Git ref. Classifies mechanical facts, approved intent, decisions, unrelated changes, and unsupported evidence; `--write` applies only mechanical generated-section refreshes. |
|
|
300
|
+
| `specs` | Maintain the versioned spec registry, preflight new specs, and apply evidence-gated completion transactions with bounded outcomes and active-context regeneration. |
|
|
299
301
|
| `specs --check` / `specs --write` | Validate or refresh `.docguard-specs.json`, the byte-stable index of immutable spec IDs, reviewed lifecycle/lineage/scope, artifact digests, task state, explicitly scoped test evidence, and archive tombstones. Refreshes preserve the reviewed block. |
|
|
300
302
|
| `specs preflight [--path <spec>]` | Before specification, print current spec lifecycle and evidence. Before planning, check the generated draft for structural blockers and report semantic overlap as review-only evidence. |
|
|
301
303
|
| `mcp` | MCP server — exposes guard/score/explain/verify/report/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
|
|
@@ -333,7 +335,7 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
|
|
|
333
335
|
| `--dir <path>` | Project directory (default: `.`) | All |
|
|
334
336
|
| `--verbose` | Show detailed output | All |
|
|
335
337
|
| `--quiet` / `-q` | Suppress banner — for hooks, CI loops, scripts | All |
|
|
336
|
-
| `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain, retire, specs |
|
|
338
|
+
| `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain, reconcile, retire, specs |
|
|
337
339
|
| `--format sarif` | SARIF 2.1.0 output — findings as rules/results for GitHub Code Scanning and SARIF dashboards | guard |
|
|
338
340
|
| `--format junit` | JUnit XML output — one testcase per validator, for GitLab CI (`artifacts:reports:junit`), Jenkins, Azure DevOps, CircleCI | guard |
|
|
339
341
|
| `--update-baseline` | Adopt DocGuard on a legacy repo without a red day one: freeze today's findings into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift. Suppression is always visible ("N pre-existing finding(s) suppressed"), and `--no-baseline` shows the full picture | guard |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review code/spec changes before any documentation intent is changed.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-012
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { buildReconciliationPlan } from '../scanners/reconciliation.mjs';
|
|
8
|
+
import { runSync } from './sync.mjs';
|
|
9
|
+
|
|
10
|
+
function printPlan(result) {
|
|
11
|
+
console.log(`Reconciliation: ${result.status}`);
|
|
12
|
+
console.log(`Revision: ${result.baseRevision || 'unknown'} → ${result.revision || 'unknown'}`);
|
|
13
|
+
for (const item of result.classifications) {
|
|
14
|
+
console.log(` [${item.confidence}] ${item.path || item.kind}: ${item.disposition}`);
|
|
15
|
+
}
|
|
16
|
+
if (result.writes.length) console.log(`Mechanical write available: ${result.writes[0].command}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runReconcile(projectDir, config, flags = {}) {
|
|
20
|
+
try {
|
|
21
|
+
if (flags.write && flags.check) throw new Error('Use either --check or --write, not both.');
|
|
22
|
+
const plan = buildReconciliationPlan(projectDir, config, flags.since);
|
|
23
|
+
let mechanical = null;
|
|
24
|
+
if (flags.write) {
|
|
25
|
+
if (plan.status === 'UNSUPPORTED' || plan.status === 'BLOCKED') {
|
|
26
|
+
throw new Error('Reconciliation write refused because evidence coverage is incomplete or the registry is invalid.');
|
|
27
|
+
}
|
|
28
|
+
mechanical = runSync(projectDir, config, { ...flags, write: true, silent: true });
|
|
29
|
+
}
|
|
30
|
+
const result = { command: 'reconcile', ...plan, applied: Boolean(flags.write), mechanical };
|
|
31
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
32
|
+
else printPlan(result);
|
|
33
|
+
if (flags.check && !['READY'].includes(result.status)) process.exitCode = 2;
|
|
34
|
+
return result;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const result = { command: 'reconcile', status: 'ERROR', error: error.message };
|
|
37
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
else console.error(`Error: ${error.message}`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/cli/commands/retire.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Historical prose leaves the working tree so agents cannot mistake it for
|
|
5
5
|
* current intent. Git remains the content store; a compact manifest records
|
|
6
6
|
* why each path left and how to restore it.
|
|
7
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
@@ -12,11 +13,10 @@ import {
|
|
|
12
13
|
readFileSync,
|
|
13
14
|
realpathSync,
|
|
14
15
|
rmSync,
|
|
15
|
-
unlinkSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import { commitFileTransaction } from '../writers/file-transaction.mjs';
|
|
20
20
|
import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
|
|
21
21
|
import { parseSpecId, readSpecRegistry } from '../scanners/spec-registry.mjs';
|
|
22
22
|
import {
|
|
@@ -279,19 +279,15 @@ function writeArchive(projectDir, config, flags) {
|
|
|
279
279
|
|
|
280
280
|
const next = { ...manifest, entries: [...manifest.entries, ...entries] };
|
|
281
281
|
assertArchivable(projectDir, files, config);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
spawnSync('git', ['restore', `--source=${commit}`, '--', ...removed], { cwd: projectDir });
|
|
292
|
-
}
|
|
293
|
-
throw error;
|
|
294
|
-
}
|
|
282
|
+
commitFileTransaction([
|
|
283
|
+
{ path: resolve(projectDir, MANIFEST_PATH), content: `${JSON.stringify(next, null, 2)}\n` },
|
|
284
|
+
...files.map(file => ({ path: resolve(projectDir, file), content: null })),
|
|
285
|
+
], {
|
|
286
|
+
validate: () => {
|
|
287
|
+
const written = loadManifest(projectDir);
|
|
288
|
+
if (written.entries.length !== next.entries.length) throw new Error('recovery manifest did not commit completely');
|
|
289
|
+
},
|
|
290
|
+
});
|
|
295
291
|
|
|
296
292
|
const directories = [...new Set(files.map(dirname))]
|
|
297
293
|
.filter(path => path !== '.')
|
package/cli/commands/specs.mjs
CHANGED
|
@@ -1,14 +1,182 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Manage and preflight the deterministic spec lifecycle registry.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-016
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-017
|
|
5
|
+
* @implements docguard.document-lifecycle#FR-020
|
|
6
|
+
*/
|
|
2
7
|
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
11
|
import { resolve } from 'node:path';
|
|
4
12
|
import { safeWrite } from '../writers/generate-io.mjs';
|
|
5
|
-
import {
|
|
13
|
+
import { commitFileTransaction } from '../writers/file-transaction.mjs';
|
|
14
|
+
import { appendImplementationOutcome } from '../writers/spec-outcomes.mjs';
|
|
15
|
+
import { serializeLifecycleContext } from '../scanners/lifecycle-context.mjs';
|
|
16
|
+
import { buildReconciliationPlan } from '../scanners/reconciliation.mjs';
|
|
17
|
+
import { preflightSpec, projectSpecRegistry, readSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
|
|
18
|
+
import { runGuardInternal } from './guard.mjs';
|
|
19
|
+
|
|
20
|
+
const CONTEXT_PATH = '.docguard/current-context.json';
|
|
21
|
+
const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
22
|
+
|
|
23
|
+
export function normalizeExternalDeliveryStatus(value) {
|
|
24
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[ -]+/g, '_');
|
|
25
|
+
return new Map([
|
|
26
|
+
['planned', 'planned'], ['in_progress', 'in_progress'], ['started', 'in_progress'],
|
|
27
|
+
['implemented', 'implemented'], ['complete', 'implemented'], ['completed', 'implemented'], ['done', 'implemented'],
|
|
28
|
+
['verified', 'verified'], ['validated', 'verified'], ['released', 'released'], ['shipped', 'released'],
|
|
29
|
+
]).get(normalized) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function headRevision(projectDir) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function trackedDirty(projectDir) {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('git', ['status', '--porcelain=v1', '--untracked-files=no'], {
|
|
41
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
}).trim();
|
|
43
|
+
} catch { return 'git-unavailable'; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function archiveReadiness(spec, targetVerified = false) {
|
|
47
|
+
if (!spec) return { status: 'BLOCKED', reason: 'Spec identity is unresolved.' };
|
|
48
|
+
const model = spec.reviewed.lifecycle.persistenceModel;
|
|
49
|
+
const delivery = targetVerified ? 'verified' : spec.reviewed.lifecycle.delivery;
|
|
50
|
+
if (model === 'living') return { status: 'KEEP_CURRENT', reason: 'Living specs remain in active context after verification.' };
|
|
51
|
+
if (!model) return { status: 'REVIEW', reason: 'Choose a persistence model before retiring the verified spec.' };
|
|
52
|
+
if (delivery !== 'verified' && delivery !== 'released') return { status: 'BLOCKED', reason: 'Spec must be verified before retirement.' };
|
|
53
|
+
if (model === 'flow_forward' && !spec.reviewed.relations.supersededBy.length) {
|
|
54
|
+
return { status: 'BLOCKED', reason: 'Flow-forward retirement requires a reviewed successor.' };
|
|
55
|
+
}
|
|
56
|
+
return { status: 'READY', reason: 'Run the reviewed spec retirement flow after the verified state is committed and retained.' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function planSpecCompletion(projectDir, config, flags, options = {}) {
|
|
60
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
61
|
+
const loaded = readSpecRegistry(projectDir);
|
|
62
|
+
const revision = headRevision(projectDir);
|
|
63
|
+
const spec = projection.registry.specs.find(entry => entry.specId === flags.id);
|
|
64
|
+
const blockers = [...projection.issues];
|
|
65
|
+
if (!flags.id) blockers.push({ code: 'SPC001', message: 'Completion requires --id <spec-id>.' });
|
|
66
|
+
if (!loaded.exists || loaded.error || !projection.current) blockers.push({ code: 'SPC001', message: 'Committed registry must be current before completion.' });
|
|
67
|
+
if (!spec) blockers.push({ code: 'SPC001', message: `Unknown spec ID: ${flags.id || '<missing>'}.` });
|
|
68
|
+
if (!revision) blockers.push({ code: 'SPC001', message: 'Completion requires a resolvable Git HEAD.' });
|
|
69
|
+
if (trackedDirty(projectDir)) blockers.push({ code: 'SPC001', message: 'Completion requires a clean tracked working tree at the recorded revision.' });
|
|
70
|
+
|
|
71
|
+
let reconcile = null;
|
|
72
|
+
if (spec) {
|
|
73
|
+
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}.` });
|
|
75
|
+
const tasks = spec.observed.taskCompletion;
|
|
76
|
+
if (!tasks.total || tasks.checked !== tasks.total) blockers.push({ code: 'SPC003', message: `All tasks must be checked (${tasks.checked}/${tasks.total}).` });
|
|
77
|
+
if (spec.observed.implementationEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified source implementation annotation is required.' });
|
|
78
|
+
if (spec.observed.testEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified test annotation is required.' });
|
|
79
|
+
const covered = new Set([...spec.observed.implementationEvidence, ...spec.observed.testEvidence].map(item => item.requirementId));
|
|
80
|
+
const missing = spec.intent.requirements
|
|
81
|
+
.map(identity => identity.slice(identity.lastIndexOf('#') + 1))
|
|
82
|
+
.filter(id => !covered.has(id));
|
|
83
|
+
if (missing.length) blockers.push({ code: 'SPC004', message: `Qualified implementation or test evidence is missing for: ${missing.join(', ')}.` });
|
|
84
|
+
if (spec.reviewed.scope.canonicalDocs.length === 0) blockers.push({ code: 'SPC005', message: 'Completion requires at least one reviewed affected canonical document.' });
|
|
85
|
+
for (const path of spec.reviewed.scope.canonicalDocs) {
|
|
86
|
+
if (!existsSync(resolve(projectDir, path))) blockers.push({ code: 'SPC005', message: `Affected canonical document is missing: ${path}.` });
|
|
87
|
+
}
|
|
88
|
+
const since = flags.since || spec.reviewed.reconciliation.lastReviewedRevision;
|
|
89
|
+
if (!since) blockers.push({ code: 'SPC006', message: 'Completion requires --since <review-baseline> for the first reconciliation.' });
|
|
90
|
+
else {
|
|
91
|
+
reconcile = buildReconciliationPlan(projectDir, config, since);
|
|
92
|
+
if (reconcile.status === 'UNSUPPORTED' || reconcile.status === 'BLOCKED') blockers.push({ code: 'SPC006', message: 'Reconciliation coverage is unsupported or blocked.' });
|
|
93
|
+
const unresolved = reconcile.classifications.filter(item => item.disposition === 'unsupported_or_ambiguous');
|
|
94
|
+
if (unresolved.length) blockers.push({ code: 'SPC006', message: `Unresolved changed files: ${unresolved.map(item => item.path).join(', ')}.` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const guard = options.guardResult || runGuardInternal(projectDir, config);
|
|
98
|
+
if ((guard.errors || 0) > 0) blockers.push({ code: 'SPC007', message: `Guard has ${guard.errors} error(s).` });
|
|
99
|
+
return {
|
|
100
|
+
status: blockers.length ? 'BLOCKED' : 'READY',
|
|
101
|
+
specId: flags.id || null,
|
|
102
|
+
revision,
|
|
103
|
+
transition: spec?.reviewed.lifecycle.delivery === 'in_progress' ? 'in_progress→implemented→verified' : 'implemented→verified',
|
|
104
|
+
blockers,
|
|
105
|
+
reconciliation: reconcile,
|
|
106
|
+
evidence: spec ? [...new Set([
|
|
107
|
+
...spec.observed.implementationEvidence.map(item => item.file),
|
|
108
|
+
...spec.observed.testEvidence.map(item => item.file),
|
|
109
|
+
...spec.reviewed.scope.canonicalDocs,
|
|
110
|
+
])].sort() : [],
|
|
111
|
+
context: CONTEXT_PATH,
|
|
112
|
+
archiveReadiness: archiveReadiness(spec, true),
|
|
113
|
+
guard: { status: guard.status, errors: guard.errors, warnings: guard.warnings },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function completeSpec(projectDir, config, flags, options = {}) {
|
|
118
|
+
const plan = planSpecCompletion(projectDir, config, flags, options);
|
|
119
|
+
if (plan.status !== 'READY') return { command: 'complete', ...plan, applied: false };
|
|
120
|
+
if (!flags.write) return { command: 'complete', ...plan, applied: false };
|
|
121
|
+
const reason = String(flags.reason || '').replace(/\s+/g, ' ').trim();
|
|
122
|
+
if (!reason || reason.length > 500) throw new Error('Completion --write requires --reason with 1-500 characters.');
|
|
123
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
124
|
+
const spec = projection.registry.specs.find(entry => entry.specId === flags.id);
|
|
125
|
+
const specPath = resolve(projectDir, spec.path);
|
|
126
|
+
const successor = flags.successor
|
|
127
|
+
? projection.registry.specs.find(entry => entry.specId === flags.successor) : null;
|
|
128
|
+
if (flags.successor && (!successor || successor.reviewed.lifecycle.context !== 'current'
|
|
129
|
+
|| successor.reviewed.lifecycle.approval !== 'approved')) {
|
|
130
|
+
throw new Error('Completion successor must be an approved current spec ID.');
|
|
131
|
+
}
|
|
132
|
+
const deviations = [...new Set(flags.deviations || [])]
|
|
133
|
+
.map(item => String(item).replace(/\s+/g, ' ').trim())
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
if (deviations.length > 20 || deviations.some(item => item.length > 500)) {
|
|
136
|
+
throw new Error('Completion accepts at most 20 deviations of 1-500 characters.');
|
|
137
|
+
}
|
|
138
|
+
const outcome = {
|
|
139
|
+
revision: plan.revision,
|
|
140
|
+
reason,
|
|
141
|
+
evidence: plan.evidence,
|
|
142
|
+
deviations,
|
|
143
|
+
successor: flags.successor || null,
|
|
144
|
+
};
|
|
145
|
+
const specContent = appendImplementationOutcome(readFileSync(specPath, 'utf8'), outcome);
|
|
146
|
+
spec.reviewed.lifecycle.delivery = 'verified';
|
|
147
|
+
spec.reviewed.reconciliation.lastReviewedRevision = plan.revision;
|
|
148
|
+
spec.reviewed.reconciliation.outcomes = [...spec.reviewed.reconciliation.outcomes, outcome].slice(-20);
|
|
149
|
+
const artifact = spec.observed.artifacts.find(item => item.path === spec.path);
|
|
150
|
+
if (artifact) artifact.digest = digest(specContent);
|
|
151
|
+
projection.registry.schemaVersion = 2;
|
|
152
|
+
const registryContent = `${JSON.stringify(projection.registry, null, 2)}\n`;
|
|
153
|
+
const contextContent = serializeLifecycleContext(projectDir, projection.registry, plan.revision);
|
|
154
|
+
commitFileTransaction([
|
|
155
|
+
{ path: specPath, content: specContent },
|
|
156
|
+
{ path: resolve(projectDir, SPEC_REGISTRY_PATH), content: registryContent },
|
|
157
|
+
{ path: resolve(projectDir, CONTEXT_PATH), content: contextContent },
|
|
158
|
+
], {
|
|
159
|
+
validate: () => {
|
|
160
|
+
const next = projectSpecRegistry(projectDir, config);
|
|
161
|
+
if (next.issues.length || !next.current) throw new Error('completed registry does not match the resulting repository');
|
|
162
|
+
const context = JSON.parse(readFileSync(resolve(projectDir, CONTEXT_PATH), 'utf8'));
|
|
163
|
+
if (context.generatedFrom !== plan.revision) throw new Error('active context revision mismatch');
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return { command: 'complete', ...plan, status: 'VERIFIED', applied: true, outcome };
|
|
167
|
+
}
|
|
6
168
|
|
|
7
169
|
function printIssues(issues) {
|
|
8
170
|
for (const issue of issues) console.log(` ${issue.code} ${issue.path}: ${issue.message}`);
|
|
9
171
|
}
|
|
10
172
|
|
|
11
173
|
function printResult(result) {
|
|
174
|
+
if (result.command === 'complete') {
|
|
175
|
+
console.log(`Spec completion: ${result.status}`);
|
|
176
|
+
console.log(`${result.specId || '<missing>'}: ${result.transition}`);
|
|
177
|
+
if (result.blockers?.length) printIssues(result.blockers.map(issue => ({ path: result.specId || SPEC_REGISTRY_PATH, ...issue })));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
12
180
|
if (result.command === 'preflight') {
|
|
13
181
|
console.log(`Spec preflight: ${result.status}`);
|
|
14
182
|
console.log(`Current specs: ${result.briefing.length}`);
|
|
@@ -34,7 +202,7 @@ function printResult(result) {
|
|
|
34
202
|
export function runSpecs(projectDir, config, flags = {}) {
|
|
35
203
|
try {
|
|
36
204
|
const action = flags.args?.[0] || null;
|
|
37
|
-
if (action &&
|
|
205
|
+
if (action && !['preflight', 'complete'].includes(action)) throw new Error(`Unknown specs action: ${action}`);
|
|
38
206
|
if (flags.check && flags.write) throw new Error('Use either --check or --write, not both.');
|
|
39
207
|
|
|
40
208
|
if (action === 'preflight') {
|
|
@@ -46,6 +214,14 @@ export function runSpecs(projectDir, config, flags = {}) {
|
|
|
46
214
|
return result;
|
|
47
215
|
}
|
|
48
216
|
|
|
217
|
+
if (action === 'complete') {
|
|
218
|
+
const result = completeSpec(projectDir, config, flags);
|
|
219
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
220
|
+
else printResult(result);
|
|
221
|
+
if (result.status === 'BLOCKED') process.exitCode = 2;
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
49
225
|
const projection = projectSpecRegistry(projectDir, config);
|
|
50
226
|
if (flags.write && projection.issues.length > 0) {
|
|
51
227
|
throw new Error(`Registry refresh refused: ${projection.issues.map(issue => issue.message).join(' ')}`);
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
|
|
|
12
12
|
*
|
|
13
13
|
* Default is a DRY RUN (preview); `--write` applies. `--since <ref>` adds the
|
|
14
14
|
* git diff as context. Only edits docguard:generated docs unless `--force`.
|
|
15
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -22,6 +23,7 @@ import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
|
22
23
|
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
23
24
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
24
25
|
import { runSyncTests } from './sync-tests.mjs';
|
|
26
|
+
import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
|
|
25
27
|
|
|
26
28
|
function gitChangedFiles(projectDir, since) {
|
|
27
29
|
const run = (args) => {
|
|
@@ -44,39 +46,6 @@ function gitChangedFiles(projectDir, since) {
|
|
|
44
46
|
* The predicates are matched against project-relative POSIX paths (the form
|
|
45
47
|
* `git diff --name-only` returns).
|
|
46
48
|
*/
|
|
47
|
-
const SECTION_FILE_MATCHERS = {
|
|
48
|
-
'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
|
|
49
|
-
'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
|
|
50
|
-
'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
|
|
51
|
-
|| /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
|
|
52
|
-
'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
53
|
-
|| /\.prisma$/.test(p),
|
|
54
|
-
'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
55
|
-
|| /\.prisma$/.test(p),
|
|
56
|
-
'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p)
|
|
57
|
-
|| /\.(tsx|jsx)$/.test(p),
|
|
58
|
-
'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
|
|
59
|
-
'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
|
|
60
|
-
'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
61
|
-
'features': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
62
|
-
'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
|
|
63
|
-
|| /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p), // any code may use env
|
|
64
|
-
'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Decide whether a given code-truth section should be re-synced based on the
|
|
69
|
-
* set of changed files. Returns true when:
|
|
70
|
-
* - changedFiles is null/empty (no scope info → sync everything), OR
|
|
71
|
-
* - any changed file matches the section's known source patterns, OR
|
|
72
|
-
* - the section has no matcher registered (unknown → conservative: sync)
|
|
73
|
-
*/
|
|
74
|
-
function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
75
|
-
if (!changedFiles || changedFiles.length === 0) return true;
|
|
76
|
-
const matcher = SECTION_FILE_MATCHERS[sectionId];
|
|
77
|
-
if (!matcher) return true; // unknown section → don't accidentally skip it
|
|
78
|
-
return changedFiles.some(matcher);
|
|
79
|
-
}
|
|
80
49
|
|
|
81
50
|
export function runSync(projectDir, config, flags) {
|
|
82
51
|
if (flags.write) assertDefaultDocWrites(config);
|
|
@@ -144,18 +113,20 @@ export function runSync(projectDir, config, flags) {
|
|
|
144
113
|
if (apply && docChanged) writeFileSync(full, content, 'utf-8');
|
|
145
114
|
}
|
|
146
115
|
|
|
116
|
+
const result = {
|
|
117
|
+
project: config.projectName,
|
|
118
|
+
since: flags.since || null,
|
|
119
|
+
changedFiles: changed,
|
|
120
|
+
applied: apply,
|
|
121
|
+
updates,
|
|
122
|
+
reviews,
|
|
123
|
+
skipped,
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
if (flags.silent) return result;
|
|
147
127
|
if (isJson) {
|
|
148
|
-
console.log(JSON.stringify(
|
|
149
|
-
|
|
150
|
-
since: flags.since || null,
|
|
151
|
-
changedFiles: changed,
|
|
152
|
-
applied: apply,
|
|
153
|
-
updates,
|
|
154
|
-
reviews,
|
|
155
|
-
skipped,
|
|
156
|
-
timestamp: new Date().toISOString(),
|
|
157
|
-
}, null, 2));
|
|
158
|
-
return;
|
|
128
|
+
console.log(JSON.stringify(result, null, 2));
|
|
129
|
+
return result;
|
|
159
130
|
}
|
|
160
131
|
|
|
161
132
|
console.log(`${c.bold}🔄 DocGuard Sync — ${config.projectName}${c.reset}`);
|
|
@@ -184,4 +155,5 @@ export function runSync(projectDir, config, flags) {
|
|
|
184
155
|
for (const s of skipped) console.log(` ${c.dim}- ${s.doc}: ${s.reason}${c.reset}`);
|
|
185
156
|
console.log('');
|
|
186
157
|
}
|
|
158
|
+
return result;
|
|
187
159
|
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { assertDefaultDocWrites } from './shared-doc-roles.mjs';
|
|
|
13
13
|
* npx docguard-cli --help — Show help
|
|
14
14
|
*
|
|
15
15
|
* @see https://github.com/raccioly/docguard
|
|
16
|
+
* @implements docguard.document-lifecycle#FR-012
|
|
17
|
+
* @implements docguard.document-lifecycle#FR-020
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -53,6 +55,7 @@ import { runAgent } from './commands/agent.mjs';
|
|
|
53
55
|
import { runMcp } from './commands/mcp.mjs';
|
|
54
56
|
import { runArchive } from './commands/retire.mjs';
|
|
55
57
|
import { runSpecs } from './commands/specs.mjs';
|
|
58
|
+
import { runReconcile } from './commands/reconcile.mjs';
|
|
56
59
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
57
60
|
|
|
58
61
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
@@ -101,6 +104,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
101
104
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
102
105
|
${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
106
|
${c.green}specs${c.reset} Track spec lifecycle and evidence (${c.cyan}--check|--write${c.reset}; ${c.cyan}preflight --path <spec>${c.reset})
|
|
107
|
+
${c.green}reconcile${c.reset} Classify code/spec changes since a Git ref before changing intent
|
|
104
108
|
${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)
|
|
105
109
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
106
110
|
${c.green}watch${c.reset} Live mode: re-run guard on file changes
|
|
@@ -331,15 +335,32 @@ const COMMAND_HELP = {
|
|
|
331
335
|
},
|
|
332
336
|
specs: {
|
|
333
337
|
summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
|
|
334
|
-
usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] [--
|
|
338
|
+
usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] | docguard specs complete --id <spec-id> [--since <ref>] [--write --reason <text>]',
|
|
335
339
|
flags: [
|
|
336
340
|
['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
|
|
337
341
|
['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
|
|
338
342
|
['preflight', 'Brief prior specs, or gate a generated draft with --path'],
|
|
343
|
+
['complete', 'Plan or apply the implemented→verified evidence transaction'],
|
|
344
|
+
['--id <spec-id>', 'Immutable spec identity to complete'],
|
|
345
|
+
['--since <ref>', 'First reconciliation baseline when none is recorded'],
|
|
346
|
+
['--reason <text>', 'Reviewed implementation outcome required for completion writes'],
|
|
347
|
+
['--deviation <text>', 'Accepted deviation to record; repeatable'],
|
|
348
|
+
['--successor <id>', 'Approved current successor spec to record'],
|
|
339
349
|
['--path <spec>', 'Generated spec to compare against current lifecycle state'],
|
|
340
350
|
['--format json', 'Machine-readable registry or preflight result'],
|
|
341
351
|
],
|
|
342
|
-
examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight
|
|
352
|
+
examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight --path specs/007-feature/spec.md', 'docguard specs complete --id acme.feature --since main --write --reason "Reviewed implementation"'],
|
|
353
|
+
},
|
|
354
|
+
reconcile: {
|
|
355
|
+
summary: 'Classify changed implementation facts, approved intent, decisions, and unsupported evidence.',
|
|
356
|
+
usage: 'docguard reconcile --since <ref> [--check|--write] [--format json]',
|
|
357
|
+
flags: [
|
|
358
|
+
['--since <ref>', 'Required Git baseline for the review graph'],
|
|
359
|
+
['--check', 'Exit 2 while reviewed reconciliation remains'],
|
|
360
|
+
['--write', 'Apply only deterministic generated-section refreshes'],
|
|
361
|
+
['--format json', 'Machine-readable nodes, edges, classifications, and write plan'],
|
|
362
|
+
],
|
|
363
|
+
examples: ['docguard reconcile --since main --format json', 'docguard reconcile --since HEAD~1 --write'],
|
|
343
364
|
},
|
|
344
365
|
};
|
|
345
366
|
|
|
@@ -589,6 +610,16 @@ async function main() {
|
|
|
589
610
|
} else if (args[i] === '--retention-ref' && args[i + 1]) {
|
|
590
611
|
flags.retentionRef = args[i + 1];
|
|
591
612
|
i++;
|
|
613
|
+
} else if (args[i] === '--id' && args[i + 1]) {
|
|
614
|
+
flags.id = args[i + 1];
|
|
615
|
+
i++;
|
|
616
|
+
} else if (args[i] === '--deviation' && args[i + 1]) {
|
|
617
|
+
flags.deviations = flags.deviations || [];
|
|
618
|
+
flags.deviations.push(args[i + 1]);
|
|
619
|
+
i++;
|
|
620
|
+
} else if (args[i] === '--successor' && args[i + 1]) {
|
|
621
|
+
flags.successor = args[i + 1];
|
|
622
|
+
i++;
|
|
592
623
|
} else if (args[i] === '--code') {
|
|
593
624
|
flags.code = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
|
|
594
625
|
} else if (args[i] === '--all') {
|
|
@@ -662,10 +693,11 @@ async function main() {
|
|
|
662
693
|
// same way runGuardInternal sees everything else.
|
|
663
694
|
if (flags.noBaseline) config.baseline = false;
|
|
664
695
|
|
|
665
|
-
// Commands
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
696
|
+
// Commands whose normal mode only reads/reports, plus explicit writers whose
|
|
697
|
+
// mutations must stay scoped to their named output. They must never add
|
|
698
|
+
// unrelated setup files. Scaffolding (ensureSkills → .agent/.specify,
|
|
699
|
+
// spawning `specify`) belongs to setup/init/generate and `init --with`, where
|
|
700
|
+
// the user is establishing or expanding setup rather than inspecting it.
|
|
669
701
|
//
|
|
670
702
|
// v0.26 (field report Bug #3): a bare `docguard guard` used to run
|
|
671
703
|
// ensureSkills → auto-init Spec Kit → spawn `specify` and write ~9 files into
|
|
@@ -675,7 +707,7 @@ async function main() {
|
|
|
675
707
|
// `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
|
|
676
708
|
const READ_ONLY_COMMANDS = new Set([
|
|
677
709
|
'guard', 'audit', 'score', 'diff', 'impact',
|
|
678
|
-
'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent', 'retire', 'archive', 'specs',
|
|
710
|
+
'diagnose', 'fix', 'trace', 'explain', 'memory', 'demo', 'agent', 'retire', 'archive', 'specs',
|
|
679
711
|
// feedback only writes its own .docguard/feedback/ — it must NOT scaffold
|
|
680
712
|
// skills or touch source, so it's gated out of ensureSkills like the rest.
|
|
681
713
|
'feedback',
|
|
@@ -703,6 +735,7 @@ async function main() {
|
|
|
703
735
|
command !== 'setup' &&
|
|
704
736
|
command !== 'init' &&
|
|
705
737
|
!READ_ONLY_COMMANDS.has(command) &&
|
|
738
|
+
!(command === 'hooks' && flags.list) &&
|
|
706
739
|
// Agent-family staleness checks must not bootstrap skills or Spec Kit.
|
|
707
740
|
!(command === 'agents' && flags.check) &&
|
|
708
741
|
!headless
|
|
@@ -798,8 +831,9 @@ async function main() {
|
|
|
798
831
|
runAgent(projectDir, config, flags);
|
|
799
832
|
break;
|
|
800
833
|
case 'hooks':
|
|
801
|
-
if (flags.claude) {
|
|
802
|
-
// Agent nudge
|
|
834
|
+
if (flags.claude || flags.list) {
|
|
835
|
+
// Agent nudge and read-only inventory paths bypass the setup wizard.
|
|
836
|
+
// Listing hooks must not refresh unrelated skills in the repository.
|
|
803
837
|
runHooks(projectDir, config, flags);
|
|
804
838
|
break;
|
|
805
839
|
}
|
|
@@ -887,6 +921,9 @@ async function main() {
|
|
|
887
921
|
case 'specs':
|
|
888
922
|
runSpecs(projectDir, config, flags);
|
|
889
923
|
break;
|
|
924
|
+
case 'reconcile':
|
|
925
|
+
runReconcile(projectDir, config, flags);
|
|
926
|
+
break;
|
|
890
927
|
case 'demo':
|
|
891
928
|
// v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
|
|
892
929
|
// fixture (templates/demo-fixture/) and prints curated drift findings
|