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 +105 -0
- package/package.json +38 -0
- package/src/commands/analyze.js +126 -0
- package/src/commands/change.js +292 -0
- package/src/commands/clarify.js +146 -0
- package/src/commands/decision.js +183 -0
- package/src/commands/hooks.js +59 -0
- package/src/commands/init.js +158 -0
- package/src/commands/skill.js +130 -0
- package/src/commands/spec.js +80 -0
- package/src/commands/templates.js +162 -0
- package/src/commands/validate.js +239 -0
- package/src/index.js +102 -0
- package/src/lib/args.js +92 -0
- package/src/lib/colors.js +31 -0
- package/src/lib/dates.js +21 -0
- package/src/lib/fs-ops.js +75 -0
- package/src/lib/index-json.js +109 -0
- package/src/lib/prompt.js +19 -0
- package/src/lib/suggest.js +44 -0
- package/src/lib/templates.js +78 -0
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
|
+
`;
|