doctrina-cli 0.8.0 → 0.9.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/package.json +1 -1
- package/src/commands/skill.js +114 -19
package/package.json
CHANGED
package/src/commands/skill.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
5
6
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
7
|
import * as idx from "../lib/index-json.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
|
-
import { flagBool } from "../lib/args.js";
|
|
9
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
9
10
|
import { c } from "../lib/colors.js";
|
|
10
11
|
import { suggest } from "../lib/suggest.js";
|
|
11
12
|
|
|
@@ -30,57 +31,91 @@ export async function run(positional, flags) {
|
|
|
30
31
|
// Surface (and optionally scaffold) skills worth capturing (review 2026-06-27
|
|
31
32
|
// passive-user feature #6). A skill exists so a hard-won lesson is not relearned
|
|
32
33
|
// by the next agent/session; the framework otherwise never pulls you toward
|
|
33
|
-
// writing one. `skill suggest` scans
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// writing one. `skill suggest` scans for fix-shaped work — the textbook case for
|
|
35
|
+
// a skill — from two deterministic sources and lists candidates whose lesson is
|
|
36
|
+
// not yet captured: archived change proposals, and the git commit history
|
|
37
|
+
// (ADR 0013). The archive only fills if a team runs the `doctrina change`
|
|
38
|
+
// lifecycle, so without the git source `suggest` is inert in most repos. With
|
|
39
|
+
// --write it scaffolds a stub per candidate, pre-seeded from the source so the
|
|
40
|
+
// human/agent only fills the body. Both sources are a pattern match — a hint,
|
|
41
|
+
// never a decision (ADR 0005); skills are still authored, never generated.
|
|
38
42
|
const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
|
|
39
43
|
|
|
44
|
+
// The same idea applied to a commit subject. Narrower than FIX_SHAPED on
|
|
45
|
+
// purpose: a deliberate folder slug tolerates more noise than a free-form
|
|
46
|
+
// subject line. Matches conventional fix-type prefixes (fix:, fix(scope):,
|
|
47
|
+
// bug:, hotfix:, patch:, and the free-form "Fix the…") plus the strongly
|
|
48
|
+
// fix-flavoured debugging keywords — never feat:/refactor:/docs:/chore:.
|
|
49
|
+
const FIX_SHAPED_SUBJECT =
|
|
50
|
+
/^(?:fix|bug|hotfix|patch)\b|\b(?:tolerate|workaround|deadlock|flaky|race condition|retry|sanitiz|sanitis)/i;
|
|
51
|
+
|
|
52
|
+
// How far back the git source looks when --since is not given, and how many
|
|
53
|
+
// candidates we print before collapsing the rest into a "+N more" line.
|
|
54
|
+
const GIT_LOG_LIMIT = 200;
|
|
55
|
+
const MAX_SHOWN = 20;
|
|
56
|
+
|
|
40
57
|
function skillSuggest(args, flags) {
|
|
41
58
|
const projectRoot = process.cwd();
|
|
42
59
|
ensureDoctrinaProject(projectRoot);
|
|
43
60
|
const doWrite = flagBool(flags, "write", false);
|
|
61
|
+
const since = flagString(flags, "since");
|
|
44
62
|
|
|
45
|
-
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
46
63
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
47
64
|
const existing = new Set(
|
|
48
65
|
isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
|
|
49
66
|
);
|
|
50
67
|
|
|
68
|
+
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
69
|
+
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
51
70
|
const candidates = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
|
|
73
|
+
// Source 1 — archived change proposals with a fix-shaped folder name.
|
|
74
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
52
75
|
if (isDir(archiveDir)) {
|
|
53
76
|
for (const name of readdirSync(archiveDir).sort()) {
|
|
54
77
|
if (!isDir(path.join(archiveDir, name))) continue;
|
|
55
78
|
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
56
79
|
if (!FIX_SHAPED.test(id)) continue;
|
|
57
80
|
const slug = skillSlug(id);
|
|
58
|
-
if (existing.has(slug)) continue; //
|
|
81
|
+
if (existing.has(slug) || seen.has(slug)) continue; // captured or already queued
|
|
59
82
|
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
60
83
|
const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
61
|
-
|
|
84
|
+
seen.add(slug);
|
|
85
|
+
candidates.push({ id, slug, why, source: "change", from: name });
|
|
62
86
|
}
|
|
63
87
|
}
|
|
64
88
|
|
|
89
|
+
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
90
|
+
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
91
|
+
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
92
|
+
if (existing.has(cand.slug) || seen.has(cand.slug)) continue;
|
|
93
|
+
seen.add(cand.slug);
|
|
94
|
+
candidates.push(cand);
|
|
95
|
+
}
|
|
96
|
+
|
|
65
97
|
if (candidates.length === 0) {
|
|
66
|
-
console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/"));
|
|
67
|
-
console.log(c.gray("(
|
|
98
|
+
console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
|
|
99
|
+
console.log(c.gray("(scans archived changes and fix-shaped commits — pass --since <ref> to widen the git window;"));
|
|
100
|
+
console.log(c.gray(" skills are written by humans, so this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
|
|
68
101
|
return 0;
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
if (!doWrite) {
|
|
72
105
|
console.log(c.bold("Skill candidates") + c.gray(" — fix-shaped lessons not yet captured:"));
|
|
73
106
|
console.log("");
|
|
74
|
-
for (const cand of candidates) {
|
|
75
|
-
console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray(
|
|
107
|
+
for (const cand of candidates.slice(0, MAX_SHOWN)) {
|
|
108
|
+
console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray(provenance(cand))}`);
|
|
76
109
|
if (cand.why) console.log(` ${c.gray(cand.why.slice(0, 90))}`);
|
|
77
110
|
}
|
|
111
|
+
const hidden = candidates.length - MAX_SHOWN;
|
|
112
|
+
if (hidden > 0) console.log(c.gray(` … +${hidden} more`));
|
|
78
113
|
console.log("");
|
|
79
114
|
console.log(c.gray(`${candidates.length} candidate${candidates.length === 1 ? "" : "s"}. Scaffold them: `) + c.cyan("doctrina skill suggest --write"));
|
|
80
115
|
return 0;
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
// --write: scaffold a stub per candidate, pre-seeded from
|
|
118
|
+
// --write: scaffold a stub per candidate, pre-seeded from its source.
|
|
84
119
|
const templatesDir = locateTemplatesDir();
|
|
85
120
|
const tplPath = path.join(templatesDir, "skill.md.template");
|
|
86
121
|
const tpl = read(tplPath);
|
|
@@ -97,7 +132,7 @@ function skillSuggest(args, flags) {
|
|
|
97
132
|
let body = substitute(tpl, { SKILL_NAME: cand.slug });
|
|
98
133
|
if (cand.why) {
|
|
99
134
|
// Drop the originating lesson into the body so authoring is "fill", not "start".
|
|
100
|
-
body += `\n<!--
|
|
135
|
+
body += `\n<!-- ${seedLabel(cand)}. The lesson to capture:\n${cand.why}\n-->\n`;
|
|
101
136
|
}
|
|
102
137
|
write(target, body, { force: false });
|
|
103
138
|
idx.addSkill(index, {
|
|
@@ -118,6 +153,63 @@ function skillSuggest(args, flags) {
|
|
|
118
153
|
return 0;
|
|
119
154
|
}
|
|
120
155
|
|
|
156
|
+
// One-line origin shown next to a candidate slug.
|
|
157
|
+
function provenance(cand) {
|
|
158
|
+
return cand.source === "commit" ? `from commit ${cand.ref}` : `from ${cand.from}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The label seeded into a scaffolded skill body so the author knows where the
|
|
162
|
+
// lesson came from.
|
|
163
|
+
function seedLabel(cand) {
|
|
164
|
+
return cand.source === "commit"
|
|
165
|
+
? `Candidate from commit ${cand.ref}`
|
|
166
|
+
: `Candidate from change "${cand.id}"`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
|
|
170
|
+
// 0013). Deterministic — a regex on the subject, never a read of the diff
|
|
171
|
+
// (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
|
|
172
|
+
// git repo, git is absent, or the --since ref is unknown, so the command
|
|
173
|
+
// degrades to archive-only rather than erroring.
|
|
174
|
+
function gitFixCommits(projectRoot, { since, limit }) {
|
|
175
|
+
// US (0x1f) between fields, RS (0x1e) between records — robust against any
|
|
176
|
+
// newline or punctuation inside a subject or body.
|
|
177
|
+
const format = "%H%x1f%s%x1f%b%x1e";
|
|
178
|
+
const args = ["log", "--no-merges", `--format=${format}`];
|
|
179
|
+
if (since) args.push(`${since}..HEAD`);
|
|
180
|
+
else args.push("-n", String(limit));
|
|
181
|
+
|
|
182
|
+
const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
183
|
+
if (r.error || r.status !== 0 || !r.stdout) return [];
|
|
184
|
+
|
|
185
|
+
const out = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const record of r.stdout.split("\x1e")) {
|
|
188
|
+
const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
|
|
189
|
+
if (!sha || !sha.trim()) continue;
|
|
190
|
+
if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
|
|
191
|
+
const slug = commitSlug(subject);
|
|
192
|
+
if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
|
|
193
|
+
seen.add(slug);
|
|
194
|
+
const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
|
195
|
+
out.push({
|
|
196
|
+
id: slug,
|
|
197
|
+
slug,
|
|
198
|
+
source: "commit",
|
|
199
|
+
ref: sha.trim().slice(0, 9),
|
|
200
|
+
why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
207
|
+
// "type(scope): " prefix, then reuse skillSlug for the lowercase/hyphenate/cap.
|
|
208
|
+
function commitSlug(subject) {
|
|
209
|
+
const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
|
|
210
|
+
return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
|
211
|
+
}
|
|
212
|
+
|
|
121
213
|
function skillSlug(id) {
|
|
122
214
|
// Reuse the change id as the skill slug, trimmed to a sane length.
|
|
123
215
|
const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -282,10 +374,13 @@ Subcommands:
|
|
|
282
374
|
sync Copy each skill's frontmatter description into
|
|
283
375
|
.doctrina/index.json so the index never drifts
|
|
284
376
|
from the frontmatter (single source of truth).
|
|
285
|
-
suggest [--write]
|
|
286
|
-
Surface fix-shaped lessons
|
|
287
|
-
|
|
288
|
-
|
|
377
|
+
suggest [--write] [--since <ref>]
|
|
378
|
+
Surface fix-shaped lessons not yet captured as skills,
|
|
379
|
+
from two sources: archived change proposals and the git
|
|
380
|
+
commit history (ADR 0013). --write scaffolds a stub per
|
|
381
|
+
candidate (pre-seeded from its source) to fill in.
|
|
382
|
+
--since <ref> scans commits in <ref>..HEAD instead of the
|
|
383
|
+
last 200 (e.g. --since v0.7.0).
|
|
289
384
|
|
|
290
385
|
Skills are written by humans, not generated. See docs/en/skills.md
|
|
291
386
|
for the design rationale and the distinction from specs / AGENTS.md /
|