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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { exists, isDir, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
|
+
import { today } from "../lib/dates.js";
|
|
7
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
|
|
10
|
+
// Local-only adoption metrics derived from git history. No network calls,
|
|
11
|
+
// no telemetry: the numbers stay in the repository, versioned like any
|
|
12
|
+
// other artifact. This is the tooling half of docs/en/validation.md.
|
|
13
|
+
|
|
14
|
+
const REEDIT_WINDOW_DAYS = 21;
|
|
15
|
+
|
|
16
|
+
export async function run(_positional, flags) {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
20
|
+
}
|
|
21
|
+
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
22
|
+
if (probe.status !== 0) {
|
|
23
|
+
console.error(c.red("error:") + " not a git repository (metrics are derived from local git history)");
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sinceRaw = flagString(flags, "since", "90");
|
|
28
|
+
const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
|
|
29
|
+
const save = flagBool(flags, "save", false);
|
|
30
|
+
|
|
31
|
+
const log = git(projectRoot, [
|
|
32
|
+
"log", `--since=${since}`, "--date=unix",
|
|
33
|
+
"--pretty=format:@@%H|%ct|%s", "--name-only",
|
|
34
|
+
]);
|
|
35
|
+
if (log.status !== 0) {
|
|
36
|
+
console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const commits = parseLog(log.stdout);
|
|
41
|
+
console.log(c.bold("Doctrina metrics") + c.gray(` (local git history, window: ${since})`));
|
|
42
|
+
console.log("");
|
|
43
|
+
if (commits.length === 0) {
|
|
44
|
+
console.log(c.gray("no commits in the window; nothing to measure"));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const snapshot = computeSnapshot(commits, since);
|
|
49
|
+
printReport(snapshot);
|
|
50
|
+
|
|
51
|
+
if (save) {
|
|
52
|
+
const dir = path.join(projectRoot, ".doctrina", "metrics");
|
|
53
|
+
const file = path.join(dir, `${today()}.json`);
|
|
54
|
+
const previous = latestSnapshot(dir, path.basename(file));
|
|
55
|
+
write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
|
|
58
|
+
if (previous) {
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log(c.bold("Δ vs " + previous.generated + ":"));
|
|
61
|
+
printDelta("revert rate", previous.revert_rate, snapshot.revert_rate);
|
|
62
|
+
printDelta("fix share", previous.fix_share, snapshot.fix_share);
|
|
63
|
+
printDelta(`${REEDIT_WINDOW_DAYS}-day re-edit rate`, previous.reedit_rate, snapshot.reedit_rate);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function computeSnapshot(commits, since) {
|
|
70
|
+
const total = commits.length;
|
|
71
|
+
const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
|
|
72
|
+
const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
|
|
73
|
+
|
|
74
|
+
// File churn: commits per file, top offenders first.
|
|
75
|
+
const churn = new Map();
|
|
76
|
+
for (const cm of commits) {
|
|
77
|
+
for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
const topChurn = [...churn.entries()]
|
|
80
|
+
.sort((a, b) => b[1] - a[1])
|
|
81
|
+
.slice(0, 5)
|
|
82
|
+
.map(([file, count]) => ({ file, commits: count }));
|
|
83
|
+
|
|
84
|
+
// Re-edit proxy: a commit counts when it touches a file another commit
|
|
85
|
+
// already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
|
|
86
|
+
// for rework, not a verdict — iterative work also trips it.
|
|
87
|
+
const windowSec = REEDIT_WINDOW_DAYS * 86400;
|
|
88
|
+
const lastTouch = new Map();
|
|
89
|
+
let reedits = 0;
|
|
90
|
+
for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
|
|
91
|
+
const isReedit = cm.files.some((f) => {
|
|
92
|
+
const prev = lastTouch.get(f);
|
|
93
|
+
return prev !== undefined && cm.ts - prev <= windowSec;
|
|
94
|
+
});
|
|
95
|
+
if (isReedit) reedits += 1;
|
|
96
|
+
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
generated: today(),
|
|
101
|
+
window: since,
|
|
102
|
+
commits: total,
|
|
103
|
+
reverts,
|
|
104
|
+
revert_rate: round(reverts / total),
|
|
105
|
+
fix_share: round(fixes / total),
|
|
106
|
+
reedit_window_days: REEDIT_WINDOW_DAYS,
|
|
107
|
+
reedit_rate: round(reedits / total),
|
|
108
|
+
top_churn: topChurn,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function printReport(s) {
|
|
113
|
+
console.log(`commits ${s.commits}`);
|
|
114
|
+
console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
|
|
115
|
+
console.log(`fix-typed commits ${pct(s.fix_share)} of commits (Conventional Commits "fix")`);
|
|
116
|
+
console.log(`${s.reedit_window_days}-day re-edit rate ${pct(s.reedit_rate)} of commits touch a file edited in the prior ${s.reedit_window_days} days`);
|
|
117
|
+
console.log(c.gray(" (proxy for rework — iterative work also counts)"));
|
|
118
|
+
if (s.top_churn.length > 0) {
|
|
119
|
+
console.log("");
|
|
120
|
+
console.log("top churn:");
|
|
121
|
+
for (const t of s.top_churn) {
|
|
122
|
+
console.log(` ${String(t.commits).padStart(4)} ${t.file}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function printDelta(label, before, after) {
|
|
128
|
+
if (before === undefined || after === undefined) return;
|
|
129
|
+
const diff = after - before;
|
|
130
|
+
const arrow = diff > 0 ? c.red(`+${pct(diff)}`) : diff < 0 ? c.green(pct(diff)) : c.gray("±0");
|
|
131
|
+
console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function latestSnapshot(dir, excludeBase) {
|
|
135
|
+
if (!isDir(dir)) return null;
|
|
136
|
+
const files = readdirSync(dir)
|
|
137
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
|
|
138
|
+
.sort();
|
|
139
|
+
if (files.length === 0) return null;
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(read(path.join(dir, files[files.length - 1])));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseLog(stdout) {
|
|
148
|
+
const commits = [];
|
|
149
|
+
let current = null;
|
|
150
|
+
for (const line of stdout.split("\n")) {
|
|
151
|
+
if (line.startsWith("@@")) {
|
|
152
|
+
const [hash, ts, ...rest] = line.slice(2).split("|");
|
|
153
|
+
current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
|
|
154
|
+
commits.push(current);
|
|
155
|
+
} else if (line.trim() !== "" && current) {
|
|
156
|
+
current.files.push(line.trim());
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return commits;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function git(cwd, args) {
|
|
163
|
+
return spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function round(x) {
|
|
167
|
+
return Math.round(x * 1000) / 1000;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pct(x) {
|
|
171
|
+
return `${(x * 100).toFixed(1)}%`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const help = `
|
|
175
|
+
Usage: doctrina metrics [--since <days|date>] [--save]
|
|
176
|
+
|
|
177
|
+
Derive adoption metrics from LOCAL git history: commit count, revert
|
|
178
|
+
count and rate, Conventional-Commit fix share, top-churn files, and a
|
|
179
|
+
${REEDIT_WINDOW_DAYS}-day re-edit proxy rate. No network calls; nothing leaves the repo.
|
|
180
|
+
|
|
181
|
+
Flags:
|
|
182
|
+
--since <n|date> Window: a day count (default 90) or any git-parseable
|
|
183
|
+
date ("2026-01-01", "3 months ago").
|
|
184
|
+
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
|
185
|
+
deltas against the most recent prior snapshot.
|
|
186
|
+
|
|
187
|
+
This is the tooling half of the empirical A/B protocol in
|
|
188
|
+
docs/en/validation.md: snapshot before adopting Doctrina, snapshot each
|
|
189
|
+
month after, compare.
|
|
190
|
+
`;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
export async function run(_positional, _flags) {
|
|
10
|
+
const projectRoot = process.cwd();
|
|
11
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
12
|
+
console.log("1. doctrina init — this directory is not a Doctrina project yet");
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const actions = [];
|
|
17
|
+
|
|
18
|
+
// A pending intake is the very first thing to resolve: until it is
|
|
19
|
+
// converted, product.md and the specs are still empty scaffolding.
|
|
20
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
21
|
+
if (isFile(intakePath)) {
|
|
22
|
+
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
23
|
+
if (status !== "converted") {
|
|
24
|
+
actions.push("doctrina intake — a pending intake awaits conversion into product.md and specs");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Open changes drive the loop: finish what is started before opening more.
|
|
29
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
30
|
+
if (isDir(changesDir)) {
|
|
31
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
32
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
33
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
34
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
35
|
+
if (!isFile(proposalPath)) {
|
|
36
|
+
actions.push(`add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
40
|
+
if (status === "applied") {
|
|
41
|
+
actions.push(`doctrina change archive ${id} — applied but not archived`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
45
|
+
const unchecked = isFile(tasksPath)
|
|
46
|
+
? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
|
|
47
|
+
: 0;
|
|
48
|
+
if (unchecked > 0) {
|
|
49
|
+
actions.push(`complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
53
|
+
if (deltas.length > 0) {
|
|
54
|
+
actions.push(`doctrina analyze ${id}, then doctrina change apply ${id} — tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`);
|
|
55
|
+
} else {
|
|
56
|
+
actions.push(`add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only: doctrina change apply ${id}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ADRs stuck in proposed need a human decision.
|
|
62
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
63
|
+
for (const f of walk(adrDir)) {
|
|
64
|
+
if (!f.endsWith(".md")) continue;
|
|
65
|
+
const m = path.basename(f).match(/^(\d{4})-/);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
const status = listHeader(read(f), "Status");
|
|
68
|
+
if (status && status.toLowerCase() === "proposed") {
|
|
69
|
+
actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Index drift is silent rot; surface it last.
|
|
74
|
+
try {
|
|
75
|
+
const current = idx.load(projectRoot);
|
|
76
|
+
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
77
|
+
actions.push("doctrina index rebuild — index.json has drifted from the tree");
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
actions.push("doctrina index rebuild — index.json is missing or unreadable");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (actions.length === 0) {
|
|
84
|
+
console.log(c.green("ok") + " no open work.");
|
|
85
|
+
console.log("");
|
|
86
|
+
console.log("Start something:");
|
|
87
|
+
console.log(` doctrina change new <id> "<title>" open a change proposal`);
|
|
88
|
+
console.log(` doctrina spec new <capability> spec a new capability`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
|
|
93
|
+
console.log("");
|
|
94
|
+
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const help = `
|
|
99
|
+
Usage: doctrina next
|
|
100
|
+
|
|
101
|
+
Inspect the .doctrina/ tree and print the recommended next workflow
|
|
102
|
+
actions in priority order: open changes (missing proposal, unchecked
|
|
103
|
+
tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
|
|
104
|
+
proposed status, and index drift. Read-only; always exits 0.
|
|
105
|
+
|
|
106
|
+
Intended use: agents and humans run it to resume work without
|
|
107
|
+
re-reading the whole tree.
|
|
108
|
+
`;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
|
+
import { flagBool } from "../lib/args.js";
|
|
5
|
+
import { c } from "../lib/colors.js";
|
|
6
|
+
|
|
7
|
+
// Category-aware full-text search across the artifact tree. Answers
|
|
8
|
+
// "where is X specified / decided / proposed?" without knowing the
|
|
9
|
+
// layout. Plain substring matching, every term must hit (AND).
|
|
10
|
+
|
|
11
|
+
const MAX_MATCHES_PER_FILE = 5;
|
|
12
|
+
|
|
13
|
+
export async function run(positional, flags) {
|
|
14
|
+
const terms = positional.map((t) => t.toLowerCase()).filter(Boolean);
|
|
15
|
+
if (terms.length === 0) {
|
|
16
|
+
console.error(c.red("error:") + " search requires at least one term");
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
const includeArchive = flagBool(flags, "archive", false);
|
|
20
|
+
const projectRoot = process.cwd();
|
|
21
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
22
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const categories = [
|
|
26
|
+
{ name: "specs", dir: path.join(".doctrina", "specs") },
|
|
27
|
+
{ name: "decisions", dir: path.join(".doctrina", "decisions") },
|
|
28
|
+
{ name: "changes", dir: path.join(".doctrina", "changes"), skip: (rel) => !includeArchive && rel.includes("/archive/") },
|
|
29
|
+
{ name: "skills", dir: path.join(".doctrina", "skills") },
|
|
30
|
+
{ name: "product", file: path.join(".doctrina", "product.md") },
|
|
31
|
+
{ name: "rules", file: "AGENTS.md" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let totalMatches = 0;
|
|
35
|
+
let filesWithMatches = 0;
|
|
36
|
+
|
|
37
|
+
for (const cat of categories) {
|
|
38
|
+
const files = cat.file
|
|
39
|
+
? [path.join(projectRoot, cat.file)].filter(exists)
|
|
40
|
+
: isDir(path.join(projectRoot, cat.dir))
|
|
41
|
+
? walk(path.join(projectRoot, cat.dir)).filter((f) => f.endsWith(".md"))
|
|
42
|
+
: [];
|
|
43
|
+
|
|
44
|
+
const findings = [];
|
|
45
|
+
for (const f of files) {
|
|
46
|
+
const rel = relPath(projectRoot, f);
|
|
47
|
+
if (cat.skip && cat.skip(rel)) continue;
|
|
48
|
+
const lines = read(f).split("\n");
|
|
49
|
+
const hits = [];
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const low = lines[i].toLowerCase();
|
|
52
|
+
if (terms.every((t) => low.includes(t))) {
|
|
53
|
+
hits.push({ line: i + 1, text: lines[i].trim() });
|
|
54
|
+
if (hits.length === MAX_MATCHES_PER_FILE) break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (hits.length > 0) findings.push({ rel, hits });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (findings.length === 0) continue;
|
|
61
|
+
console.log(c.bold(cat.name));
|
|
62
|
+
for (const file of findings) {
|
|
63
|
+
filesWithMatches += 1;
|
|
64
|
+
for (const h of file.hits) {
|
|
65
|
+
totalMatches += 1;
|
|
66
|
+
console.log(` ${c.cyan(file.rel)}:${h.line}: ${truncate(h.text, 100)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.log("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (totalMatches === 0) {
|
|
73
|
+
console.log(c.gray(`no matches for "${positional.join(" ")}"${includeArchive ? "" : " (archive excluded; pass --archive to include it)"}`));
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
console.log(c.gray(`${totalMatches} match${totalMatches === 1 ? "" : "es"} in ${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncate(s, n) {
|
|
81
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const help = `
|
|
85
|
+
Usage: doctrina search <term> [<term> ...] [--archive]
|
|
86
|
+
|
|
87
|
+
Case-insensitive substring search across the artifact tree, grouped
|
|
88
|
+
by category (specs, decisions, changes, skills, product, AGENTS.md).
|
|
89
|
+
Every term must match on the same line. The change archive is
|
|
90
|
+
excluded unless --archive is passed.
|
|
91
|
+
|
|
92
|
+
Exits 0 when matches are found, 1 otherwise. Read-only.
|
|
93
|
+
`;
|
package/src/commands/skill.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, mkdirp, read, walk, write } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
6
|
import { today } from "../lib/dates.js";
|
|
@@ -8,7 +8,7 @@ import { flagBool } from "../lib/args.js";
|
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { suggest } from "../lib/suggest.js";
|
|
10
10
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "list"];
|
|
11
|
+
const SUBCOMMANDS = ["new", "list", "sync"];
|
|
12
12
|
|
|
13
13
|
export async function run(positional, flags) {
|
|
14
14
|
const sub = positional[0];
|
|
@@ -21,6 +21,7 @@ export async function run(positional, flags) {
|
|
|
21
21
|
return 2;
|
|
22
22
|
}
|
|
23
23
|
if (sub === "new") return skillNew(positional.slice(1), flags);
|
|
24
|
+
if (sub === "sync") return skillSync();
|
|
24
25
|
return skillList();
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -38,7 +39,7 @@ function skillNew(args, flags) {
|
|
|
38
39
|
mkdirp(skillsDir);
|
|
39
40
|
const targetPath = path.join(skillsDir, `${name}.md`);
|
|
40
41
|
if (exists(targetPath) && !force) {
|
|
41
|
-
console.error(c.red("error:") + ` ${
|
|
42
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
|
|
42
43
|
return 1;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -46,7 +47,7 @@ function skillNew(args, flags) {
|
|
|
46
47
|
const tplPath = path.join(templatesDir, "skill.md.template");
|
|
47
48
|
const body = substitute(read(tplPath), { SKILL_NAME: name });
|
|
48
49
|
write(targetPath, body, { force });
|
|
49
|
-
console.log(c.green("created") + ` ${
|
|
50
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
|
|
50
51
|
|
|
51
52
|
const date = today();
|
|
52
53
|
const index = idx.load(projectRoot);
|
|
@@ -60,7 +61,56 @@ function skillNew(args, flags) {
|
|
|
60
61
|
idx.save(projectRoot, index);
|
|
61
62
|
console.log(c.green("indexed") + ` skill "${name}"`);
|
|
62
63
|
console.log("");
|
|
63
|
-
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then update index.json
|
|
64
|
+
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina skill sync` to update index.json");
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function skillSync() {
|
|
69
|
+
const projectRoot = process.cwd();
|
|
70
|
+
ensureDoctrinaProject(projectRoot);
|
|
71
|
+
|
|
72
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
73
|
+
if (!isDir(skillsDir)) {
|
|
74
|
+
console.log(c.gray("no skills directory; nothing to sync"));
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const files = walk(skillsDir).filter((f) => f.endsWith(".md"));
|
|
79
|
+
const index = idx.load(projectRoot);
|
|
80
|
+
const date = today();
|
|
81
|
+
let synced = 0;
|
|
82
|
+
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
const id = path.basename(f, ".md");
|
|
85
|
+
const desc = parseFrontmatter(read(f), "description");
|
|
86
|
+
if (!desc) {
|
|
87
|
+
console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
|
|
91
|
+
if (!entry) {
|
|
92
|
+
idx.addSkill(index, {
|
|
93
|
+
id,
|
|
94
|
+
path: `.doctrina/skills/${id}.md`,
|
|
95
|
+
description: desc,
|
|
96
|
+
last_updated: date,
|
|
97
|
+
});
|
|
98
|
+
console.log(c.green("indexed") + ` ${id}`);
|
|
99
|
+
synced += 1;
|
|
100
|
+
} else if (entry.description !== desc) {
|
|
101
|
+
entry.description = desc;
|
|
102
|
+
entry.last_updated = date;
|
|
103
|
+
console.log(c.green("synced ") + ` ${id}`);
|
|
104
|
+
synced += 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (synced > 0) {
|
|
109
|
+
idx.touch(index, date);
|
|
110
|
+
idx.save(projectRoot, index);
|
|
111
|
+
}
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(c.green("ok") + ` ${synced} skill${synced === 1 ? "" : "s"} synced, ${files.length - synced} already up to date`);
|
|
64
114
|
return 0;
|
|
65
115
|
}
|
|
66
116
|
|
|
@@ -120,6 +170,9 @@ Subcommands:
|
|
|
120
170
|
it. Name must match [a-z][a-z0-9-]*.
|
|
121
171
|
list Print one line per skill with name + description.
|
|
122
172
|
Read-only.
|
|
173
|
+
sync Copy each skill's frontmatter description into
|
|
174
|
+
.doctrina/index.json so the index never drifts
|
|
175
|
+
from the frontmatter (single source of truth).
|
|
123
176
|
|
|
124
177
|
Skills are written by humans, not generated. See docs/en/skills.md
|
|
125
178
|
for the design rationale and the distinction from specs / AGENTS.md /
|
package/src/commands/spec.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import {
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
5
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
|
+
import { specHeader } from "../lib/scan.js";
|
|
5
7
|
import * as idx from "../lib/index-json.js";
|
|
6
8
|
import { today } from "../lib/dates.js";
|
|
7
9
|
import { flagBool } from "../lib/args.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
10
12
|
|
|
11
|
-
const SUBCOMMANDS = ["new"];
|
|
13
|
+
const SUBCOMMANDS = ["new", "list"];
|
|
12
14
|
|
|
13
15
|
export async function run(positional, flags) {
|
|
14
16
|
const sub = positional[0];
|
|
@@ -20,6 +22,7 @@ export async function run(positional, flags) {
|
|
|
20
22
|
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
21
23
|
return 2;
|
|
22
24
|
}
|
|
25
|
+
if (sub === "list") return specList();
|
|
23
26
|
|
|
24
27
|
const capability = positional[1];
|
|
25
28
|
if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
|
|
@@ -35,7 +38,7 @@ export async function run(positional, flags) {
|
|
|
35
38
|
const targetDir = path.join(projectRoot, ".doctrina", "specs", capability);
|
|
36
39
|
const targetPath = path.join(targetDir, "spec.md");
|
|
37
40
|
if (exists(targetPath) && !force) {
|
|
38
|
-
console.error(c.red("error:") + ` ${
|
|
41
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
|
|
39
42
|
return 1;
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -45,7 +48,7 @@ export async function run(positional, flags) {
|
|
|
45
48
|
const date = today();
|
|
46
49
|
const body = substitute(read(tplPath), { CAPABILITY: capability, DATE: date });
|
|
47
50
|
write(targetPath, body, { force });
|
|
48
|
-
console.log(c.green("created") + ` ${
|
|
51
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
|
|
49
52
|
|
|
50
53
|
const index = idx.load(projectRoot);
|
|
51
54
|
idx.addSpec(index, {
|
|
@@ -61,6 +64,40 @@ export async function run(positional, flags) {
|
|
|
61
64
|
return 0;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
function specList() {
|
|
68
|
+
const projectRoot = process.cwd();
|
|
69
|
+
ensureDoctrinaProject(projectRoot);
|
|
70
|
+
|
|
71
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
72
|
+
const rows = [];
|
|
73
|
+
if (isDir(specsDir)) {
|
|
74
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
75
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
76
|
+
if (!isFile(p)) continue;
|
|
77
|
+
const text = read(p);
|
|
78
|
+
rows.push({
|
|
79
|
+
id: cap,
|
|
80
|
+
version: specHeader(text, "Version") ?? "—",
|
|
81
|
+
status: specHeader(text, "Status") ?? "active",
|
|
82
|
+
lines: lineCount(p),
|
|
83
|
+
updated: specHeader(text, "Last updated") ?? "—",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (rows.length === 0) {
|
|
88
|
+
console.log(c.gray("no specs found in .doctrina/specs/"));
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
console.log(c.bold("Specs:"));
|
|
92
|
+
console.log("");
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
|
|
95
|
+
}
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log(c.gray(`${rows.length} spec${rows.length === 1 ? "" : "s"}`));
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
64
101
|
function ensureDoctrinaProject(projectRoot) {
|
|
65
102
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
66
103
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
@@ -68,10 +105,15 @@ function ensureDoctrinaProject(projectRoot) {
|
|
|
68
105
|
}
|
|
69
106
|
|
|
70
107
|
export const help = `
|
|
71
|
-
Usage: doctrina spec
|
|
108
|
+
Usage: doctrina spec <subcommand>
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
Subcommands:
|
|
111
|
+
new <capability> [--bug] [--force] Create a capability spec under
|
|
112
|
+
.doctrina/specs/<capability>/spec.md
|
|
113
|
+
and index it.
|
|
114
|
+
list One line per spec: id, version,
|
|
115
|
+
status, line count, last updated.
|
|
116
|
+
Read-only.
|
|
75
117
|
|
|
76
118
|
Options:
|
|
77
119
|
--bug Scaffold the bug-shape template (current / expected /
|