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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/skill.js +114 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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 the archived changes for fix-shaped work —
34
- // the textbook case for a skill — and lists candidates whose lesson is not yet
35
- // captured. With --write it scaffolds a stub per candidate, pre-seeded from the
36
- // change so the human/agent only fills the body. Deterministic pattern match on
37
- // the archived folder name — a hint, never a decision (ADR 0005).
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; // lesson already captured
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
- candidates.push({ id, slug, why, archive: name });
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("(skills are written by humans; this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
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("from " + cand.archive)}`);
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 the change.
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<!-- Candidate from change "${cand.id}". The lesson to capture:\n${cand.why}\n-->\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 in the change archive whose
287
- skill is not yet captured. --write scaffolds a stub per
288
- candidate (pre-seeded from the change) to fill in.
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 /