doctrina-cli 0.1.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 ADDED
@@ -0,0 +1,105 @@
1
+ # doctrina-cli
2
+
3
+ > CLI for the Doctrina framework. The package is published as
4
+ > `doctrina-cli`; the executable it installs is `doctrina`.
5
+
6
+ **Status:** v0.1.0 — first working release. Zero runtime dependencies
7
+ (Node.js standard library only).
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm install -g doctrina-cli
13
+ # or run on demand
14
+ npx doctrina-cli init
15
+ ```
16
+
17
+ Requires Node.js 20.12 or later.
18
+
19
+ ## Commands
20
+
21
+ ```
22
+ doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
+ doctrina spec new <capability> create a capability spec
24
+ doctrina change new <id> "<title>" open a change proposal
25
+ doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
26
+ doctrina change archive <id> archive an applied change
27
+ doctrina decision new "<title>" create the next sequential ADR
28
+ doctrina decision supersede <num> "<t>" supersede an existing ADR
29
+ doctrina analyze <change-id> inspect a change folder before applying
30
+ doctrina clarify <path> smell-test a Markdown file for ambiguity
31
+ doctrina validate schema + artifact-existence checks
32
+ doctrina templates list enumerate shipped templates
33
+ doctrina templates check compare project against recommended template shape
34
+ doctrina hooks install install pre-commit hook
35
+ doctrina --help | -h print the command surface
36
+ doctrina --version | -v print the package version
37
+ ```
38
+
39
+ Per-command help is available with `doctrina <command> --help`.
40
+
41
+ ## `init` flags
42
+
43
+ | Flag | Purpose |
44
+ |------|---------|
45
+ | `--project-name <name>` | Override the project name (defaults to cwd basename) |
46
+ | `--project-description <text>` | One-sentence description (prompted if omitted) |
47
+ | `--agent <name>` | Install adapter for `claude`, `codex`, `cursor`, or `all` |
48
+ | `--date <YYYY-MM-DD>` | Override the system date in artifacts |
49
+ | `--force` | Overwrite existing files |
50
+ | `--non-interactive` | Fail instead of prompting for missing required values |
51
+
52
+ ## Adapter behaviour
53
+
54
+ - `--agent claude` writes a `CLAUDE.md` at the project root that
55
+ `@`-imports `AGENTS.md`. Claude Code picks it up automatically.
56
+ - `--agent codex` installs nothing. OpenAI Codex CLI reads `AGENTS.md`
57
+ natively.
58
+ - `--agent cursor` writes `.cursor/rules/00-doctrina.mdc` with
59
+ `alwaysApply: true` pointing at `AGENTS.md`.
60
+ - `--agent all` installs every adapter.
61
+
62
+ All adapter files are kept under 30 lines to avoid per-turn token tax
63
+ and to make drift from `AGENTS.md` mechanically impossible.
64
+
65
+ ## `change apply` semantics
66
+
67
+ For each spec delta found under `.doctrina/changes/<id>/specs/`:
68
+
69
+ - **ADDED:** writes the delta body to the target spec; refuses if the
70
+ target already exists.
71
+ - **REMOVED:** deletes the target spec.
72
+ - **MODIFIED:** prints a manual-merge pointer and does not write. The
73
+ user merges the delta into the target spec by hand. This is
74
+ deliberate; auto-merging is the kind of implicit decision Doctrina
75
+ avoids.
76
+
77
+ ## `validate` checks
78
+
79
+ 1. `AGENTS.md` exists at root and is ≤ 200 lines (warns at > 150).
80
+ 2. `.doctrina/product.md` exists.
81
+ 3. `.doctrina/index.json` parses and matches the v0.1 shape.
82
+ 4. Every artifact referenced in `index.json` exists.
83
+ 5. Every ADR has a parseable `Status:` header.
84
+ 6. Every adapter template under `.doctrina/templates/adapters/` is ≤ 30 lines.
85
+ 7. Every open change has a `proposal.md`.
86
+ 8. Capability specs are ≤ 400 lines (warns above).
87
+ 9. ADRs are ≤ 300 lines (warns above).
88
+ 10. Spec/ADR files on disk are present in `index.json` (orphan warn).
89
+ 11. Markdown links inside specs/ADRs resolve to real files (stale ref warn).
90
+
91
+ Exit code: 0 if no errors (warnings allowed), 1 otherwise.
92
+
93
+ ## Tests
94
+
95
+ ```
96
+ npm test
97
+ ```
98
+
99
+ Runs the suite via the built-in Node test runner. The integration tests
100
+ spawn the CLI as a subprocess and exercise the full surface against a
101
+ temporary project directory.
102
+
103
+ ## License
104
+
105
+ MIT.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "doctrina-cli",
3
+ "version": "0.1.0",
4
+ "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "agents-md",
8
+ "spec-driven-development",
9
+ "sdd",
10
+ "ai-coding-agents",
11
+ "claude-code",
12
+ "codex",
13
+ "cursor",
14
+ "adr"
15
+ ],
16
+ "type": "module",
17
+ "bin": {
18
+ "doctrina": "./src/index.js"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20.12"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/gcarin1/doctrina.git",
33
+ "directory": "packages/doctrina-cli"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
4
+ import { c } from "../lib/colors.js";
5
+
6
+ export async function run(positional, _flags) {
7
+ const id = positional[0];
8
+ if (!id) {
9
+ console.error(c.red("error:") + " analyze requires <change-id>");
10
+ return 2;
11
+ }
12
+
13
+ const projectRoot = process.cwd();
14
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
15
+ if (!isDir(changeDir)) {
16
+ console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
17
+ return 1;
18
+ }
19
+
20
+ console.log(`analyzing ${path.relative(projectRoot, changeDir)}/`);
21
+ console.log("");
22
+
23
+ const results = [];
24
+
25
+ // proposal.md
26
+ const proposalPath = path.join(changeDir, "proposal.md");
27
+ if (!isFile(proposalPath)) {
28
+ results.push(fail("proposal.md missing"));
29
+ } else {
30
+ results.push(pass("proposal.md present"));
31
+ const text = read(proposalPath);
32
+ if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
33
+ else results.push(fail(`proposal.md missing "## Why" section`));
34
+ }
35
+
36
+ // tasks.md
37
+ const tasksPath = path.join(changeDir, "tasks.md");
38
+ if (!isFile(tasksPath)) {
39
+ results.push(fail("tasks.md missing"));
40
+ } else {
41
+ results.push(pass("tasks.md present"));
42
+ const text = read(tasksPath);
43
+ if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
44
+ else results.push(info("tasks.md has no unchecked tasks (already done?)"));
45
+ }
46
+
47
+ // design.md (optional)
48
+ const designPath = path.join(changeDir, "design.md");
49
+ if (isFile(designPath)) results.push(info("design.md present"));
50
+ else results.push(info("design.md absent (optional)"));
51
+
52
+ // Spec deltas
53
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
54
+ if (deltaFiles.length === 0) {
55
+ results.push(info("0 spec deltas (metadata-only change)"));
56
+ } else {
57
+ results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
58
+ for (const deltaPath of deltaFiles) {
59
+ const rel = path.relative(projectRoot, deltaPath);
60
+ const text = read(deltaPath);
61
+ const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
62
+ const op = opMatch ? opMatch[1] : null;
63
+ const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
64
+ const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
65
+
66
+ if (!op) {
67
+ results.push(fail(` ${rel}: Operation header missing or malformed`, " "));
68
+ continue;
69
+ }
70
+ if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
71
+ results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`, " "));
72
+ continue;
73
+ }
74
+ const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
75
+ const targetRel = path.relative(projectRoot, targetSpec);
76
+ if (op === "ADDED") {
77
+ if (exists(targetSpec)) {
78
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} already exists`, " "));
79
+ } else {
80
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
81
+ }
82
+ } else if (op === "MODIFIED") {
83
+ if (!exists(targetSpec)) {
84
+ results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, " "));
85
+ } else {
86
+ results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`, " "));
87
+ }
88
+ } else {
89
+ if (!exists(targetSpec)) {
90
+ results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, " "));
91
+ } else {
92
+ results.push(pass(` ${cap} (REMOVED) → ${targetRel}`, " "));
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ for (const r of results) console.log(r.line);
99
+ console.log("");
100
+
101
+ const failed = results.filter((r) => r.kind === "fail").length;
102
+ if (failed === 0) {
103
+ console.log(c.green("ok") + ` ready to apply`);
104
+ } else {
105
+ console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
106
+ }
107
+ return failed === 0 ? 0 : 1;
108
+ }
109
+
110
+ function pass(msg) {
111
+ return { kind: "pass", line: c.green("✓ ") + msg };
112
+ }
113
+ function fail(msg) {
114
+ return { kind: "fail", line: c.red("✗ ") + msg };
115
+ }
116
+ function info(msg) {
117
+ return { kind: "info", line: c.gray("- ") + msg };
118
+ }
119
+
120
+ export const help = `
121
+ Usage: doctrina analyze <change-id>
122
+
123
+ Inspect a change folder before applying. Reports on proposal,
124
+ tasks, design, and each spec delta. Exits 0 when no failures, 1
125
+ otherwise. Does not modify any files.
126
+ `;
@@ -0,0 +1,292 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isDir, mkdirp, move, read, remove, walk, write } from "../lib/fs-ops.js";
4
+ import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
5
+ import * as idx from "../lib/index-json.js";
6
+ import { today } from "../lib/dates.js";
7
+ import { flagBool } from "../lib/args.js";
8
+ import { c } from "../lib/colors.js";
9
+ import { suggest } from "../lib/suggest.js";
10
+
11
+ const SUBCOMMANDS = ["new", "apply", "archive"];
12
+
13
+ export async function run(positional, flags) {
14
+ const sub = positional[0];
15
+ switch (sub) {
16
+ case "new":
17
+ return changeNew(positional.slice(1), flags);
18
+ case "apply":
19
+ return changeApply(positional.slice(1), flags);
20
+ case "archive":
21
+ return changeArchive(positional.slice(1), flags);
22
+ default:
23
+ console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
24
+ const guess = suggest(sub, SUBCOMMANDS);
25
+ console.error(c.gray("hint: ") + (guess
26
+ ? `did you mean \`doctrina change ${guess}\`?`
27
+ : `available: ${SUBCOMMANDS.join(", ")}`));
28
+ return 2;
29
+ }
30
+ }
31
+
32
+ function changeNew(args, flags) {
33
+ const id = args[0];
34
+ const title = args.slice(1).join(" ").trim();
35
+ if (!id) {
36
+ console.error(c.red("error:") + " change new requires <id> and \"<title>\"");
37
+ return 2;
38
+ }
39
+ if (!title) {
40
+ console.error(c.red("error:") + " change new requires a title (quote it if it contains spaces)");
41
+ return 2;
42
+ }
43
+
44
+ const force = flagBool(flags, "force", false);
45
+ const projectRoot = process.cwd();
46
+ ensureDoctrinaProject(projectRoot);
47
+
48
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
49
+ if (exists(changeDir) && !force) {
50
+ console.error(c.red("error:") + ` change "${id}" already exists at ${path.relative(projectRoot, changeDir)}`);
51
+ return 1;
52
+ }
53
+
54
+ const templatesDir = locateTemplatesDir();
55
+ const date = today();
56
+ const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
57
+
58
+ const tree = loadTemplateTree(templatesDir, "change");
59
+ for (const entry of tree) {
60
+ if (entry.relativePath === "spec-delta.md.template") continue;
61
+ const written = materialiseEntry(entry, changeDir, tokens, { force });
62
+ console.log(c.green("created") + ` ${path.relative(projectRoot, written)}`);
63
+ }
64
+ mkdirp(path.join(changeDir, "specs"));
65
+
66
+ const index = idx.load(projectRoot);
67
+ idx.addChange(index, { id, title, path: `.doctrina/changes/${id}`, status: "proposed", opened: date });
68
+ idx.touch(index, date);
69
+ idx.save(projectRoot, index);
70
+
71
+ console.log("");
72
+ console.log(c.bold("Change opened.") + " Add spec deltas under " +
73
+ c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
74
+ return 0;
75
+ }
76
+
77
+ function changeApply(args, _flags) {
78
+ const id = args[0];
79
+ if (!id) {
80
+ console.error(c.red("error:") + " change apply requires <id>");
81
+ return 2;
82
+ }
83
+ const projectRoot = process.cwd();
84
+ ensureDoctrinaProject(projectRoot);
85
+
86
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
87
+ if (!isDir(changeDir)) {
88
+ console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
89
+ return 1;
90
+ }
91
+
92
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
93
+ const date = today();
94
+ let errors = 0;
95
+ let writes = 0;
96
+ let manual = 0;
97
+
98
+ if (deltaFiles.length === 0) {
99
+ console.log(c.yellow("note:") + " no spec deltas in this change. Marking proposal as applied.");
100
+ }
101
+
102
+ for (const deltaPath of deltaFiles) {
103
+ const rel = path.relative(changeDir, deltaPath);
104
+ const text = read(deltaPath);
105
+ const op = parseOperation(text);
106
+ const capability = parseCapabilityFromDelta(text, deltaPath);
107
+ if (!op || !capability) {
108
+ console.error(c.red("error:") + ` cannot parse Operation or capability in ${rel}`);
109
+ errors += 1;
110
+ continue;
111
+ }
112
+ const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
113
+
114
+ if (op === "ADDED") {
115
+ if (exists(targetPath)) {
116
+ console.error(c.red("error:") + ` ADDED delta targets existing spec ${path.relative(projectRoot, targetPath)} — refusing to overwrite`);
117
+ errors += 1;
118
+ continue;
119
+ }
120
+ const body = extractDeltaBody(text);
121
+ write(targetPath, body, { force: false });
122
+ writes += 1;
123
+ console.log(c.green("applied[ADDED]") + ` ${path.relative(projectRoot, targetPath)}`);
124
+ } else if (op === "REMOVED") {
125
+ if (!exists(targetPath)) {
126
+ console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${path.relative(projectRoot, targetPath)} — skipping`);
127
+ continue;
128
+ }
129
+ remove(targetPath);
130
+ writes += 1;
131
+ console.log(c.green("applied[REMOVED]") + ` ${path.relative(projectRoot, targetPath)}`);
132
+ } else if (op === "MODIFIED") {
133
+ console.log(c.yellow("manual[MODIFIED]") + ` merge ${path.relative(projectRoot, deltaPath)} into ${path.relative(projectRoot, targetPath)} by hand`);
134
+ manual += 1;
135
+ } else {
136
+ console.error(c.red("error:") + ` unknown Operation "${op}" in ${rel}`);
137
+ errors += 1;
138
+ }
139
+ }
140
+
141
+ // Flip proposal status from "proposed" to "applied" when fully successful.
142
+ // Zero-delta changes are trivially successful (no work to fail), so they
143
+ // also flip — otherwise metadata-only changes are stuck at "proposed".
144
+ if (errors === 0 && manual === 0) {
145
+ const proposalPath = path.join(changeDir, "proposal.md");
146
+ if (exists(proposalPath)) {
147
+ const txt = read(proposalPath);
148
+ const updated = txt.replace(
149
+ /^(-\s+\*\*Status:\*\*)\s+proposed\s*$/m,
150
+ `$1 applied\n- **Applied:** ${date}`,
151
+ );
152
+ if (updated !== txt) {
153
+ write(proposalPath, updated, { force: true });
154
+ console.log(c.green("status") + " proposal.md → applied");
155
+ }
156
+ }
157
+ }
158
+
159
+ // Update index for ADDED/REMOVED that affected specs
160
+ if (writes > 0) {
161
+ const index = idx.load(projectRoot);
162
+ for (const deltaPath of deltaFiles) {
163
+ const text = read(deltaPath);
164
+ const op = parseOperation(text);
165
+ const capability = parseCapabilityFromDelta(text, deltaPath);
166
+ if (!capability) continue;
167
+ if (op === "ADDED") {
168
+ idx.addSpec(index, {
169
+ id: capability,
170
+ path: `.doctrina/specs/${capability}/spec.md`,
171
+ status: "active",
172
+ version: "0.1.0",
173
+ last_updated: date,
174
+ });
175
+ } else if (op === "REMOVED") {
176
+ idx.removeSpec(index, capability);
177
+ }
178
+ }
179
+ idx.touch(index, date);
180
+ idx.save(projectRoot, index);
181
+ }
182
+
183
+ console.log("");
184
+ console.log(c.bold("Apply summary:") + ` ${writes} written, ${manual} manual, ${errors} errors.`);
185
+ if (manual > 0) {
186
+ console.log(`Resolve manual merges, then run ${c.cyan(`doctrina change archive ${id}`)}.`);
187
+ } else if (errors === 0) {
188
+ console.log(`Next: ${c.cyan(`doctrina change archive ${id}`)}.`);
189
+ }
190
+ return errors > 0 ? 1 : 0;
191
+ }
192
+
193
+ function changeArchive(args, _flags) {
194
+ const id = args[0];
195
+ if (!id) {
196
+ console.error(c.red("error:") + " change archive requires <id>");
197
+ return 2;
198
+ }
199
+ const projectRoot = process.cwd();
200
+ ensureDoctrinaProject(projectRoot);
201
+
202
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
203
+ if (!isDir(changeDir)) {
204
+ console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
205
+ return 1;
206
+ }
207
+
208
+ const date = today();
209
+ const archiveName = `${date}-${id}`;
210
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
211
+ if (exists(archiveDir)) {
212
+ console.error(c.red("error:") + ` archive target already exists: ${path.relative(projectRoot, archiveDir)}`);
213
+ return 1;
214
+ }
215
+ move(changeDir, archiveDir);
216
+ console.log(c.green("archived") + ` ${path.relative(projectRoot, archiveDir)}`);
217
+
218
+ // Collect affected specs from delta files for the index entry
219
+ const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
220
+ const specsAffected = [];
221
+ let title = id;
222
+ for (const deltaPath of deltaFiles) {
223
+ const text = read(deltaPath);
224
+ const op = parseOperation(text) ?? "MODIFIED";
225
+ const capability = parseCapabilityFromDelta(text, deltaPath);
226
+ if (capability) specsAffected.push({ capability, operation: op });
227
+ }
228
+ // Title from proposal
229
+ const proposal = path.join(archiveDir, "proposal.md");
230
+ if (exists(proposal)) {
231
+ const firstLine = read(proposal).split("\n", 1)[0] ?? "";
232
+ const m = firstLine.match(/^#\s+Change\s+[^—-]+[—-]\s*(.+)$/);
233
+ if (m) title = m[1].trim();
234
+ }
235
+
236
+ const index = idx.load(projectRoot);
237
+ idx.moveChangeToArchive(index, id, {
238
+ id,
239
+ title,
240
+ path: `.doctrina/changes/archive/${archiveName}`,
241
+ status: "applied",
242
+ applied: date,
243
+ specs_affected: specsAffected,
244
+ });
245
+ idx.touch(index, date);
246
+ idx.save(projectRoot, index);
247
+ console.log(c.green("indexed") + " change archived");
248
+ return 0;
249
+ }
250
+
251
+ function parseOperation(text) {
252
+ const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
253
+ if (!m) return null;
254
+ const op = m[1];
255
+ if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
256
+ return null;
257
+ }
258
+
259
+ function parseCapabilityFromDelta(text, deltaPath) {
260
+ // Prefer the explicit header "# Spec Delta — capability: <name>"
261
+ const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
262
+ if (m) return m[1];
263
+ // Fall back to the parent directory name of the delta file
264
+ const parent = path.basename(path.dirname(deltaPath));
265
+ if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
266
+ return null;
267
+ }
268
+
269
+ function extractDeltaBody(text) {
270
+ // The delta separates headers from the spec body with a `---` line.
271
+ const idxSep = text.indexOf("\n---\n");
272
+ if (idxSep < 0) return text;
273
+ return text.slice(idxSep + 5).replace(/^\n+/, "");
274
+ }
275
+
276
+ function ensureDoctrinaProject(projectRoot) {
277
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
278
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
279
+ }
280
+ }
281
+
282
+ export const help = `
283
+ Usage: doctrina change <subcommand> [args]
284
+
285
+ Subcommands:
286
+ new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
287
+ apply <id> Apply spec deltas (ADDED writes, REMOVED deletes, MODIFIED is manual)
288
+ archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
289
+
290
+ Options:
291
+ --force Overwrite existing files (where applicable)
292
+ `;