doctrina-cli 0.17.0 → 0.20.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 (57) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +131 -7
  6. package/src/commands/close.js +92 -28
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/next.js +1 -0
  14. package/src/commands/prime.js +76 -7
  15. package/src/commands/review.js +47 -8
  16. package/src/commands/skill.js +3 -127
  17. package/src/commands/upgrade.js +15 -0
  18. package/src/commands/verify.js +59 -6
  19. package/src/commands/work.js +139 -5
  20. package/src/commands/workspace.js +409 -0
  21. package/src/index.js +5 -7
  22. package/src/lib/actions.js +9 -5
  23. package/src/lib/adapters.js +80 -0
  24. package/src/lib/change-ops.js +92 -0
  25. package/src/lib/code-inventory.js +297 -0
  26. package/src/lib/commands.js +52 -5
  27. package/src/lib/context-slice.js +118 -0
  28. package/src/lib/delivery.js +208 -0
  29. package/src/lib/git.js +59 -2
  30. package/src/lib/metrics-model.js +23 -1
  31. package/src/lib/playbook.js +1 -1
  32. package/src/lib/runtime.js +6 -0
  33. package/src/lib/scan.js +13 -0
  34. package/src/lib/signoff.js +24 -0
  35. package/src/lib/skill-candidates.js +162 -0
  36. package/src/lib/skill-export.js +95 -0
  37. package/src/lib/snapshot.js +76 -2
  38. package/src/lib/spec-ops.js +117 -1
  39. package/src/lib/task-card.js +102 -0
  40. package/src/lib/validation-model.js +1 -1
  41. package/src/lib/verify-cache.js +76 -0
  42. package/src/lib/views.js +56 -2
  43. package/src/lib/work-model.js +90 -12
  44. package/src/lib/workspace-agents.js +259 -0
  45. package/src/lib/workspace-tools.js +113 -0
  46. package/src/lib/workspace.js +371 -0
  47. package/templates/AGENTS.md.template +17 -14
  48. package/templates/README.md +11 -0
  49. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  50. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  51. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  52. package/templates/change/spec-delta.md.template +2 -0
  53. package/templates/delivery.md.template +39 -0
  54. package/templates/playbooks/absorb.md.template +40 -0
  55. package/templates/playbooks/consolidate.md.template +28 -0
  56. package/templates/playbooks/delivery.md.template +38 -0
  57. package/templates/playbooks/work.md.template +4 -1
@@ -1,11 +1,15 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
- import { notADoctrinaProject } from "../lib/exit-codes.js";
7
- import { collectSnapshot } from "../lib/snapshot.js";
8
- import { renderView } from "../lib/views.js";
4
+ import { exists, read } from "../lib/fs-ops.js";
5
+ import { findWorkspace, readWorkspace } from "../lib/workspace.js";
6
+ import { listDeliveries } from "../lib/delivery.js";
7
+ import { workspacePrimer } from "../lib/workspace-agents.js";
8
+ import { flagBool, flagString } from "../lib/args.js";
9
+ import { c } from "../lib/colors.js";
10
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { collectSnapshot, openChanges, recentlyClosed } from "../lib/snapshot.js";
12
+ import { renderView, pulse } from "../lib/views.js";
9
13
 
10
14
  // Session primer: the ~40-line read that orients an agent at the start of a
11
15
  // session — where things stand, what the standing rules are, what work is
@@ -22,23 +26,77 @@ import { renderView } from "../lib/views.js";
22
26
  // Flags this command accepts. Declared HERE, with the command, so
23
27
  // adding a command never requires editing the entrypoint — the gap that
24
28
  // let six flags ship undeclared and silently swallow a positional (C3).
25
- export const flags = { boolean: ["json", "rules"], string: [] };
29
+ export const flags = { boolean: ["json", "rules", "pulse"], string: ["hook"] };
30
+
31
+ // What an agent hook accepts on stdout (change 0211). Claude Code adds the
32
+ // plain text of a SessionStart hook to the conversation; Cursor's
33
+ // sessionStart reads `additional_context` from a JSON object. Both cap what
34
+ // they keep — Claude at 10,000 characters — so the primer is cut short of
35
+ // that rather than replaced by a file path the agent is never asked to read.
36
+ const HOOK_AGENTS = ["claude", "cursor"];
37
+ const HOOK_MAX_CHARS = 9000;
26
38
 
27
39
  export async function run(_positional, flags) {
28
40
  const projectRoot = process.cwd();
29
41
  if (!exists(path.join(projectRoot, ".doctrina"))) {
42
+ // At a workspace root the session belongs to the orchestrator (change
43
+ // 0223): the primer is the workspace's.
44
+ const found = findWorkspace(projectRoot);
45
+ if (found && found.via !== "pointer") return primeWorkspace(found.dir, flags);
30
46
  throw notADoctrinaProject();
31
47
  }
32
48
  // --rules: the standing rules in full, which is what the removed
33
49
  // `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
34
50
  // prints both lists, from the same collection, so the two cannot disagree.
51
+ // --pulse: one line for a per-prompt hook — the open changes only, read
52
+ // without collecting the gates, because it runs before every prompt.
53
+ if (flagBool(flags, "pulse", false)) {
54
+ for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
55
+ return 0;
56
+ }
57
+ if (flags.has("hook")) {
58
+ const agent = flagString(flags, "hook");
59
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
60
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
61
+ return EXIT.USAGE;
62
+ }
63
+ const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
64
+ ...renderView("prime", collectSnapshot(projectRoot)), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
65
+ .join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
66
+ console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
67
+ return 0;
68
+ }
35
69
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
70
  for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
37
71
  return 0;
38
72
  }
39
73
 
74
+ function primeWorkspace(wsDir, flags) {
75
+ const ws = readWorkspace(wsDir);
76
+ const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
77
+ if (flagBool(flags, "pulse", false)) {
78
+ console.log(open.length === 0
79
+ ? "Doctrina workspace: no delivery is open — an item of work across repositories opens one with `doctrina delivery new \"<title>\" --ref <id>`."
80
+ : open.map((d) => `open delivery: ${d.id} — "${d.title}" (${d.projects.join(", ") || "not routed"}). Name it in your first sentence to the human; \`doctrina delivery status ${d.id}\`.`).join(" "));
81
+ return 0;
82
+ }
83
+ const lines = workspacePrimer(ws, wsDir, open);
84
+ if (flags.has("hook")) {
85
+ const agent = flagString(flags, "hook");
86
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
87
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
88
+ return EXIT.USAGE;
89
+ }
90
+ const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
91
+ console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
92
+ return 0;
93
+ }
94
+ for (const line of lines) console.log(line);
95
+ return 0;
96
+ }
97
+
40
98
  export const help = `
41
- Usage: doctrina prime
99
+ Usage: doctrina prime [--rules | --pulse | --hook claude|cursor]
42
100
 
43
101
  Print the session primer: gate digest, standing rules (accepted ADR
44
102
  titles + non-goal count), open work with task progress, and the top
@@ -51,6 +109,17 @@ collection of the tree, so they cannot disagree.
51
109
  --rules Print the standing rules in full instead of the primer: every
52
110
  accepted ADR and every declared non-goal — what the removed
53
111
  \`doctrina constitution\` printed, from the same collection.
112
+ --pulse One line for a per-prompt agent hook: the open change(s), or
113
+ that a request changing behaviour opens one with \`work\`.
114
+ --hook <agent>
115
+ The primer plus the pulse, as the agent's session-start hook
116
+ reads it: plain text for claude, {"additional_context"} JSON
117
+ for cursor; cut at 9,000 characters. \`doctrina adapter add
118
+ claude|cursor\` installs the hooks that call these.
119
+
120
+ At a workspace root (no .doctrina/, a workspace.json) it is the
121
+ orchestrator's primer: the projects and their state, the open deliveries,
122
+ the tools — and with --hook, the workspace's AGENTS.md after it.
54
123
 
55
124
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
56
125
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
@@ -8,7 +8,8 @@ import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
9
  import { readLedger, churnByCapability } from "../lib/ledger.js";
10
10
  import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
- import { dependentsOf } from "../lib/scan.js";
11
+ import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
12
+ import { refuseChangeRef } from "../lib/project.js";
12
13
  import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
14
  import { summarize as coverageSummary } from "../lib/coverage-model.js";
14
15
  import { summarize as traceSummary } from "../lib/trace-model.js";
@@ -37,7 +38,7 @@ const CHURN_NOTABLE = 3;
37
38
  // bury every other finding under a file listing.
38
39
  const ORPHAN_LIST_LIMIT = 5;
39
40
 
40
- export const flags = { boolean: ["json", "strict"], string: ["diff"] };
41
+ export const flags = { boolean: ["json", "strict"], string: ["diff", "change"] };
41
42
 
42
43
  export async function run(_positional, flags) {
43
44
  const projectRoot = process.cwd();
@@ -60,8 +61,26 @@ export async function run(_positional, flags) {
60
61
  return EXIT.USAGE;
61
62
  }
62
63
 
64
+ // --change <id>: review the tree AS that change's close sees it (change
65
+ // 0200). Its deltas are the spec updates — merged one step later, by
66
+ // `apply` — so the capabilities they target count as updated.
67
+ const changeId = flagString(flags, "change");
68
+ /** @type {Set<string> | null} */
69
+ let changeCaps = null;
70
+ if (changeId !== undefined) {
71
+ const refused = refuseChangeRef(changeId);
72
+ if (refused !== null) return refused;
73
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
74
+ if (!isDir(changeDir)) {
75
+ console.error(c.red("error:") + ` change "${changeId}" not found at .doctrina/changes/${changeId}`);
76
+ return EXIT.USAGE;
77
+ }
78
+ changeCaps = new Set(deltaCapabilities(changeDir));
79
+ }
80
+
63
81
  const { all, sourceFiles } = reviewScope(projectRoot, against);
64
- console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
82
+ console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes") +
83
+ (changeId ? c.gray(` · change ${changeId}`) : ""));
65
84
  console.log("");
66
85
 
67
86
  if (all.length === 0) {
@@ -85,10 +104,18 @@ export async function run(_positional, flags) {
85
104
  );
86
105
  const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
87
106
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
88
- for (const m of ranked) {
89
- if (!specChanged.has(m.id)) {
90
- breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
107
+ const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
108
+ if (changeCaps === null) {
109
+ for (const id of stale) {
110
+ breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
91
111
  }
112
+ } else if (stale.length > 0) {
113
+ // Reviewed as a change: code under capabilities it carries no delta for
114
+ // is either this change forgetting a delta or other work lying in the
115
+ // tree — the review cannot tell which, so it says both, ONCE. One break
116
+ // per capability, repeated on every close, is how the warning became
117
+ // noise nobody read (field review, 0.17).
118
+ breaks.push(`code changed under ${stale.length === 1 ? "a capability" : `${stale.length} capabilities`} this change carries no delta for: ${stale.map((id) => `"${id}"`).join(", ")} — if this change made those edits, add a delta (or note why the spec still holds); if other work did, it is not this change's (commit or stash it)`);
92
119
  }
93
120
 
94
121
  // 1b. Dependents of touched capabilities (the machine-readable
@@ -107,7 +134,10 @@ export async function run(_positional, flags) {
107
134
  if (touched.size > 0) {
108
135
  const since = windowCutoff(CHURN_WINDOW_DAYS);
109
136
  const churn = churnByCapability(readLedger(projectRoot).entries, { since })
110
- .filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
137
+ // Reviewed as a change, only the capabilities it updates: churn is
138
+ // context for the spec being changed, and four lines of it about
139
+ // ground the change merely stands near was more noise per close.
140
+ .filter((row) => (changeCaps ?? touched).has(row.capability) && row.changes >= CHURN_NOTABLE);
111
141
  for (const row of churn) {
112
142
  notes.push(`capability "${row.capability}" landed ${row.changes} changes in the last ${CHURN_WINDOW_DAYS} days (last ${row.last}) — history, not a verdict: read it as "this area is moving", not "this area is wrong"`);
113
143
  }
@@ -219,7 +249,7 @@ function runContractCheck(projectRoot) {
219
249
  }
220
250
 
221
251
  export const help = `
222
- Usage: doctrina review [--diff <ref>] [--strict]
252
+ Usage: doctrina review [--diff <ref>] [--change <id>] [--strict]
223
253
 
224
254
  Deterministic conformance review of your changes against the spec / ADR /
225
255
  contract tree. By default it reviews the working-tree changes (tracked +
@@ -232,6 +262,15 @@ citing missing proof, product intent realized by no spec, and contract
232
262
  port/reference collisions. It checks conformance shape, NOT semantic
233
263
  fidelity (whether the code does what the spec says stays a human/LLM call).
234
264
 
265
+ --change <id> reviews the tree as that change's close does: the capabilities
266
+ its deltas target count as updated (the close merges them one step later),
267
+ and code under capabilities it carries no delta for is reported in one line —
268
+ this change forgot a delta, or other work is lying in the tree.
269
+
270
+ A file's owner is the spec whose \`**Source:**\` claims it; failing that, the
271
+ spec citing its exact path; failing both, the path and basename inference,
272
+ from specs that declare no Source.
273
+
235
274
  Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
236
275
  exists (CI gate). Notes (advisory) never fail the gate.
237
276
  `;
@@ -1,7 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
4
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
6
5
  import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
7
6
  import * as idx from "../lib/index-json.js";
@@ -9,10 +8,10 @@ import { today } from "../lib/dates.js";
9
8
  import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
10
9
  import { c } from "../lib/colors.js";
11
10
  import { suggest } from "../lib/suggest.js";
11
+ import { skillCandidates } from "../lib/skill-candidates.js";
12
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { parseFrontmatter } from "../lib/doc-model.js";
14
- import { git, GIT_STATE } from "../lib/git.js";
15
- import { FIX_SHAPED, FIX_SHAPED_SUBJECT, fold, skillSlug } from "../lib/lexicon.js";
14
+ import { fold, skillSlug } from "../lib/lexicon.js";
16
15
  import { artifactNameError } from "../lib/names.js";
17
16
 
18
17
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
@@ -237,57 +236,8 @@ function skillSuggest(args, flags) {
237
236
  return writeErrorDraft(projectRoot, draft, doWrite);
238
237
  }
239
238
 
239
+ const candidates = skillCandidates(projectRoot, { since, limit: GIT_LOG_LIMIT });
240
240
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
241
- const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
242
- const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
243
- // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
244
- // review item 11: the human names the skill after the lesson, not after the
245
- // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
246
- // dedup signals: an existing skill whose body cites the candidate's change
247
- // id / commit hash, and token-overlap similarity between the slugs.
248
- const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
249
- const captured = (cand) => {
250
- if (existing.has(cand.slug)) return true;
251
- for (const sk of existingBodies) {
252
- if (cand.id && sk.body.includes(cand.id)) return true;
253
- if (cand.from && sk.body.includes(cand.from)) return true;
254
- if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
255
- }
256
- return false;
257
- };
258
-
259
- // Two deterministic sources, deduped by slug. `seen` guards against a lesson
260
- // appearing twice (e.g. a fix committed and later archived as a change).
261
- /** @type {SkillCandidate[]} */
262
- const candidates = [];
263
- const seen = new Set();
264
-
265
- // Source 1 — archived change proposals with a fix-shaped folder name.
266
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
267
- if (isDir(archiveDir)) {
268
- for (const name of readdirSync(archiveDir).sort()) {
269
- if (!isDir(path.join(archiveDir, name))) continue;
270
- const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
271
- if (!FIX_SHAPED.test(id)) continue;
272
- const slug = skillSlug(id);
273
- if (seen.has(slug)) continue;
274
- /** @type {SkillCandidate} */
275
- const cand = { id, slug, source: "change", from: name };
276
- if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
277
- const proposal = path.join(archiveDir, name, "proposal.md");
278
- cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
279
- seen.add(slug);
280
- candidates.push(cand);
281
- }
282
- }
283
-
284
- // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
285
- // slug collision: a `## Why` paragraph is a richer seed than a subject line.
286
- for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
287
- if (seen.has(cand.slug) || captured(cand)) continue;
288
- seen.add(cand.slug);
289
- candidates.push(cand);
290
- }
291
241
 
292
242
  if (candidates.length === 0) {
293
243
  console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
@@ -361,80 +311,6 @@ function seedLabel(cand) {
361
311
  : `Candidate from change "${cand.id}"`;
362
312
  }
363
313
 
364
- // Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
365
- // 0013). Deterministic — a regex on the subject, never a read of the diff
366
- // (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
367
- // git repo, git is absent, or the --since ref is unknown, so the command
368
- // degrades to archive-only rather than erroring.
369
- function gitFixCommits(projectRoot, { since, limit }) {
370
- // US (0x1f) between fields, RS (0x1e) between records — robust against any
371
- // newline or punctuation inside a subject or body.
372
- const format = "%H%x1f%s%x1f%b%x1e";
373
- const args = ["log", "--no-merges", `--format=${format}`];
374
- if (since) args.push(`${since}..HEAD`);
375
- else args.push("-n", String(limit));
376
-
377
- const r = git(projectRoot, args);
378
- if (r.state !== GIT_STATE.OK || !r.stdout) return [];
379
-
380
- /** @type {SkillCandidate[]} */
381
- const out = [];
382
- const seen = new Set();
383
- for (const record of r.stdout.split("\x1e")) {
384
- const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
385
- if (!sha || !sha.trim()) continue;
386
- if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
387
- const slug = commitSlug(subject);
388
- if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
389
- seen.add(slug);
390
- const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
391
- out.push({
392
- id: slug,
393
- slug,
394
- source: "commit",
395
- ref: sha.trim().slice(0, 9),
396
- why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
397
- });
398
- }
399
- return out;
400
- }
401
-
402
- // Derive a skill slug from a commit subject: drop a conventional-commit
403
- // "type(scope): " prefix and hand the rest to the shared slug, which folds.
404
- function commitSlug(subject) {
405
- return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
406
- }
407
-
408
- // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
409
- // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
410
- // error-boundary" share tela/preta/render → high overlap even though the
411
- // human renamed the skill after the lesson. Deterministic; never semantic.
412
- function slugSimilarity(a, b) {
413
- const toks = (s) => new Set(
414
- String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
415
- );
416
- const A = toks(a);
417
- const B = toks(b);
418
- if (A.size === 0 || B.size === 0) return 0;
419
- let inter = 0;
420
- for (const t of A) if (B.has(t)) inter += 1;
421
- return inter / (A.size + B.size - inter);
422
- }
423
-
424
- // Fix-shaped verbs and connectives carry no lesson identity — without this,
425
- // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
426
- const SLUG_NOISE = new Set([
427
- "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
428
- "que", "nao", "por", "para", "una", "uma", "dos", "das",
429
- ]);
430
-
431
- function firstWhyLine(proposalText) {
432
- const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
433
- if (!m) return "";
434
- const line = m[1].trim();
435
- return /^<!--/.test(line) ? "" : line;
436
- }
437
-
438
314
  function skillNew(args, flags) {
439
315
  const name = args[0];
440
316
  const nameError = artifactNameError(name, "skill name");
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
12
13
  import { collectFindings } from "../lib/templates-model.js";
13
14
  import * as templates from "./templates.js";
14
15
  import * as validate from "./validate.js";
@@ -74,6 +75,11 @@ export async function run(_positional, flags) {
74
75
  console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
75
76
  }
76
77
 
78
+ // The skill exports (change 0212) follow the index: regenerated by the
79
+ // write, reported by the preview.
80
+ const exportPlan = planSkillExport(projectRoot);
81
+ const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
82
+
77
83
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
78
84
  console.log("");
79
85
  console.log(c.gray("──── 2/3 index + framework stamp"));
@@ -81,6 +87,15 @@ export async function run(_positional, flags) {
81
87
  const current = idx.load(projectRoot);
82
88
  const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
83
89
  const staleStamp = (current.framework_version ?? null) !== running;
90
+ if (exportChanges > 0) {
91
+ if (writeMode) {
92
+ applySkillExport(projectRoot, exportPlan);
93
+ console.log(c.green("export ") + ` ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
94
+ } else {
95
+ console.log(c.yellow("would ") + `export ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
96
+ pending += 1;
97
+ }
98
+ }
84
99
  if (!drifted && !staleStamp) {
85
100
  console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
86
101
  } else if (writeMode) {
@@ -8,9 +8,10 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
8
8
  import { flagBool, flagString } from "../lib/args.js";
9
9
  import { today } from "../lib/dates.js";
10
10
  import { c } from "../lib/colors.js";
11
- import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
11
+ import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL, declaredPaths, workingChanges } from "../lib/signoff.js";
12
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { collectReproducibility } from "../lib/reproducibility.js";
14
+ import { verifyFingerprint, recordGreen, reusableGreen } from "../lib/verify-cache.js";
14
15
 
15
16
  // The build/verify gate `validate` is not. `validate` checks the shape of
16
17
  // the artifact tree; it never runs the project, so a repo that does not
@@ -40,7 +41,7 @@ const STARTER = {
40
41
  // Flags this command accepts. Declared HERE, with the command, so
41
42
  // adding a command never requires editing the entrypoint — the gap that
42
43
  // let six flags ship undeclared and silently swallow a positional (C3).
43
- export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
44
+ export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict", "reuse", "affected"], string: ["signoff"] };
44
45
 
45
46
  export async function run(_positional, flags) {
46
47
  const projectRoot = process.cwd();
@@ -182,6 +183,22 @@ export async function run(_positional, flags) {
182
183
  }
183
184
 
184
185
  const strict = flagBool(flags, "strict", false);
186
+ const affected = flagBool(flags, "affected", false);
187
+ // The fingerprint of the code these checks are about to run on, taken
188
+ // BEFORE they run (change 0203): what a green run proves is the tree it
189
+ // started from. Null outside git — then nothing is remembered or reused.
190
+ const fingerprint = verifyFingerprint(projectRoot, read(configPath));
191
+ if (flagBool(flags, "reuse", false) && fingerprint !== null) {
192
+ const green = reusableGreen(projectRoot, fingerprint);
193
+ if (green) {
194
+ console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
195
+ console.log("");
196
+ console.log(c.green("ok") + ` reused the green run of ${green.at.replace("T", " ").slice(0, 16)} UTC — ` +
197
+ `the code and ${CONFIG_REL} are byte-identical to what it ran on (${green.checks.join(", ")})`);
198
+ console.log(c.gray(" `doctrina verify` runs the checks again"));
199
+ return 0;
200
+ }
201
+ }
185
202
  console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
186
203
  const results = [];
187
204
  for (const ch of checks) {
@@ -220,6 +237,20 @@ export async function run(_positional, flags) {
220
237
  continue;
221
238
  }
222
239
 
240
+ // --affected (change 0204): a check that declares the "paths" it covers
241
+ // runs only when the uncommitted work touches one of them. The inner
242
+ // loop of a change — not the close, which always runs every check. A
243
+ // check that declares no paths covers everything and always runs; a
244
+ // question git cannot answer runs the check.
245
+ if (affected && declaredPaths(ch).length > 0) {
246
+ const touched = workingChanges(projectRoot, declaredPaths(ch));
247
+ if (touched !== null && touched.length === 0) {
248
+ console.log(c.gray(`──── ${name}: `) + c.gray(`skip — the uncommitted work touches none of ${declaredPaths(ch).join(", ")}`));
249
+ results.push({ name, ok: true, kind: "command", skipped: true });
250
+ continue;
251
+ }
252
+ }
253
+
223
254
  const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
224
255
  console.log(c.gray(`──── ${name}: `) + ch.run + where);
225
256
  // Run through the shell so declared commands can use pipes, &&, env,
@@ -287,9 +318,18 @@ export async function run(_positional, flags) {
287
318
  // manual check still awaiting sign-off. Pending manual checks are otherwise
288
319
  // a non-blocking report.
289
320
  const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
290
- const pendNote = pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "";
321
+ const skipped = results.filter((r) => r.skipped);
322
+ const pendNote = (pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "") +
323
+ (skipped.length > 0 ? c.gray(` (${skipped.length} skipped by --affected: ${skipped.map((r) => r.name).join(", ")})`) : "");
291
324
  if (!failGate) {
292
- console.log(c.green("ok") + ` ${passed}/${results.length} checks passed${pendNote}`);
325
+ const ran = passed - skipped.length;
326
+ console.log(c.green("ok") + ` ${ran}/${results.length - skipped.length} checks passed${pendNote}`);
327
+ // Remembered only when EVERY check passed — a run green with a manual
328
+ // check still pending is a report, not something a close may stand on,
329
+ // and neither is a run --affected cut short.
330
+ if (fingerprint !== null && pending.length === 0 && skipped.length === 0) {
331
+ recordGreen(projectRoot, fingerprint, results.map((r) => r.name));
332
+ }
293
333
  return 0;
294
334
  }
295
335
  const reasons = [];
@@ -403,7 +443,7 @@ function renderReproducibility(projectRoot) {
403
443
  }
404
444
 
405
445
  export const help = `
406
- Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
446
+ Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--reuse] [--affected] [--signoff "<name>=<note>"] [--force]
407
447
 
408
448
  Run the project-declared build/verify checks from ${CONFIG_REL} in order,
409
449
  streaming their output, and exit non-zero if any check fails. This is the
@@ -414,13 +454,18 @@ root, targets a sub-package in a monorepo; a "manual" check is the
414
454
  qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
415
455
  {
416
456
  "checks": [
417
- { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
457
+ { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api",
458
+ "paths": ["packages/api"] },
418
459
  { "name": "test", "run": "npm test" },
419
460
  { "name": "build", "run": "npm run build" },
420
461
  { "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
421
462
  ]
422
463
  }
423
464
 
465
+ "paths" (git pathspecs, relative to the project root) says what a check
466
+ covers: --affected runs it only when the uncommitted work touches one, and a
467
+ manual check's sign-off expires when one changes.
468
+
424
469
  A manual check passes when it has been signed off and is otherwise reported
425
470
  as "pending" — non-blocking by default, failing only under --strict (so CI
426
471
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
@@ -450,6 +495,14 @@ Flags:
450
495
  prepare/prepack; a Prisma dep with no generate on install) so
451
496
  "verify green" can't hide a clean checkout that won't build.
452
497
  --strict Fail the gate when a manual check is still pending sign-off
498
+ --reuse Skip the checks when the last fully green run covered this exact
499
+ code: the working tree (tracked, untracked, edits) minus
500
+ .doctrina/, plus ${CONFIG_REL}, are byte-identical. \`close\` runs
501
+ verify this way; a plain \`doctrina verify\` always runs them.
502
+ --affected Run a check that declares "paths" only when the uncommitted work
503
+ (edits against HEAD, untracked files) touches one of them —
504
+ the inner loop of a change. Checks with no "paths" always run;
505
+ \`close\` always runs every check. Never remembered by --reuse.
453
506
  --signoff "<name>=<note>"
454
507
  Record today's sign-off for a manual check, then exit
455
508
  --force With --init, overwrite an existing config