doctrina-cli 0.1.1 → 0.2.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 +27 -5
- package/package.json +1 -1
- package/src/commands/analyze.js +5 -5
- package/src/commands/change.js +113 -16
- package/src/commands/clarify.js +81 -18
- package/src/commands/context.js +129 -0
- package/src/commands/decision.js +91 -8
- package/src/commands/hooks.js +3 -3
- package/src/commands/index-rebuild.js +109 -0
- package/src/commands/init.js +58 -7
- package/src/commands/intake.js +166 -0
- package/src/commands/metrics.js +190 -0
- package/src/commands/next.js +108 -0
- package/src/commands/search.js +93 -0
- package/src/commands/skill.js +58 -5
- package/src/commands/spec.js +49 -7
- package/src/commands/templates.js +103 -4
- package/src/commands/validate.js +76 -9
- package/src/commands/work.js +215 -0
- package/src/index.js +26 -3
- package/src/lib/diff.js +103 -0
- package/src/lib/ears.js +108 -0
- package/src/lib/fs-ops.js +12 -0
- package/src/lib/scan.js +185 -0
- package/src/lib/templates.js +2 -2
- package/templates/AGENTS.md.template +15 -0
- package/templates/README.md +23 -2
- package/templates/doctrina/index.json.template +2 -1
package/src/lib/diff.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Minimal line diff (LCS-based) for `change diff`. Specs are capped at a
|
|
2
|
+
// few hundred lines, so the O(n*m) table is fine and keeps us at zero deps.
|
|
3
|
+
|
|
4
|
+
export function diffLines(aText, bText) {
|
|
5
|
+
const a = splitLines(aText);
|
|
6
|
+
const b = splitLines(bText);
|
|
7
|
+
const n = a.length;
|
|
8
|
+
const m = b.length;
|
|
9
|
+
|
|
10
|
+
// LCS length table.
|
|
11
|
+
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
12
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
13
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
14
|
+
table[i][j] = a[i] === b[j]
|
|
15
|
+
? table[i + 1][j + 1] + 1
|
|
16
|
+
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Backtrack into a flat op list.
|
|
21
|
+
const ops = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
let j = 0;
|
|
24
|
+
while (i < n && j < m) {
|
|
25
|
+
if (a[i] === b[j]) {
|
|
26
|
+
ops.push({ type: "same", text: a[i] });
|
|
27
|
+
i += 1;
|
|
28
|
+
j += 1;
|
|
29
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
30
|
+
ops.push({ type: "del", text: a[i] });
|
|
31
|
+
i += 1;
|
|
32
|
+
} else {
|
|
33
|
+
ops.push({ type: "add", text: b[j] });
|
|
34
|
+
j += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
while (i < n) ops.push({ type: "del", text: a[i++] });
|
|
38
|
+
while (j < m) ops.push({ type: "add", text: b[j++] });
|
|
39
|
+
return ops;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Render ops as unified-style hunks with `context` lines around changes.
|
|
43
|
+
export function formatUnified(ops, { context = 3, aLabel = "a", bLabel = "b" } = {}) {
|
|
44
|
+
const out = [`--- ${aLabel}`, `+++ ${bLabel}`];
|
|
45
|
+
const hunks = collectHunks(ops, context);
|
|
46
|
+
let aLine = 1;
|
|
47
|
+
let bLine = 1;
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
for (const [start, end] of hunks) {
|
|
50
|
+
// Advance line counters across the skipped region.
|
|
51
|
+
for (; cursor < start; cursor++) {
|
|
52
|
+
if (ops[cursor].type !== "add") aLine += 1;
|
|
53
|
+
if (ops[cursor].type !== "del") bLine += 1;
|
|
54
|
+
}
|
|
55
|
+
let aLen = 0;
|
|
56
|
+
let bLen = 0;
|
|
57
|
+
for (let k = start; k < end; k++) {
|
|
58
|
+
if (ops[k].type !== "add") aLen += 1;
|
|
59
|
+
if (ops[k].type !== "del") bLen += 1;
|
|
60
|
+
}
|
|
61
|
+
out.push(`@@ -${aLine},${aLen} +${bLine},${bLen} @@`);
|
|
62
|
+
for (; cursor < end; cursor++) {
|
|
63
|
+
const op = ops[cursor];
|
|
64
|
+
const prefix = op.type === "add" ? "+" : op.type === "del" ? "-" : " ";
|
|
65
|
+
out.push(prefix + op.text);
|
|
66
|
+
if (op.type !== "add") aLine += 1;
|
|
67
|
+
if (op.type !== "del") bLine += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (hunks.length === 0) out.push("(no differences)");
|
|
71
|
+
return out.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function collectHunks(ops, context) {
|
|
75
|
+
// Indices of changed ops.
|
|
76
|
+
const changed = [];
|
|
77
|
+
for (let k = 0; k < ops.length; k++) {
|
|
78
|
+
if (ops[k].type !== "same") changed.push(k);
|
|
79
|
+
}
|
|
80
|
+
if (changed.length === 0) return [];
|
|
81
|
+
const hunks = [];
|
|
82
|
+
let start = Math.max(0, changed[0] - context);
|
|
83
|
+
let end = Math.min(ops.length, changed[0] + context + 1);
|
|
84
|
+
for (let c = 1; c < changed.length; c++) {
|
|
85
|
+
const k = changed[c];
|
|
86
|
+
if (k - context <= end) {
|
|
87
|
+
end = Math.min(ops.length, k + context + 1);
|
|
88
|
+
} else {
|
|
89
|
+
hunks.push([start, end]);
|
|
90
|
+
start = Math.max(0, k - context);
|
|
91
|
+
end = Math.min(ops.length, k + context + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
hunks.push([start, end]);
|
|
95
|
+
return hunks;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function splitLines(text) {
|
|
99
|
+
const lines = text.split("\n");
|
|
100
|
+
// Drop a single trailing empty line produced by a final newline.
|
|
101
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
package/src/lib/ears.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Structural EARS checks for capability specs. The clarify command catches
|
|
2
|
+
// word-level smells; this catches grammar-shape problems per EARS section.
|
|
3
|
+
// All findings are warnings: specs evolve, validation should not block.
|
|
4
|
+
|
|
5
|
+
const SECTION_RULES = [
|
|
6
|
+
{
|
|
7
|
+
match: /^###\s+Ubiquitous/i,
|
|
8
|
+
name: "Ubiquitous",
|
|
9
|
+
check(req) {
|
|
10
|
+
if (/^when\b/i.test(req)) return "event-shaped requirement (starts with \"When\") — move to ### Event-driven";
|
|
11
|
+
if (/^while\b/i.test(req)) return "state-shaped requirement (starts with \"While\") — move to ### State-driven";
|
|
12
|
+
if (/^where\b/i.test(req)) return "optional-shaped requirement (starts with \"Where\") — move to ### Optional";
|
|
13
|
+
if (!/\bshall\b/i.test(req)) return "missing \"shall\" — ubiquitous form is \"The system shall ...\"";
|
|
14
|
+
return null;
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
match: /^###\s+Event-driven/i,
|
|
19
|
+
name: "Event-driven",
|
|
20
|
+
check(req) {
|
|
21
|
+
if (!/^when\b/i.test(req)) return "expected \"When <trigger>, the system shall ...\"";
|
|
22
|
+
if (!/\bshall\b/i.test(req)) return "missing \"shall\" after the When-trigger";
|
|
23
|
+
return null;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
match: /^###\s+State-driven/i,
|
|
28
|
+
name: "State-driven",
|
|
29
|
+
check(req) {
|
|
30
|
+
if (!/^while\b/i.test(req)) return "expected \"While <state>, the system shall ...\"";
|
|
31
|
+
if (!/\bshall\b/i.test(req)) return "missing \"shall\" after the While-state";
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
match: /^###\s+Unwanted/i,
|
|
37
|
+
name: "Unwanted-behavior",
|
|
38
|
+
check(req) {
|
|
39
|
+
if (!/\bshall\b/i.test(req)) return "missing \"shall\" — unwanted-behavior form is \"The system shall not ...\"";
|
|
40
|
+
if (!/\b(not|no|never)\b/i.test(req)) return "missing a negation — unwanted-behavior requirements forbid something";
|
|
41
|
+
return null;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
match: /^###\s+Optional/i,
|
|
46
|
+
name: "Optional",
|
|
47
|
+
check(req) {
|
|
48
|
+
if (!/^where\b/i.test(req)) return "expected \"Where <feature is present>, the system may ...\"";
|
|
49
|
+
if (!/\bmay\b/i.test(req)) return "missing \"may\" — optional requirements use may, not shall";
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Check one spec text. Returns [{ line, message }]. Only applies to specs
|
|
56
|
+
// that declare a "## Requirements (EARS)" section; bug-shape specs and
|
|
57
|
+
// free-form specs are skipped entirely.
|
|
58
|
+
export function checkEars(text) {
|
|
59
|
+
const lines = text.split("\n");
|
|
60
|
+
const findings = [];
|
|
61
|
+
|
|
62
|
+
let inRequirements = false;
|
|
63
|
+
let rule = null;
|
|
64
|
+
let req = null; // { line, text }
|
|
65
|
+
|
|
66
|
+
const flush = () => {
|
|
67
|
+
if (req && rule) {
|
|
68
|
+
const message = rule.check(req.text.trim());
|
|
69
|
+
if (message) findings.push({ line: req.line, message: `${rule.name}: ${message}` });
|
|
70
|
+
}
|
|
71
|
+
req = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
|
|
77
|
+
if (/^##\s+Requirements \(EARS\)/i.test(line)) {
|
|
78
|
+
inRequirements = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!inRequirements) continue;
|
|
82
|
+
if (/^##\s+/.test(line)) {
|
|
83
|
+
// Left the Requirements section.
|
|
84
|
+
flush();
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (/^###\s+/.test(line)) {
|
|
88
|
+
flush();
|
|
89
|
+
rule = SECTION_RULES.find((r) => r.match.test(line)) ?? null;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (/^-\s+/.test(line)) {
|
|
93
|
+
flush();
|
|
94
|
+
req = { line: i + 1, text: line.replace(/^-\s+/, "") };
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Continuation of the current bullet (indented text or nested bullet).
|
|
98
|
+
if (req && /^\s+\S/.test(line)) {
|
|
99
|
+
req.text += " " + line.trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
flush();
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isEarsSpec(text) {
|
|
107
|
+
return /^##\s+Requirements \(EARS\)/im.test(text);
|
|
108
|
+
}
|
package/src/lib/fs-ops.js
CHANGED
|
@@ -73,3 +73,15 @@ export function lineCount(p) {
|
|
|
73
73
|
if (text.length === 0) return 0;
|
|
74
74
|
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
// Normalise a path to forward slashes for display and comparison. CLI
|
|
78
|
+
// output is part of the contract (tests, docs, agents parse it), so it
|
|
79
|
+
// must not change shape between platforms.
|
|
80
|
+
export function toPosix(p) {
|
|
81
|
+
return p.split(path.sep).join("/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// path.relative with stable, platform-independent forward slashes.
|
|
85
|
+
export function relPath(from, to) {
|
|
86
|
+
return toPosix(path.relative(from, to));
|
|
87
|
+
}
|
package/src/lib/scan.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
4
|
+
import { today } from "./dates.js";
|
|
5
|
+
import { parseFrontmatter } from "../commands/skill.js";
|
|
6
|
+
import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
|
|
7
|
+
|
|
8
|
+
// Parse a spec-style header line: **Name:** value
|
|
9
|
+
export function specHeader(text, name) {
|
|
10
|
+
const m = text.match(new RegExp(`^\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
|
|
11
|
+
return m ? m[1].trim() : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Parse an ADR/proposal-style header line: - **Name:** value
|
|
15
|
+
export function listHeader(text, name) {
|
|
16
|
+
const m = text.match(new RegExp(`^-\\s+\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
|
|
17
|
+
return m ? m[1].trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dirEntries(dir) {
|
|
21
|
+
if (!isDir(dir)) return [];
|
|
22
|
+
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Regenerate the index object from the artifacts on disk. The files are
|
|
26
|
+
// the source of truth; fields with no on-disk source (project name,
|
|
27
|
+
// framework_version, product metadata) are carried over from `current`.
|
|
28
|
+
export function deriveIndex(projectRoot, current) {
|
|
29
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
30
|
+
const date = today();
|
|
31
|
+
const cur = current?.artifacts ?? {};
|
|
32
|
+
|
|
33
|
+
const out = {
|
|
34
|
+
$schema_version: current?.$schema_version ?? "0.1.0",
|
|
35
|
+
project: current?.project ?? path.basename(projectRoot),
|
|
36
|
+
framework_version: current?.framework_version ?? "0.0.0",
|
|
37
|
+
last_updated: current?.last_updated ?? date,
|
|
38
|
+
artifacts: {
|
|
39
|
+
product: cur.product ?? {
|
|
40
|
+
path: ".doctrina/product.md",
|
|
41
|
+
status: "active",
|
|
42
|
+
version: "0.1.0",
|
|
43
|
+
last_updated: date,
|
|
44
|
+
},
|
|
45
|
+
specs: [],
|
|
46
|
+
decisions: [],
|
|
47
|
+
changes: [],
|
|
48
|
+
changes_archive: [],
|
|
49
|
+
skills: [],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Specs — headers inside each spec.md win over the previous index.
|
|
54
|
+
const specsDir = path.join(dot, "specs");
|
|
55
|
+
for (const cap of dirEntries(specsDir)) {
|
|
56
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
57
|
+
if (!isFile(p)) continue;
|
|
58
|
+
const text = read(p);
|
|
59
|
+
const prev = (cur.specs ?? []).find((s) => s.id === cap);
|
|
60
|
+
out.artifacts.specs.push({
|
|
61
|
+
id: cap,
|
|
62
|
+
path: `.doctrina/specs/${cap}/spec.md`,
|
|
63
|
+
status: specHeader(text, "Status") ?? prev?.status ?? "active",
|
|
64
|
+
version: specHeader(text, "Version") ?? prev?.version ?? "0.1.0",
|
|
65
|
+
last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Decisions — NNNN-slug.md files; Status/Date headers win.
|
|
70
|
+
const adrDir = path.join(dot, "decisions");
|
|
71
|
+
for (const f of walk(adrDir)) {
|
|
72
|
+
const base = path.basename(f);
|
|
73
|
+
const m = base.match(/^(\d{4})-.*\.md$/);
|
|
74
|
+
if (!m) continue;
|
|
75
|
+
const text = read(f);
|
|
76
|
+
const prev = (cur.decisions ?? []).find((d) => d.id === m[1]);
|
|
77
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
78
|
+
const entry = {
|
|
79
|
+
id: m[1],
|
|
80
|
+
path: `.doctrina/decisions/${base}`,
|
|
81
|
+
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? base,
|
|
82
|
+
status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
|
|
83
|
+
date: listHeader(text, "Date") ?? prev?.date ?? date,
|
|
84
|
+
};
|
|
85
|
+
const supersedes = listHeader(text, "Supersedes");
|
|
86
|
+
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
87
|
+
const supersededBy = listHeader(text, "Superseded by");
|
|
88
|
+
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
89
|
+
out.artifacts.decisions.push(entry);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Open changes — every directory except archive/.
|
|
93
|
+
const changesDir = path.join(dot, "changes");
|
|
94
|
+
for (const id of dirEntries(changesDir)) {
|
|
95
|
+
if (id === "archive") continue;
|
|
96
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
97
|
+
const prev = (cur.changes ?? []).find((c) => c.id === id);
|
|
98
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
99
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
100
|
+
const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
|
|
101
|
+
out.artifacts.changes.push({
|
|
102
|
+
id,
|
|
103
|
+
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? id,
|
|
104
|
+
path: `.doctrina/changes/${id}`,
|
|
105
|
+
status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
|
|
106
|
+
opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Archived changes — folder name carries the applied date and id.
|
|
111
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
112
|
+
for (const name of dirEntries(archiveDir)) {
|
|
113
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
114
|
+
if (!m || !isDir(path.join(archiveDir, name))) continue;
|
|
115
|
+
const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
|
|
116
|
+
const proposalPath = path.join(archiveDir, name, "proposal.md");
|
|
117
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
118
|
+
const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
|
|
119
|
+
const specsAffected = [];
|
|
120
|
+
for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
|
|
121
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
122
|
+
const text = read(deltaPath);
|
|
123
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
124
|
+
if (capability) {
|
|
125
|
+
specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
out.artifacts.changes_archive.push({
|
|
129
|
+
id: m[2],
|
|
130
|
+
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? m[2],
|
|
131
|
+
path: `.doctrina/changes/archive/${name}`,
|
|
132
|
+
status: "applied",
|
|
133
|
+
applied: m[1],
|
|
134
|
+
specs_affected: specsAffected,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Skills — frontmatter description wins.
|
|
139
|
+
const skillsDir = path.join(dot, "skills");
|
|
140
|
+
for (const f of walk(skillsDir)) {
|
|
141
|
+
if (!f.endsWith(".md")) continue;
|
|
142
|
+
const id = path.basename(f, ".md");
|
|
143
|
+
const prev = (cur.skills ?? []).find((s) => s.id === id);
|
|
144
|
+
out.artifacts.skills.push({
|
|
145
|
+
id,
|
|
146
|
+
path: `.doctrina/skills/${id}.md`,
|
|
147
|
+
description: parseFrontmatter(read(f), "description")
|
|
148
|
+
?? prev?.description ?? "<edit me — one-sentence summary>",
|
|
149
|
+
last_updated: prev?.last_updated ?? date,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Deterministic stringify (sorted keys) so two indexes compare by content,
|
|
157
|
+
// not by key insertion order.
|
|
158
|
+
export function stableStringify(value) {
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
161
|
+
}
|
|
162
|
+
if (value && typeof value === "object") {
|
|
163
|
+
const keys = Object.keys(value).sort();
|
|
164
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
|
|
165
|
+
}
|
|
166
|
+
return JSON.stringify(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Content equality ignoring the root last_updated timestamp. Absent
|
|
170
|
+
// artifact categories compare equal to empty ones so pre-skills indexes
|
|
171
|
+
// are not flagged for a purely structural difference.
|
|
172
|
+
export function indexesMatch(a, b) {
|
|
173
|
+
if (!a || !b) return false;
|
|
174
|
+
const normalize = (x) => {
|
|
175
|
+
const clone = { ...x };
|
|
176
|
+
delete clone.last_updated;
|
|
177
|
+
const arts = { ...(clone.artifacts ?? {}) };
|
|
178
|
+
for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills"]) {
|
|
179
|
+
arts[cat] = arts[cat] ?? [];
|
|
180
|
+
}
|
|
181
|
+
clone.artifacts = arts;
|
|
182
|
+
return clone;
|
|
183
|
+
};
|
|
184
|
+
return stableStringify(normalize(a)) === stableStringify(normalize(b));
|
|
185
|
+
}
|
package/src/lib/templates.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { read, walk, write } from "./fs-ops.js";
|
|
3
|
+
import { read, toPosix, walk, write } from "./fs-ops.js";
|
|
4
4
|
|
|
5
5
|
// Resolve the path to the .doctrina/templates/ directory shipped with this
|
|
6
6
|
// package. The package layout is:
|
|
@@ -59,7 +59,7 @@ export function loadTemplateTree(templatesDir, subdir) {
|
|
|
59
59
|
const root = path.join(templatesDir, subdir);
|
|
60
60
|
const files = walk(root);
|
|
61
61
|
return files.map((templatePath) => {
|
|
62
|
-
const relativePath = path.relative(root, templatePath);
|
|
62
|
+
const relativePath = toPosix(path.relative(root, templatePath));
|
|
63
63
|
const body = templatePath.endsWith(".gitkeep") ? "" : read(templatePath);
|
|
64
64
|
return { templatePath, relativePath, body };
|
|
65
65
|
});
|
|
@@ -11,6 +11,21 @@ This repository uses the Doctrina framework for spec-driven, AGENTS.md
|
|
|
11
11
|
native multi-agent development. The full framework artifacts live under
|
|
12
12
|
`.doctrina/`.
|
|
13
13
|
|
|
14
|
+
## Working from intent
|
|
15
|
+
|
|
16
|
+
- **Bootstrap.** If `.doctrina/intake.md` exists with `Status: pending`,
|
|
17
|
+
the specs have not been written yet. Before anything else, run
|
|
18
|
+
`doctrina intake` and execute the printed bootstrap playbook in one
|
|
19
|
+
linear pass: fill `product.md`, derive capabilities, author one EARS
|
|
20
|
+
spec each, run the quality gates (`doctrina clarify --all`,
|
|
21
|
+
`doctrina validate`), then flip the intake to `Status: converted`. Ask
|
|
22
|
+
the user only when the intake is genuinely ambiguous. After conversion
|
|
23
|
+
the specs are the only source of truth — never edit `intake.md` to
|
|
24
|
+
change requirements.
|
|
25
|
+
- **Day-to-day.** Turn a brief request into a scaffolded change with
|
|
26
|
+
`doctrina work "<prompt>"`, then follow the printed work playbook
|
|
27
|
+
(spec delta → tasks → implement → analyze → apply → archive → validate).
|
|
28
|
+
|
|
14
29
|
## Stack and tooling
|
|
15
30
|
|
|
16
31
|
<!-- Replace with the project's actual stack. Keep this section short. -->
|
package/templates/README.md
CHANGED
|
@@ -38,18 +38,33 @@ doctrina/ the .doctrina/ skeleton
|
|
|
38
38
|
changes/.gitkeep
|
|
39
39
|
changes/archive/.gitkeep
|
|
40
40
|
decisions/.gitkeep
|
|
41
|
+
skills/.gitkeep
|
|
41
42
|
templates/.gitkeep
|
|
42
|
-
spec.md.template one capability spec
|
|
43
|
+
spec.md.template one capability spec (EARS)
|
|
44
|
+
spec-bug.md.template bug-shape capability spec
|
|
45
|
+
skill.md.template one on-demand skill
|
|
43
46
|
change/
|
|
44
47
|
proposal.md.template
|
|
45
48
|
tasks.md.template
|
|
46
49
|
design.md.template
|
|
47
50
|
spec-delta.md.template
|
|
48
51
|
decision.md.template one ADR (Nygard/MADR)
|
|
52
|
+
hooks/
|
|
53
|
+
pre-commit.sample installed by `doctrina hooks install`
|
|
54
|
+
watch.sample on-save validation wrapper (manual wiring)
|
|
49
55
|
adapters/
|
|
50
56
|
claude/CLAUDE.md.template
|
|
51
57
|
codex/README.md
|
|
52
|
-
cursor/rules/00-doctrina.mdc.template
|
|
58
|
+
cursor/.cursor/rules/00-doctrina.mdc.template
|
|
59
|
+
copilot/.github/copilot-instructions.md.template
|
|
60
|
+
gemini/GEMINI.md.template
|
|
61
|
+
aider/CONVENTIONS.md.template
|
|
62
|
+
windsurf/.windsurfrules.template
|
|
63
|
+
continue/.continue/rules/00-doctrina.md.template
|
|
64
|
+
amp/README.md
|
|
65
|
+
devin/README.md
|
|
66
|
+
factory/README.md
|
|
67
|
+
jules/README.md
|
|
53
68
|
```
|
|
54
69
|
|
|
55
70
|
## Adapter strategy
|
|
@@ -59,6 +74,12 @@ adapters/
|
|
|
59
74
|
| `claude` | `CLAUDE.md` at project root with `@AGENTS.md` import | Claude Code reads `CLAUDE.md` natively and supports `@file` includes. |
|
|
60
75
|
| `codex` | nothing | OpenAI Codex CLI reads `AGENTS.md` natively; an adapter file would only add drift surface. |
|
|
61
76
|
| `cursor` | `.cursor/rules/00-doctrina.mdc` with `alwaysApply: true` | Cursor's `.mdc` rules live in a dedicated directory; the file is a thin pointer. |
|
|
77
|
+
| `copilot` | `.github/copilot-instructions.md` | GitHub Copilot's repository-level custom instructions path. |
|
|
78
|
+
| `gemini` | `GEMINI.md` at project root | Gemini CLI's native context file. |
|
|
79
|
+
| `aider` | `CONVENTIONS.md` at project root | Aider reads CONVENTIONS.md as cached read-only context. |
|
|
80
|
+
| `windsurf` | `.windsurfrules` at project root | Windsurf's rules file convention. |
|
|
81
|
+
| `continue` | `.continue/rules/00-doctrina.md` | Continue.dev's rules directory. |
|
|
82
|
+
| `amp` / `devin` / `factory` / `jules` | nothing | AGENTS.md-native (same posture as `codex`); the directory marks the support contract and gives future per-agent affordances a home. |
|
|
62
83
|
|
|
63
84
|
Adapter files stay under 30 lines so they cannot meaningfully drift from
|
|
64
85
|
`AGENTS.md` and so they do not pay a noticeable per-turn token tax.
|