doctrina-cli 0.13.0 → 0.14.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 +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +66 -19
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +58 -2
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +8 -1
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +239 -26
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import { COMMAND_NAMES } from "./commands.js";
|
|
6
|
+
import { locateTemplatesDir } from "./templates.js";
|
|
7
|
+
|
|
8
|
+
// "Docs ship inside the change, never after it" (audit item D2).
|
|
9
|
+
//
|
|
10
|
+
// A docs phase scheduled AFTER the work never happens. So a change that
|
|
11
|
+
// alters a documented surface — a command, a flag, an exit code — must also
|
|
12
|
+
// carry the documentation for it, checked at close time and refusable with
|
|
13
|
+
// --force exactly like the archive verification gate.
|
|
14
|
+
//
|
|
15
|
+
// Both halves are deterministic: what the change says it touches is read
|
|
16
|
+
// from its own artifacts, and whether docs moved is read from git. No
|
|
17
|
+
// language understanding (ADR 0005).
|
|
18
|
+
|
|
19
|
+
const DOC_PATHS = [/^docs\//, /^README\.md$/, /^README\.pt\.md$/];
|
|
20
|
+
|
|
21
|
+
// Non-empty lines of the shipped change templates, so authored content can
|
|
22
|
+
// be told apart from scaffold. Computed once; falls back to an empty set
|
|
23
|
+
// when the templates cannot be located (unusual installs), which only makes
|
|
24
|
+
// the gate more eager, never wrong about a real signal.
|
|
25
|
+
// Ticking a scaffold checkbox rewrites "- [ ] ..." to "- [x] ...", which
|
|
26
|
+
// would otherwise stop the line matching its own template and make every
|
|
27
|
+
// completed change look authored. Compare with the box state normalised.
|
|
28
|
+
function normaliseScaffoldLine(line) {
|
|
29
|
+
return line.trim().replace(/^-\s*\[[ xX]\]/, "- [ ]");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let scaffoldCache = null;
|
|
33
|
+
function scaffoldLines() {
|
|
34
|
+
if (scaffoldCache) return scaffoldCache;
|
|
35
|
+
const lines = new Set();
|
|
36
|
+
try {
|
|
37
|
+
const changeTemplates = path.join(locateTemplatesDir(), "change");
|
|
38
|
+
for (const f of walk(changeTemplates)) {
|
|
39
|
+
for (const line of read(f).split(/\r?\n/)) {
|
|
40
|
+
const trimmed = normaliseScaffoldLine(line);
|
|
41
|
+
if (trimmed) lines.add(trimmed);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch { /* no templates dir — treat everything as authored */ }
|
|
45
|
+
scaffoldCache = lines;
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Does this change alter something the documentation makes promises about?
|
|
50
|
+
// Read from the change's own artifacts — the deltas it will merge into
|
|
51
|
+
// specs, plus the proposal that states its shape. Returns the list of
|
|
52
|
+
// signals found (empty when the change touches no documented surface).
|
|
53
|
+
export function documentedSurfaceSignals(changeDir) {
|
|
54
|
+
const signals = [];
|
|
55
|
+
const known = new Set(COMMAND_NAMES);
|
|
56
|
+
|
|
57
|
+
// Read only what the AUTHOR wrote. The scaffold's own boilerplate names
|
|
58
|
+
// commands — the proposal's Verification checklist cites `doctrina verify`
|
|
59
|
+
// and `doctrina coverage` — so scanning the raw file made every change look
|
|
60
|
+
// like it touched a documented surface. A gate that fires on everything is
|
|
61
|
+
// a gate that gets ignored, so the template's lines are subtracted first.
|
|
62
|
+
const boilerplate = scaffoldLines();
|
|
63
|
+
const authored = (text) => text
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
|
|
66
|
+
.join("\n");
|
|
67
|
+
|
|
68
|
+
const sources = [];
|
|
69
|
+
const proposal = path.join(changeDir, "proposal.md");
|
|
70
|
+
if (isFile(proposal)) sources.push(authored(read(proposal)));
|
|
71
|
+
for (const p of walk(path.join(changeDir, "specs"))) {
|
|
72
|
+
if (p.endsWith("delta.md")) sources.push(authored(read(p)));
|
|
73
|
+
}
|
|
74
|
+
const text = sources.join("\n");
|
|
75
|
+
if (!text.trim()) return signals;
|
|
76
|
+
|
|
77
|
+
// Command references, from code context only, so prose about "the
|
|
78
|
+
// doctrina framework" is not a signal.
|
|
79
|
+
const chunks = [];
|
|
80
|
+
const outside = text.replace(/```[\s\S]*?```/g, (b) => { chunks.push(b); return "\n"; });
|
|
81
|
+
for (const m of outside.matchAll(/`[^`]+`/g)) chunks.push(m[0]);
|
|
82
|
+
const code = chunks.join("\n");
|
|
83
|
+
|
|
84
|
+
const commands = new Set();
|
|
85
|
+
for (const m of code.matchAll(/(?:npx\s+doctrina-cli|doctrina)\s+([a-z][a-z-]+)/g)) {
|
|
86
|
+
if (known.has(m[1])) commands.add(m[1]);
|
|
87
|
+
}
|
|
88
|
+
if (commands.size > 0) {
|
|
89
|
+
signals.push(`commands: ${[...commands].sort().join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const flags = new Set();
|
|
93
|
+
for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
|
|
94
|
+
if (flags.size > 0) {
|
|
95
|
+
signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
|
|
99
|
+
signals.push("exit codes");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return signals;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Documentation paths touched by the work in flight. Union of the working
|
|
106
|
+
// tree (uncommitted, the common agent case) and this branch's commits
|
|
107
|
+
// against the default branch (the case where the agent already committed).
|
|
108
|
+
// Returns [] outside a git repository — where the gate cannot see, it does
|
|
109
|
+
// not accuse.
|
|
110
|
+
//
|
|
111
|
+
// KNOWN COARSENESS: this answers "did documentation move in this work?",
|
|
112
|
+
// not "did documentation move FOR THIS CHANGE?". Closing several changes
|
|
113
|
+
// from one uncommitted working tree, docs written for an earlier change
|
|
114
|
+
// satisfy the gate for a later one. The gate is deliberately permissive
|
|
115
|
+
// here — its job is to stop a change shipping with NO documentation at
|
|
116
|
+
// all, and attributing a hunk to a change would need guesswork the rest of
|
|
117
|
+
// the framework refuses to do (ADR 0005).
|
|
118
|
+
export function docsTouched(projectRoot) {
|
|
119
|
+
const git = (args) => {
|
|
120
|
+
const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
121
|
+
if (r.error || r.status !== 0) return [];
|
|
122
|
+
return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
123
|
+
};
|
|
124
|
+
const set = new Set();
|
|
125
|
+
for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
|
|
126
|
+
for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
|
|
127
|
+
for (const base of ["main", "master"]) {
|
|
128
|
+
const mb = git(["merge-base", "HEAD", base]);
|
|
129
|
+
if (mb.length > 0) {
|
|
130
|
+
for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...set]
|
|
135
|
+
.map((f) => f.replace(/\\/g, "/"))
|
|
136
|
+
.filter((f) => DOC_PATHS.some((re) => re.test(f)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isGitRepo(projectRoot) {
|
|
140
|
+
const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
141
|
+
cwd: projectRoot, encoding: "utf8",
|
|
142
|
+
});
|
|
143
|
+
return !r.error && r.status === 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The gate itself: { ok, signals, touched, reason }. `ok` is true when the
|
|
147
|
+
// change touches no documented surface, when documentation moved with it,
|
|
148
|
+
// or when git cannot answer.
|
|
149
|
+
export function checkDocsImpact(projectRoot, changeDir) {
|
|
150
|
+
const signals = documentedSurfaceSignals(changeDir);
|
|
151
|
+
if (signals.length === 0) {
|
|
152
|
+
return { ok: true, signals, touched: [], reason: "touches no documented surface" };
|
|
153
|
+
}
|
|
154
|
+
if (!isGitRepo(projectRoot)) {
|
|
155
|
+
return { ok: true, signals, touched: [], reason: "not a git repository — cannot tell what moved" };
|
|
156
|
+
}
|
|
157
|
+
const touched = docsTouched(projectRoot);
|
|
158
|
+
if (touched.length > 0) {
|
|
159
|
+
return { ok: true, signals, touched, reason: `documentation moved with it (${touched.length} file${touched.length === 1 ? "" : "s"})` };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
signals,
|
|
164
|
+
touched,
|
|
165
|
+
reason: "alters a documented surface but no docs/ or README change accompanies it",
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/lib/ears.js
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The exit-code contract (audit item C7).
|
|
3
|
+
//
|
|
4
|
+
// Code 1 used to mean three different things, and an agent driving the
|
|
5
|
+
// loop could not tell them apart:
|
|
6
|
+
//
|
|
7
|
+
// "your change is not ready" -> iterate and retry
|
|
8
|
+
// "this project is not configured" -> run a setup command first
|
|
9
|
+
// "this machine cannot run this" -> stop
|
|
10
|
+
//
|
|
11
|
+
// All three returned 1. The first means keep working, the second means do
|
|
12
|
+
// something else first, the third means give up — and an autonomous loop
|
|
13
|
+
// that cannot distinguish them either spins on an unfixable failure or
|
|
14
|
+
// abandons a fixable one.
|
|
15
|
+
//
|
|
16
|
+
// This is the smallest module in the CLI and the one a machine consumer
|
|
17
|
+
// depends on most. Every command's exit path maps to a member here.
|
|
18
|
+
|
|
19
|
+
export const EXIT = Object.freeze({
|
|
20
|
+
/** The command did what it was asked. Warnings are allowed. */
|
|
21
|
+
OK: 0,
|
|
22
|
+
/** A gate failed: the WORK is not ready. Fix the work and retry. */
|
|
23
|
+
GATE: 1,
|
|
24
|
+
/** The INVOCATION is wrong: unknown command, missing/malformed argument.
|
|
25
|
+
* Do not retry unchanged. */
|
|
26
|
+
USAGE: 2,
|
|
27
|
+
/** A PRECONDITION is missing: the project is not set up for this yet.
|
|
28
|
+
* Run the named setup command, then retry. */
|
|
29
|
+
PRECONDITION: 3,
|
|
30
|
+
/** The ENVIRONMENT cannot run this: git absent, unreadable tree, and
|
|
31
|
+
* other conditions no amount of retrying fixes. Stop. */
|
|
32
|
+
ENVIRONMENT: 4,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// What each code means, for `--help`, the docs, and the tests that assert
|
|
36
|
+
// the mapping. Keyed by numeric code so a consumer can look one up.
|
|
37
|
+
export const EXIT_MEANINGS = Object.freeze({
|
|
38
|
+
[EXIT.OK]: {
|
|
39
|
+
name: "OK",
|
|
40
|
+
summary: "success (warnings allowed)",
|
|
41
|
+
agentAction: "continue",
|
|
42
|
+
},
|
|
43
|
+
[EXIT.GATE]: {
|
|
44
|
+
name: "GATE",
|
|
45
|
+
summary: "a gate failed — the work is not ready",
|
|
46
|
+
agentAction: "fix the work, then retry the same command",
|
|
47
|
+
},
|
|
48
|
+
[EXIT.USAGE]: {
|
|
49
|
+
name: "USAGE",
|
|
50
|
+
summary: "the invocation is wrong — unknown command or bad argument",
|
|
51
|
+
agentAction: "do not retry unchanged; correct the invocation",
|
|
52
|
+
},
|
|
53
|
+
[EXIT.PRECONDITION]: {
|
|
54
|
+
name: "PRECONDITION",
|
|
55
|
+
summary: "a precondition is missing — the project is not set up for this",
|
|
56
|
+
agentAction: "run the setup command named in the error, then retry",
|
|
57
|
+
},
|
|
58
|
+
[EXIT.ENVIRONMENT]: {
|
|
59
|
+
name: "ENVIRONMENT",
|
|
60
|
+
summary: "the environment cannot run this command",
|
|
61
|
+
agentAction: "stop; retrying will not help",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The block `doctrina --help` prints, generated so help and contract
|
|
66
|
+
// cannot drift.
|
|
67
|
+
export function exitCodeHelp() {
|
|
68
|
+
return Object.entries(EXIT_MEANINGS)
|
|
69
|
+
.map(([code, m]) => ` ${code} ${m.summary}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A precondition failure, thrown by a command whose project/config is not
|
|
74
|
+
// ready. The entrypoint maps it to EXIT.PRECONDITION and prints the setup
|
|
75
|
+
// command, so every "you need to run X first" path reports the same code.
|
|
76
|
+
export class PreconditionError extends Error {
|
|
77
|
+
constructor(message, remedy) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "PreconditionError";
|
|
80
|
+
this.exitCode = EXIT.PRECONDITION;
|
|
81
|
+
this.remedy = remedy ?? null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// An environment failure: the machine, not the project, is the problem.
|
|
86
|
+
export class EnvironmentError extends Error {
|
|
87
|
+
constructor(message, remedy) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "EnvironmentError";
|
|
90
|
+
this.exitCode = EXIT.ENVIRONMENT;
|
|
91
|
+
this.remedy = remedy ?? null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// "Not a Doctrina project" is the single most common precondition, raised
|
|
96
|
+
// by nearly every command. One constructor so the message and the code
|
|
97
|
+
// stay identical everywhere.
|
|
98
|
+
export function notADoctrinaProject() {
|
|
99
|
+
return new PreconditionError(
|
|
100
|
+
"not a Doctrina project (no .doctrina/ in cwd)",
|
|
101
|
+
"doctrina init",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { COMMAND_NAMES } from "./commands.js";
|
|
6
|
+
|
|
7
|
+
// One catalog of "which flags does command X accept", with two consumers:
|
|
8
|
+
//
|
|
9
|
+
// - the static undeclared-flag test (C3), which asserts every flag a
|
|
10
|
+
// command READS is declared, so `parseArgs` never swallows a positional
|
|
11
|
+
// as an undeclared flag's value again; and
|
|
12
|
+
// - the docs accuracy gate (D1, `scripts/check-docs.js`), which asserts
|
|
13
|
+
// every flag the DOCS document against a command is declared.
|
|
14
|
+
//
|
|
15
|
+
// One invariant, two checks: source and prose are held to the same list, so
|
|
16
|
+
// a renamed flag cannot stay green in either place. Both read a command
|
|
17
|
+
// module's exported `flags` — the declaration lives WITH the command, not in
|
|
18
|
+
// the entrypoint, which is why an added command no longer requires editing
|
|
19
|
+
// `src/index.js` to make its flags parse (the structural half of C3).
|
|
20
|
+
|
|
21
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const commandsDir = path.join(here, "..", "commands");
|
|
23
|
+
|
|
24
|
+
// Flags the entrypoint owns for every command; a command never redeclares them.
|
|
25
|
+
export const GLOBAL_FLAGS = Object.freeze({
|
|
26
|
+
boolean: ["help", "h", "v", "debug"],
|
|
27
|
+
string: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Command name -> module filename. Every command's module is `<name>.js`
|
|
31
|
+
// except where this map says otherwise.
|
|
32
|
+
const MODULE_OVERRIDES = { index: "index-rebuild.js" };
|
|
33
|
+
|
|
34
|
+
export function moduleFileFor(commandName) {
|
|
35
|
+
return MODULE_OVERRIDES[commandName] ?? `${commandName}.js`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function modulePathFor(commandName) {
|
|
39
|
+
return path.join(commandsDir, moduleFileFor(commandName));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The flag spec a command module exports, normalised to
|
|
43
|
+
// { boolean: string[], string: string[] }. Returns null when the module
|
|
44
|
+
// declares none — which the C3 test treats as a failure and the docs gate
|
|
45
|
+
// treats as "not checkable yet", so the two can land independently.
|
|
46
|
+
export async function loadFlagSpec(commandName) {
|
|
47
|
+
const mod = await import(pathToUrl(modulePathFor(commandName)));
|
|
48
|
+
const spec = mod.flags;
|
|
49
|
+
if (!spec) return null;
|
|
50
|
+
return {
|
|
51
|
+
boolean: [...(spec.boolean ?? [])],
|
|
52
|
+
string: [...(spec.string ?? [])],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every flag name a command accepts (own + global), as a Set. null when the
|
|
57
|
+
// command declares no spec.
|
|
58
|
+
export async function declaredFlags(commandName) {
|
|
59
|
+
const spec = await loadFlagSpec(commandName);
|
|
60
|
+
if (!spec) return null;
|
|
61
|
+
return new Set([
|
|
62
|
+
...spec.boolean, ...spec.string,
|
|
63
|
+
...GLOBAL_FLAGS.boolean, ...GLOBAL_FLAGS.string,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadAllFlagSpecs() {
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Flag names a source file READS, by scanning its call sites:
|
|
74
|
+
// flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
|
|
75
|
+
// Deterministic text scan, not execution — it sees every branch, including
|
|
76
|
+
// the ones a given run never takes.
|
|
77
|
+
export function scanFlagUsage(sourceText) {
|
|
78
|
+
const used = new Set();
|
|
79
|
+
const patterns = [
|
|
80
|
+
/flagBool\(\s*flags\s*,\s*["']([^"']+)["']/g,
|
|
81
|
+
/flagString\(\s*flags\s*,\s*["']([^"']+)["']/g,
|
|
82
|
+
/flags\.has\(\s*["']([^"']+)["']/g,
|
|
83
|
+
/flags\.get\(\s*["']([^"']+)["']/g,
|
|
84
|
+
];
|
|
85
|
+
for (const re of patterns) {
|
|
86
|
+
for (const m of sourceText.matchAll(re)) used.add(m[1]);
|
|
87
|
+
}
|
|
88
|
+
return used;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readCommandSource(commandName) {
|
|
92
|
+
return readFileSync(modulePathFor(commandName), "utf8");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Flags a command's `help` string documents: every `--flag` token in it.
|
|
96
|
+
// The docs gate compares prose against this too, so help and reference
|
|
97
|
+
// cannot drift apart from the declaration.
|
|
98
|
+
export function scanHelpFlags(helpText) {
|
|
99
|
+
const out = new Set();
|
|
100
|
+
for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pathToUrl(p) {
|
|
105
|
+
return "file:///" + p.replace(/\\/g, "/");
|
|
106
|
+
}
|
package/src/lib/fs-ops.js
CHANGED
package/src/lib/gates.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSection } from "./doc-model.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { exists, isFile, mkdirp, read } from "./fs-ops.js";
|
|
6
|
+
import { today } from "./dates.js";
|
|
7
|
+
import { collectAnalysis } from "../commands/analyze.js";
|
|
8
|
+
|
|
9
|
+
// The gate-to-transition map (audit item C6).
|
|
10
|
+
//
|
|
11
|
+
// Each command used to decide its own preconditions. `change archive` and
|
|
12
|
+
// `close` checked theirs; `change apply` checked nothing. So `analyze`
|
|
13
|
+
// could exit 1 on a change and `change apply` would mutate it anyway and
|
|
14
|
+
// exit 0 — and the README flowchart tells agents to follow exactly that
|
|
15
|
+
// analyze → apply path. An agent could reach, through one path, a state
|
|
16
|
+
// another path forbids.
|
|
17
|
+
//
|
|
18
|
+
// The fix is not "add a check to apply" but "declare once which gates
|
|
19
|
+
// guard which transition, and have every driver consult the declaration".
|
|
20
|
+
// Which specific gate guards which transition matters less than the
|
|
21
|
+
// guarantee that the answer does not depend on how you got there.
|
|
22
|
+
|
|
23
|
+
// A gate is a named precondition returning human-readable blockers.
|
|
24
|
+
export const GATES = {
|
|
25
|
+
// Structural validity: the change's own files parse and point at real
|
|
26
|
+
// targets. The same checks `doctrina analyze` reports.
|
|
27
|
+
structure: {
|
|
28
|
+
label: "structure",
|
|
29
|
+
rerun: (id) => `doctrina analyze ${id}`,
|
|
30
|
+
blockers(projectRoot, changeDir) {
|
|
31
|
+
return collectAnalysis(projectRoot, changeDir)
|
|
32
|
+
.filter((r) => r.kind === "fail")
|
|
33
|
+
.map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Verification: the work is claimed complete. Every checkbox in
|
|
38
|
+
// tasks.md (closing steps included) and in the proposal's
|
|
39
|
+
// "## Verification" section.
|
|
40
|
+
verification: {
|
|
41
|
+
label: "verification",
|
|
42
|
+
rerun: (id) => `doctrina change tick ${id}`,
|
|
43
|
+
blockers(projectRoot, changeDir) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
|
|
46
|
+
|
|
47
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
48
|
+
if (isFile(tasksPath)) {
|
|
49
|
+
const n = countUnchecked(read(tasksPath));
|
|
50
|
+
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
|
|
51
|
+
}
|
|
52
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
53
|
+
if (isFile(proposalPath)) {
|
|
54
|
+
const n = countUnchecked(getSection(read(proposalPath), "Verification"));
|
|
55
|
+
if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Which gates guard each lifecycle transition. The single declaration.
|
|
63
|
+
//
|
|
64
|
+
// `apply` requires structure: mutating specs from a change whose deltas do
|
|
65
|
+
// not parse is how a malformed delta reaches a spec file. This is the gate
|
|
66
|
+
// `analyze` reports and that `apply` used to ignore entirely (C6).
|
|
67
|
+
//
|
|
68
|
+
// `archive` requires verification, and deliberately NOT structure. The
|
|
69
|
+
// structure gate is a PRE-APPLY question — "is this safe to apply?" — and
|
|
70
|
+
// one of its checks is that an ADDED delta's target does not already hold
|
|
71
|
+
// real content. After a successful apply that target holds exactly the
|
|
72
|
+
// content the delta just wrote, so re-asking at archive time reports a
|
|
73
|
+
// conflict that is the proof the apply worked. A gate must be asked at the
|
|
74
|
+
// point its question is meaningful.
|
|
75
|
+
export const TRANSITIONS = {
|
|
76
|
+
apply: { label: "change apply", gates: ["structure"] },
|
|
77
|
+
archive: { label: "change archive", gates: ["verification"] },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Evaluate a transition's gates. Returns { ok, blockers, gates } where
|
|
81
|
+
// blockers is a flat list of human-readable reasons.
|
|
82
|
+
export function checkTransition(projectRoot, changeDir, transition) {
|
|
83
|
+
const t = TRANSITIONS[transition];
|
|
84
|
+
if (!t) throw new Error(`unknown transition "${transition}"`);
|
|
85
|
+
const blockers = [];
|
|
86
|
+
for (const name of t.gates) {
|
|
87
|
+
for (const b of GATES[name].blockers(projectRoot, changeDir)) {
|
|
88
|
+
blockers.push({ gate: name, message: b });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ok: blockers.length === 0, blockers, gates: t.gates };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Record a forced transition in the archive ledger, so a gap that was
|
|
95
|
+
// waved through is part of the visible history rather than nothing at all
|
|
96
|
+
// — the same posture `change archive --force` and `change abandon` take.
|
|
97
|
+
export function recordForcedGap(projectRoot, id, transition, blockers) {
|
|
98
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
99
|
+
const ledgerPath = path.join(archiveDir, "LEDGER.md");
|
|
100
|
+
// Create the ledger if this is the first entry. A forced transition can
|
|
101
|
+
// happen before any change has ever been archived, and a gap that goes
|
|
102
|
+
// unrecorded because the file did not exist yet is exactly the silence
|
|
103
|
+
// this is meant to break.
|
|
104
|
+
if (!exists(ledgerPath)) {
|
|
105
|
+
mkdirp(archiveDir);
|
|
106
|
+
writeFileSync(ledgerPath,
|
|
107
|
+
"# Change ledger\n\n" +
|
|
108
|
+
"One line per archived change, newest last. Appended by\n" +
|
|
109
|
+
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
110
|
+
}
|
|
111
|
+
const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
|
|
112
|
+
appendFileSync(
|
|
113
|
+
ledgerPath,
|
|
114
|
+
` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
|
|
115
|
+
`blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
|
|
116
|
+
);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stripAnsi(s) {
|
|
121
|
+
// eslint-disable-next-line no-control-regex
|
|
122
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
123
|
+
}
|
package/src/lib/git.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
// One place that knows how to ask git a question and how to interpret not
|
|
5
|
+
// getting an answer (audit item C8).
|
|
6
|
+
//
|
|
7
|
+
// Nine call sites each ran `spawnSync("git", ...)` and decided for
|
|
8
|
+
// themselves what a non-zero status meant. `metrics` decided it meant
|
|
9
|
+
// failure, so on a brand-new project — the most common state in which
|
|
10
|
+
// someone explores the CLI — it printed:
|
|
11
|
+
//
|
|
12
|
+
// error: git log failed: fatal: your current branch 'master' does not
|
|
13
|
+
// have any commits yet
|
|
14
|
+
//
|
|
15
|
+
// A repository with no commits is a valid state, not an error. So is a
|
|
16
|
+
// directory that is not a repository at all. What is genuinely an error
|
|
17
|
+
// is git being absent when a command cannot work without it — and that is
|
|
18
|
+
// an ENVIRONMENT condition (exit 4), not a gate failure.
|
|
19
|
+
|
|
20
|
+
export const GIT_STATE = Object.freeze({
|
|
21
|
+
/** git ran and answered. */
|
|
22
|
+
OK: "ok",
|
|
23
|
+
/** A repository with no commits yet — a valid first-run state. */
|
|
24
|
+
EMPTY: "empty",
|
|
25
|
+
/** Not a git repository. */
|
|
26
|
+
NOT_A_REPO: "not-a-repo",
|
|
27
|
+
/** git is not installed or not on PATH. */
|
|
28
|
+
ABSENT: "absent",
|
|
29
|
+
/** git ran and refused, for a reason this module does not classify. */
|
|
30
|
+
FAILED: "failed",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Run a git command. Never throws. Returns
|
|
34
|
+
// { state, stdout, lines, stderr, code }.
|
|
35
|
+
export function git(cwd, args) {
|
|
36
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
37
|
+
if (r.error) {
|
|
38
|
+
// ENOENT here means the binary is missing, not that the command failed.
|
|
39
|
+
return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
|
|
40
|
+
}
|
|
41
|
+
const stderr = String(r.stderr ?? "");
|
|
42
|
+
if (r.status === 0) {
|
|
43
|
+
const stdout = String(r.stdout ?? "");
|
|
44
|
+
return { state: GIT_STATE.OK, stdout, lines: splitLines(stdout), stderr, code: 0 };
|
|
45
|
+
}
|
|
46
|
+
// git's own wording for the benign cases. Matched on the message because
|
|
47
|
+
// git reports them all with a generic non-zero status. "Needed a single
|
|
48
|
+
// revision" is what `rev-parse --verify HEAD` says on a repo with no
|
|
49
|
+
// commits, which is how the empty-history probe first fooled itself.
|
|
50
|
+
if (/does not have any commits yet|unknown revision or path not in the working tree|bad default revision|Needed a single revision|ambiguous argument 'HEAD'/i.test(stderr)) {
|
|
51
|
+
return { state: GIT_STATE.EMPTY, stdout: "", lines: [], stderr, code: r.status };
|
|
52
|
+
}
|
|
53
|
+
if (/not a git repository/i.test(stderr)) {
|
|
54
|
+
return { state: GIT_STATE.NOT_A_REPO, stdout: "", lines: [], stderr, code: r.status };
|
|
55
|
+
}
|
|
56
|
+
// Anything else that exited non-zero is a failure, NOT a success with an
|
|
57
|
+
// empty answer. Reporting it as OK is how a caller reads "no results"
|
|
58
|
+
// from a command that actually refused.
|
|
59
|
+
return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Is this a git repository at all?
|
|
63
|
+
export function isRepo(cwd) {
|
|
64
|
+
return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Does it have at least one commit? False for a fresh `git init`.
|
|
68
|
+
export function hasCommits(cwd) {
|
|
69
|
+
return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The state a command should report before trying to read history:
|
|
73
|
+
// { usable, state, reason } where `reason` is a sentence fit to print.
|
|
74
|
+
export function historyState(cwd) {
|
|
75
|
+
const probe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
76
|
+
if (probe.state === GIT_STATE.ABSENT) {
|
|
77
|
+
return { usable: false, state: GIT_STATE.ABSENT, reason: "git is not installed or not on PATH" };
|
|
78
|
+
}
|
|
79
|
+
if (probe.state !== GIT_STATE.OK) {
|
|
80
|
+
return { usable: false, state: GIT_STATE.NOT_A_REPO, reason: "this is not a git repository" };
|
|
81
|
+
}
|
|
82
|
+
if (!hasCommits(cwd)) {
|
|
83
|
+
return { usable: false, state: GIT_STATE.EMPTY, reason: "this repository has no commits yet" };
|
|
84
|
+
}
|
|
85
|
+
return { usable: true, state: GIT_STATE.OK, reason: "" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Lines of output, trimmed and blank-filtered — what nearly every caller
|
|
89
|
+
// actually wanted from stdout.
|
|
90
|
+
export function gitLines(cwd, args) {
|
|
91
|
+
const r = git(cwd, args);
|
|
92
|
+
return r.state === GIT_STATE.OK ? r.lines : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitLines(s) {
|
|
96
|
+
return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
97
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { read, write, exists } from "./fs-ops.js";
|
|
3
4
|
import { cliVersion } from "./version.js";
|
|
4
5
|
|
|
5
6
|
export const SCHEMA_VERSION = "0.1.0";
|
|
6
7
|
|
|
8
|
+
// The artifact categories a well-formed index.json carries. ONE definition,
|
|
9
|
+
// consumed by `blank()` (what `init` writes) and by `templates check` /
|
|
10
|
+
// `templates update` (what a project is measured against).
|
|
11
|
+
//
|
|
12
|
+
// There used to be two: the shape `init` materialised from a template file
|
|
13
|
+
// and the shape `templates check` required. Nothing compared them, so when
|
|
14
|
+
// `contracts` was added to the expected shape and not to the template,
|
|
15
|
+
// every project was born needing a scaffold update (audit item C5).
|
|
16
|
+
export const ARTIFACT_CATEGORIES = Object.freeze([
|
|
17
|
+
"specs", "decisions", "changes", "changes_archive", "skills", "contracts",
|
|
18
|
+
]);
|
|
19
|
+
|
|
7
20
|
export function indexPath(projectRoot) {
|
|
8
21
|
return path.join(projectRoot, ".doctrina", "index.json");
|
|
9
22
|
}
|
|
@@ -46,12 +59,7 @@ export function blank(projectName, date) {
|
|
|
46
59
|
version: "0.1.0",
|
|
47
60
|
last_updated: date,
|
|
48
61
|
},
|
|
49
|
-
|
|
50
|
-
decisions: [],
|
|
51
|
-
changes: [],
|
|
52
|
-
changes_archive: [],
|
|
53
|
-
skills: [],
|
|
54
|
-
contracts: [],
|
|
62
|
+
...Object.fromEntries(ARTIFACT_CATEGORIES.map((cat) => [cat, []])),
|
|
55
63
|
},
|
|
56
64
|
};
|
|
57
65
|
}
|