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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -0
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +4 -1
package/src/commands/prime.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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>\`.
|
package/src/commands/review.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
`;
|
package/src/commands/skill.js
CHANGED
|
@@ -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 {
|
|
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");
|
package/src/commands/upgrade.js
CHANGED
|
@@ -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) {
|
package/src/commands/verify.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|