doctrina-cli 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { slugify } from "./dates.js";
|
|
3
|
+
|
|
4
|
+
// ONE lexicon (audit findings F5/F6).
|
|
5
|
+
//
|
|
6
|
+
// `work` ranked a prompt against the spec tree with one stop list, and
|
|
7
|
+
// `context --for` ranked the same prompt with a different one — while the
|
|
8
|
+
// work playbook tells the agent to run `context` immediately after `work`.
|
|
9
|
+
// Two rankers, in sequence, on the same text, free to disagree about which
|
|
10
|
+
// capability it is about. And `FIX_SHAPED` appeared byte for byte in two
|
|
11
|
+
// modules, so a term added to one classifier silently did not reach the
|
|
12
|
+
// other.
|
|
13
|
+
//
|
|
14
|
+
// What follows is the whole vocabulary the CLI uses to read natural
|
|
15
|
+
// language: how text is folded, what counts as a content word, and how
|
|
16
|
+
// strongly a document answers a query. Every consumer reads it from here.
|
|
17
|
+
//
|
|
18
|
+
// It is deliberately small and deterministic. The CLI does no natural-
|
|
19
|
+
// language interpretation (ADR 0005) — these functions rank, they never
|
|
20
|
+
// decide, and the playbook says so at the point the ranking is shown.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fold to comparable ASCII-ish lowercase: strip combining marks so a
|
|
24
|
+
* Portuguese prompt and an ASCII spec match on the same word ("função" and
|
|
25
|
+
* "funcao"), and lowercase so case never decides relevance.
|
|
26
|
+
*/
|
|
27
|
+
export function fold(text) {
|
|
28
|
+
return String(text ?? "").normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The connective tissue of a task description, in English and Portuguese.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately conservative: it covers grammar and the verbs every prompt
|
|
35
|
+
* uses ("add", "create", "implementar"), and nothing domain-specific — a
|
|
36
|
+
* stop list that swallowed a real term would quietly make retrieval worse
|
|
37
|
+
* in exactly the cases it is supposed to help.
|
|
38
|
+
*/
|
|
39
|
+
export const STOPWORDS = new Set([
|
|
40
|
+
// en — grammar
|
|
41
|
+
"a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for",
|
|
42
|
+
"from", "has", "have", "how", "i", "if", "in", "into", "is", "it", "its",
|
|
43
|
+
"of", "on", "or", "should", "that", "the", "then", "this", "to", "up",
|
|
44
|
+
"was", "we", "what", "when", "where", "which", "why", "will", "with",
|
|
45
|
+
"shall", "must",
|
|
46
|
+
// en — the verbs and nouns every feature prompt carries
|
|
47
|
+
"make", "add", "new", "use", "create", "implement", "feature", "system",
|
|
48
|
+
"user", "users",
|
|
49
|
+
// pt (ASCII-folded) — grammar
|
|
50
|
+
"uma", "umas", "uns", "dos", "das", "nos", "nas", "por", "para", "com",
|
|
51
|
+
"que", "sem", "aos", "nao", "mais", "ser", "quando", "esta", "sao",
|
|
52
|
+
"pela", "pelo", "deve", "de", "do", "da", "no", "na", "em", "ao", "os",
|
|
53
|
+
"as", "um", "ou", "se", "eh", "pra",
|
|
54
|
+
// pt — the same verbs and nouns
|
|
55
|
+
"faca", "fazer", "criar", "crie", "novo", "nova", "adicionar", "adicione",
|
|
56
|
+
"implementar", "implemente", "funcionalidade", "sistema", "usuario",
|
|
57
|
+
"usuarios",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The content words of a query: folded, de-duplicated, stop words dropped.
|
|
62
|
+
*
|
|
63
|
+
* A term must be at least two characters, so an initial or a stray letter
|
|
64
|
+
* never becomes a search term — but "id", "ci" and "pt" survive, and those
|
|
65
|
+
* are real terms in this domain.
|
|
66
|
+
*/
|
|
67
|
+
export function terms(query) {
|
|
68
|
+
if (query === undefined || query === null) return [];
|
|
69
|
+
const words = fold(query).match(/[a-z][a-z0-9-]+/g) ?? [];
|
|
70
|
+
return [...new Set(words.filter((w) => !STOPWORDS.has(w)))];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How strongly a document answers the query, as a comparable TUPLE rather
|
|
75
|
+
* than one blended number — so the tiebreak order is readable and no
|
|
76
|
+
* weighting constant has to be guessed:
|
|
77
|
+
*
|
|
78
|
+
* [ terms in the title/id, terms in the body, hits per 1000 chars ]
|
|
79
|
+
*
|
|
80
|
+
* Density, not raw hit count, breaks the final tie. Raw hits reward a
|
|
81
|
+
* document for being long: the 473-line `cli` spec out-scored `skills` on
|
|
82
|
+
* the query "write a skill from git history" purely on volume, which is the
|
|
83
|
+
* length bias that makes naive retrieval useless on a mature tree.
|
|
84
|
+
*/
|
|
85
|
+
export function relevance(text, queryTerms, emphasis = "") {
|
|
86
|
+
if (queryTerms.length === 0) return [0, 0, 0];
|
|
87
|
+
const hay = fold(text);
|
|
88
|
+
const head = fold(emphasis);
|
|
89
|
+
let inTitle = 0;
|
|
90
|
+
let inBody = 0;
|
|
91
|
+
let hits = 0;
|
|
92
|
+
for (const term of queryTerms) {
|
|
93
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
const boundary = `(^|[^a-z0-9-])${escaped}`;
|
|
95
|
+
const n = (hay.match(new RegExp(boundary, "g")) ?? []).length;
|
|
96
|
+
if (n > 0) {
|
|
97
|
+
inBody += 1;
|
|
98
|
+
hits += n;
|
|
99
|
+
}
|
|
100
|
+
if (head && new RegExp(boundary).test(head)) inTitle += 1;
|
|
101
|
+
}
|
|
102
|
+
const density = hay.length > 0 ? Math.round((hits / hay.length) * 1000) : 0;
|
|
103
|
+
return [inTitle, inBody, density];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The same relevance, projected onto ONE comparable number.
|
|
108
|
+
*
|
|
109
|
+
* `work` shows a score in a column and `context` orders by the tuple; both
|
|
110
|
+
* must agree about which capability a prompt is about, so the scalar is a
|
|
111
|
+
* PROJECTION of the tuple rather than a second calculation. The weights only
|
|
112
|
+
* have to preserve the tuple's own ordering — title beats body beats
|
|
113
|
+
* density — which is what the lexicographic comparison already says.
|
|
114
|
+
*/
|
|
115
|
+
export function score(text, queryTerms, emphasis = "") {
|
|
116
|
+
const [inTitle, inBody, density] = relevance(text, queryTerms, emphasis);
|
|
117
|
+
return inTitle * 100 + inBody * 10 + Math.min(density, 9);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A slug or identifier that reads as a FIX rather than a feature — the
|
|
122
|
+
* signal `skill suggest` and `next` both look for when deciding whether an
|
|
123
|
+
* archived change taught a lesson worth capturing.
|
|
124
|
+
*
|
|
125
|
+
* One definition: it lived byte for byte in two modules, so a term added to
|
|
126
|
+
* one classifier did not reach the other.
|
|
127
|
+
*/
|
|
128
|
+
export const FIX_SHAPED =
|
|
129
|
+
/(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The same idea applied to a commit subject. Narrower than FIX_SHAPED on
|
|
133
|
+
* purpose: a subject is prose, so it needs the conventional-commit shape or
|
|
134
|
+
* an explicit fix verb rather than a bare word match.
|
|
135
|
+
*/
|
|
136
|
+
export const FIX_SHAPED_SUBJECT =
|
|
137
|
+
/^(fix|bugfix|hotfix|patch)(\(|:|!)|^(revert)\b|\b(fixes|fixed|regression|workaround)\b/i;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Which language a document is written in, by counting each lexicon's
|
|
141
|
+
* grammar words. Used by `clarify` to pick its smell lexicon when the
|
|
142
|
+
* project declares no language.
|
|
143
|
+
*/
|
|
144
|
+
const PT_GRAMMAR = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
|
|
145
|
+
const EN_GRAMMAR = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
|
|
146
|
+
|
|
147
|
+
export function detectLanguage(text) {
|
|
148
|
+
const pt = (String(text).match(PT_GRAMMAR) ?? []).length;
|
|
149
|
+
const en = (String(text).match(EN_GRAMMAR) ?? []).length;
|
|
150
|
+
return pt > en ? "pt" : "en";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The margin a ranked winner must hold over the runner-up before a caller
|
|
155
|
+
* may ACT on the ranking rather than merely display it.
|
|
156
|
+
*
|
|
157
|
+
* `triage` calls a verdict confident when the winning lane beats the
|
|
158
|
+
* runner-up by 2. Copying that number here would copy a unit, not the
|
|
159
|
+
* notion: on `score()` above, every signal is worth 10 or 100 and the
|
|
160
|
+
* density tie-breaker alone moves the number by up to 9. A gap of 2 can
|
|
161
|
+
* therefore mean the two specs matched exactly the same terms and one of
|
|
162
|
+
* them is shorter — the opposite of confidence.
|
|
163
|
+
*
|
|
164
|
+
* 10 is the smallest gap density CANNOT produce: it is one whole body
|
|
165
|
+
* term. Density is capped at 9 precisely so it can only break ties, so a
|
|
166
|
+
* margin at or above 10 says the winner led on term overlap itself.
|
|
167
|
+
*/
|
|
168
|
+
export const CONFIDENT_MARGIN = 10;
|
|
169
|
+
|
|
170
|
+
// The SLUG_WORDS content words of a prompt, slugified — the short half of a
|
|
171
|
+
// change id (change 0070).
|
|
172
|
+
//
|
|
173
|
+
// The id used to be the whole prompt slugified, and the proposal's H1 is
|
|
174
|
+
// `# Change <id> — <title>` with the title defaulting to that same prompt: so
|
|
175
|
+
// a change opened on the default path said the same sentence twice, and every
|
|
176
|
+
// read surface printed both halves. `prime` spent 133 characters of one line
|
|
177
|
+
// on it — the command whose entire value is density.
|
|
178
|
+
//
|
|
179
|
+
// The id is what a person types and what sorts a backlog, so it wants to be
|
|
180
|
+
// short; the title is what a person reads, so it wants to be whole. Capping
|
|
181
|
+
// the SLUG rather than truncating the title gives both, and is exactly what
|
|
182
|
+
// `--title` did by hand. Deterministic: a shipped stopword list, first N
|
|
183
|
+
// content words, no interpretation (ADR 0005).
|
|
184
|
+
export const SLUG_WORDS = 4;
|
|
185
|
+
|
|
186
|
+
// A number is content in an id (change 0191). `terms` serves retrieval, where
|
|
187
|
+
// a bare "24" matches everything, so it keeps only words that start with a
|
|
188
|
+
// letter — and the slug inherited that: "cortar a 0.17.0" became `cortar`
|
|
189
|
+
// and "migrar para Node 24" `migrar-node`. The slug reads its own tokens: a
|
|
190
|
+
// version or a number is one word, its dots folded into hyphens.
|
|
191
|
+
function slugTerms(prompt) {
|
|
192
|
+
const words = (fold(String(prompt ?? "")).match(/[a-z0-9][a-z0-9.-]*/g) ?? [])
|
|
193
|
+
.map((w) => w.replace(/[.-]+$/g, "").replace(/\./g, "-"))
|
|
194
|
+
.filter((w) => (w.length > 1 || /^\d$/.test(w)) && !STOPWORDS.has(w));
|
|
195
|
+
return [...new Set(words)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function slugFromPrompt(prompt, { words = SLUG_WORDS } = {}) {
|
|
199
|
+
const content = slugTerms(prompt).slice(0, words);
|
|
200
|
+
const slug = slugify(content.join(" "));
|
|
201
|
+
// A prompt of nothing but stopwords ("do it now") still needs an id.
|
|
202
|
+
return slug || slugify(prompt).split("-").slice(0, words).join("-");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A SKILL SLUG IS FOLDED, NOT SHREDDED.
|
|
206
|
+
//
|
|
207
|
+
// `skill suggest` names a lesson from text a person wrote — a change id, a
|
|
208
|
+
// commit subject, the seed tokens of an error. That name became a filename,
|
|
209
|
+
// and it was built by stripping everything outside `[a-z0-9-]` from the raw
|
|
210
|
+
// text. On Portuguese input the strip is destructive rather than normalising:
|
|
211
|
+
// every capital is DELETED and every accented letter becomes a hyphen, so
|
|
212
|
+
// "Corrigir A Validação" came out "orrigir-alida-o" and a real run wrote
|
|
213
|
+
// `.doctrina/skills/o-modelo-de-documento-n-o-l-cabe-alho-de.md`.
|
|
214
|
+
//
|
|
215
|
+
// The tell was already in this module: SLUG_NOISE lists "nao", a token the
|
|
216
|
+
// old pipeline could never produce. So the slug goes through `slugify`, the
|
|
217
|
+
// same fold every other slug in the tree uses.
|
|
218
|
+
//
|
|
219
|
+
// The leading numeric run goes with it. The source is often a change id
|
|
220
|
+
// ("0049-fix-tela-preta"), the digits identify nothing about the lesson, and
|
|
221
|
+
// the skills spec requires a filename slug to start with a letter.
|
|
222
|
+
export const SKILL_SLUG_MAX = 40;
|
|
223
|
+
|
|
224
|
+
export function skillSlug(text, { max = SKILL_SLUG_MAX } = {}) {
|
|
225
|
+
const slug = slugify(text).replace(/^\d+(?:-|$)/, "");
|
|
226
|
+
const capped = slug.length > max ? slug.slice(0, max).replace(/-+$/g, "") : slug;
|
|
227
|
+
return capped || "lesson";
|
|
228
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The adoption metrics themselves: what a snapshot IS, computed once.
|
|
3
|
+
//
|
|
4
|
+
// `metrics` renders a snapshot and saves it; `report` shows two of its rates
|
|
5
|
+
// for the reporting period. Before this they would each have had their own
|
|
6
|
+
// idea of "revert rate", which is the kind of divergence nobody notices until
|
|
7
|
+
// two numbers on two screens disagree and neither is wrong (ADR 0025).
|
|
8
|
+
//
|
|
9
|
+
// Everything here is derived from local git history. No network calls, no
|
|
10
|
+
// telemetry: the numbers stay in the repository, versioned like any other
|
|
11
|
+
// artifact. This is the tooling half of docs/en/validation.md.
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { readdirSync } from "node:fs";
|
|
14
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
15
|
+
import { today } from "./dates.js";
|
|
16
|
+
import { GIT_STATE, git } from "./git.js";
|
|
17
|
+
|
|
18
|
+
export const REEDIT_WINDOW_DAYS = 21;
|
|
19
|
+
export const METRICS_REL = ".doctrina/metrics";
|
|
20
|
+
|
|
21
|
+
export function computeSnapshot(commits, since) {
|
|
22
|
+
const total = commits.length;
|
|
23
|
+
const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
|
|
24
|
+
const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
|
|
25
|
+
|
|
26
|
+
// File churn: commits per file, top offenders first.
|
|
27
|
+
const churn = new Map();
|
|
28
|
+
for (const cm of commits) {
|
|
29
|
+
for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
const topChurn = [...churn.entries()]
|
|
32
|
+
.sort((a, b) => b[1] - a[1])
|
|
33
|
+
.slice(0, 5)
|
|
34
|
+
.map(([file, count]) => ({ file, commits: count }));
|
|
35
|
+
|
|
36
|
+
// Re-edit proxy: a commit counts when it touches a file another commit
|
|
37
|
+
// already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
|
|
38
|
+
// for rework, not a verdict — iterative work also trips it.
|
|
39
|
+
const windowSec = REEDIT_WINDOW_DAYS * 86400;
|
|
40
|
+
const lastTouch = new Map();
|
|
41
|
+
let reedits = 0;
|
|
42
|
+
for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
|
|
43
|
+
const isReedit = cm.files.some((f) => {
|
|
44
|
+
const prev = lastTouch.get(f);
|
|
45
|
+
return prev !== undefined && cm.ts - prev <= windowSec;
|
|
46
|
+
});
|
|
47
|
+
if (isReedit) reedits += 1;
|
|
48
|
+
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
generated: today(),
|
|
53
|
+
window: since,
|
|
54
|
+
commits: total,
|
|
55
|
+
reverts,
|
|
56
|
+
revert_rate: round(reverts / total),
|
|
57
|
+
fix_share: round(fixes / total),
|
|
58
|
+
reedit_window_days: REEDIT_WINDOW_DAYS,
|
|
59
|
+
reedit_rate: round(reedits / total),
|
|
60
|
+
top_churn: topChurn,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
export function parseLog(stdout) {
|
|
66
|
+
const commits = [];
|
|
67
|
+
let current = null;
|
|
68
|
+
for (const line of stdout.split("\n")) {
|
|
69
|
+
if (line.startsWith("@@")) {
|
|
70
|
+
const [hash, ts, ...rest] = line.slice(2).split("|");
|
|
71
|
+
current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
|
|
72
|
+
commits.push(current);
|
|
73
|
+
} else if (line.trim() !== "" && current) {
|
|
74
|
+
current.files.push(line.trim());
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return commits;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
export function round(x) {
|
|
82
|
+
return Math.round(x * 1000) / 1000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
const WINDOW_UNIT = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/i;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The git `--since` value for a `--since` flag, or null when the flag is not
|
|
90
|
+
* a window this can check.
|
|
91
|
+
*
|
|
92
|
+
* git never refuses a date: "abc" parses as NOW (an empty window, reported
|
|
93
|
+
* as "nothing to measure") and "2026-13-45" as some other day. Handing it
|
|
94
|
+
* free text turned a typo into a confident, wrong answer, so only the forms
|
|
95
|
+
* whose meaning is certain pass: a day count, a calendar date
|
|
96
|
+
* (YYYY-MM-DD), or "<n> <unit>s ago".
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw
|
|
99
|
+
* @returns {string | null}
|
|
100
|
+
*/
|
|
101
|
+
export function sinceWindow(raw) {
|
|
102
|
+
const s = String(raw ?? "").trim();
|
|
103
|
+
if (/^\d+$/.test(s)) return Number(s) > 0 ? `${Number(s)} days ago` : null;
|
|
104
|
+
const date = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
105
|
+
if (date) {
|
|
106
|
+
const [y, m, d] = date.slice(1).map(Number);
|
|
107
|
+
const t = new Date(Date.UTC(y, m - 1, d));
|
|
108
|
+
const real = t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
|
|
109
|
+
return real ? s : null;
|
|
110
|
+
}
|
|
111
|
+
const rel = s.match(WINDOW_UNIT);
|
|
112
|
+
if (rel) return Number(rel[1]) > 0 ? s.replace(/\s+/g, " ") : null;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A snapshot for a window, or null when there is no history to read.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} projectRoot
|
|
120
|
+
* @param {string} since a git --since value ("90 days ago", "2026-01-01")
|
|
121
|
+
*/
|
|
122
|
+
export function collectMetrics(projectRoot, since) {
|
|
123
|
+
const log = git(projectRoot, [
|
|
124
|
+
"log", `--since=${since}`, "--date=unix",
|
|
125
|
+
"--pretty=format:@@%H|%ct|%s", "--name-only",
|
|
126
|
+
]);
|
|
127
|
+
if (log.state !== GIT_STATE.OK) return null;
|
|
128
|
+
const commits = parseLog(log.stdout);
|
|
129
|
+
if (commits.length === 0) return null;
|
|
130
|
+
return computeSnapshot(commits, since);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every saved snapshot, oldest first.
|
|
135
|
+
*
|
|
136
|
+
* The snapshots are a TIME SERIES, versioned in the repository — and for two
|
|
137
|
+
* releases the only thing that ever read them was a delta against the most
|
|
138
|
+
* recent one, so a year of measurement answered exactly one question: "is it
|
|
139
|
+
* better than last time?" (audit finding F15). Reading the series is what
|
|
140
|
+
* makes the saving worth doing.
|
|
141
|
+
*/
|
|
142
|
+
export function readSeries(projectRoot) {
|
|
143
|
+
const dir = path.join(projectRoot, ...METRICS_REL.split("/"));
|
|
144
|
+
if (!isDir(dir)) return [];
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const f of readdirSync(dir).sort()) {
|
|
147
|
+
if (!/^\d{4}-\d{2}-\d{2}\.json$/.test(f)) continue;
|
|
148
|
+
const file = path.join(dir, f);
|
|
149
|
+
if (!isFile(file)) continue;
|
|
150
|
+
try {
|
|
151
|
+
const snap = JSON.parse(read(file));
|
|
152
|
+
// The filename is the authority on when it was taken: `generated` can
|
|
153
|
+
// be absent in a hand-edited file, and a series ordered by a field
|
|
154
|
+
// that may be missing is not ordered at all.
|
|
155
|
+
out.push({ ...snap, generated: snap.generated ?? f.replace(/\.json$/, "") });
|
|
156
|
+
} catch { /* a malformed snapshot is skipped, never fatal */ }
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The most recent saved snapshot, excluding one basename. */
|
|
162
|
+
export function latestSnapshot(projectRoot, excludeBase = null) {
|
|
163
|
+
const series = readSeries(projectRoot).filter((s) => `${s.generated}.json` !== excludeBase);
|
|
164
|
+
return series.length > 0 ? series[series.length - 1] : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The direction each tracked rate moved across the WHOLE series.
|
|
169
|
+
*
|
|
170
|
+
* First to last, not last to previous: a rate that drifted up for six months
|
|
171
|
+
* and dipped once looks like an improvement from the delta and like what it
|
|
172
|
+
* is from the series.
|
|
173
|
+
*
|
|
174
|
+
* @param {ReturnType<typeof readSeries>} series
|
|
175
|
+
*/
|
|
176
|
+
export function trend(series) {
|
|
177
|
+
const rates = ["revert_rate", "fix_share", "reedit_rate"];
|
|
178
|
+
if (series.length < 2) return { span: series.length, rows: [] };
|
|
179
|
+
const first = series[0];
|
|
180
|
+
const last = series[series.length - 1];
|
|
181
|
+
const rows = [];
|
|
182
|
+
for (const rate of rates) {
|
|
183
|
+
const a = first[rate];
|
|
184
|
+
const b = last[rate];
|
|
185
|
+
if (typeof a !== "number" || typeof b !== "number") continue;
|
|
186
|
+
rows.push({ rate, first: a, last: b, delta: round(b - a) });
|
|
187
|
+
}
|
|
188
|
+
return { span: series.length, from: first.generated, to: last.generated, rows };
|
|
189
|
+
}
|
package/src/lib/names.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// One grammar for the NAME of a spec, a contract or a skill (change 0110).
|
|
3
|
+
//
|
|
4
|
+
// Three commands carried their own copy of `/^[a-z][a-z0-9-]*$/`, and none
|
|
5
|
+
// of them knew anything about a file system. Measured on Windows: `spec new
|
|
6
|
+
// nul` created `.doctrina/specs/nul/spec.md` — a directory Node could write
|
|
7
|
+
// and git could neither see nor remove (`could not open directory`, `failed
|
|
8
|
+
// to remove`), gone only through a `\\?\` path. `con`, `prn`, `aux`,
|
|
9
|
+
// `com1`-`com9` and `lpt1`-`lpt9` are device names on Windows, and this
|
|
10
|
+
// repository's own tree is Windows/CRLF. A 120-character name passed too
|
|
11
|
+
// (`Filename too long` from git), as did `trail-` and `a--b`.
|
|
12
|
+
//
|
|
13
|
+
// A name is a directory name on every file system the project will touch.
|
|
14
|
+
// The message names the rule that failed, so the fix is the invocation.
|
|
15
|
+
|
|
16
|
+
const RESERVED = new Set([
|
|
17
|
+
"con", "prn", "aux", "nul",
|
|
18
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
19
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const NAME_MAX = 64;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* null when `name` is a legal artifact name; otherwise the reason, ready to
|
|
26
|
+
* print after "error:".
|
|
27
|
+
*
|
|
28
|
+
* @param {string | undefined} name
|
|
29
|
+
* @param {string} kind "capability" | "contract name" | "skill name"
|
|
30
|
+
* @returns {string | null}
|
|
31
|
+
*/
|
|
32
|
+
export function artifactNameError(name, kind) {
|
|
33
|
+
const value = String(name ?? "");
|
|
34
|
+
if (value === "") return `${kind} is required`;
|
|
35
|
+
if (!/^[a-z0-9-]+$/.test(value)) return `${kind} must be lowercase letters, digits, or hyphens (got "${value}")`;
|
|
36
|
+
if (!/^[a-z]/.test(value)) return `${kind} must start with a letter (got "${value}")`;
|
|
37
|
+
if (/-$/.test(value)) return `${kind} must not end with a hyphen (got "${value}")`;
|
|
38
|
+
if (/--/.test(value)) return `${kind} must not contain a doubled hyphen (got "${value}")`;
|
|
39
|
+
if (value.length > NAME_MAX) return `${kind} must be at most ${NAME_MAX} characters (got ${value.length})`;
|
|
40
|
+
if (RESERVED.has(value)) return `${kind} "${value}" is a reserved device name on Windows — a directory git cannot see or remove there; pick another`;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { c } from "./colors.js";
|
|
3
|
+
import { readTemplate, substitute } from "./templates.js";
|
|
4
|
+
|
|
5
|
+
// Playbooks are TEMPLATES, not code (audit finding F17).
|
|
6
|
+
//
|
|
7
|
+
// `printPlaybook()` was ~100 lines of literal `console.log` — including the
|
|
8
|
+
// documentation of the ops grammar — and the same procedure was written out
|
|
9
|
+
// again in AGENTS.md and twice more in docs/{en,pt}/workflow.md. Four homes
|
|
10
|
+
// for one fact, in the framework whose first design principle is that a fact
|
|
11
|
+
// has exactly one.
|
|
12
|
+
//
|
|
13
|
+
// Worse for adopters: a team whose process differs (an extra review step, a
|
|
14
|
+
// different close) had no way to say so. Every other scaffold resolves
|
|
15
|
+
// project-over-bundled per file (ADR 0019); the procedure the agent actually
|
|
16
|
+
// executes was the one thing hard-coded.
|
|
17
|
+
//
|
|
18
|
+
// So a playbook resolves through the same chain, and this module renders it.
|
|
19
|
+
//
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The markup
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
//
|
|
24
|
+
// Colour has to survive the move, or the output changes and the migration is
|
|
25
|
+
// not faithful. The template marks spans inline:
|
|
26
|
+
//
|
|
27
|
+
// [[c]]doctrina close 0001[[/c]] cyan — a command to run
|
|
28
|
+
// [[g]]...[[/g]] gray — commentary
|
|
29
|
+
// [[b]]...[[/b]] bold — a heading
|
|
30
|
+
// [[y]]...[[/y]] yellow — a warning
|
|
31
|
+
//
|
|
32
|
+
// Markup is expanded BEFORE token substitution, deliberately: a token's value
|
|
33
|
+
// can carry a user's prompt, and a prompt must never be able to inject colour
|
|
34
|
+
// into the playbook. Blocks the CLI computes (a capability ranking, a warning
|
|
35
|
+
// list) arrive already coloured through `c.*`, so they need no markup.
|
|
36
|
+
//
|
|
37
|
+
// Everything variable is a TOKEN, and the code pre-renders it. That answers
|
|
38
|
+
// the open question the change carried — extend the substituter, or
|
|
39
|
+
// pre-render? — in favour of pre-rendering: `substitute` stays a plain
|
|
40
|
+
// string map, and the template stays a document rather than becoming a
|
|
41
|
+
// programming language with conditionals and loops in it.
|
|
42
|
+
|
|
43
|
+
const MARKS = { c: c.cyan, g: c.gray, b: c.bold, y: c.yellow };
|
|
44
|
+
|
|
45
|
+
/** Expand `[[x]]...[[/x]]` colour spans. Innermost first, so spans nest. */
|
|
46
|
+
export function expandMarkup(text) {
|
|
47
|
+
let out = String(text);
|
|
48
|
+
let previous;
|
|
49
|
+
do {
|
|
50
|
+
previous = out;
|
|
51
|
+
out = out.replace(/\[\[([cgby])\]\]((?:(?!\[\[[cgby]\]\])[\s\S])*?)\[\[\/\1\]\]/g,
|
|
52
|
+
(_, mark, body) => MARKS[mark](body));
|
|
53
|
+
} while (out !== previous);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a named playbook, resolved project-over-bundled.
|
|
59
|
+
*
|
|
60
|
+
* Returns the text; the caller prints it. Keeping the write at the edge is
|
|
61
|
+
* what lets a test assert the rendering without capturing stdout.
|
|
62
|
+
*/
|
|
63
|
+
export function renderPlaybook(projectRoot, name, tokens = {}) {
|
|
64
|
+
const tpl = readTemplate(projectRoot, `playbooks/${name}.md.template`);
|
|
65
|
+
// A trailing newline in the file would print an extra blank line; the
|
|
66
|
+
// caller decides its own spacing.
|
|
67
|
+
return substitute(expandMarkup(dropEmptyBlocks(tpl.body, tokens)), tokens).replace(/\n$/, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A line holding NOTHING but one token, whose value is empty or absent, is
|
|
72
|
+
* removed rather than left as a blank line.
|
|
73
|
+
*
|
|
74
|
+
* That single rule is what lets the optional parts of a playbook — the
|
|
75
|
+
* thin-prompt warning, the capability hint, the backfill note — be plain
|
|
76
|
+
* tokens on their own line instead of conditionals in a template language.
|
|
77
|
+
* A block that IS present brings its own spacing in its value.
|
|
78
|
+
*/
|
|
79
|
+
function dropEmptyBlocks(body, tokens) {
|
|
80
|
+
return body
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((line) => {
|
|
83
|
+
const m = line.match(/^\s*\{\{(\w+)\}\}\s*$/);
|
|
84
|
+
if (!m) return true;
|
|
85
|
+
const value = tokens[m[1]];
|
|
86
|
+
return value !== undefined && String(value) !== "";
|
|
87
|
+
})
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Render and print. The one place a playbook reaches stdout. */
|
|
92
|
+
export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
93
|
+
console.log(renderPlaybook(projectRoot, name, tokens));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap"];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exists } from "./fs-ops.js";
|
|
4
|
+
import { EXIT, notADoctrinaProject } from "./exit-codes.js";
|
|
5
|
+
import { c } from "./colors.js";
|
|
6
|
+
import { load } from "./index-json.js";
|
|
7
|
+
|
|
8
|
+
// The one precondition every command shares: this is a Doctrina project.
|
|
9
|
+
// Six command modules carried an identical private copy of it; a lib
|
|
10
|
+
// operation that needs it should not have to reach into a command for the
|
|
11
|
+
// check (audit finding F7).
|
|
12
|
+
export function ensureDoctrinaProject(projectRoot) {
|
|
13
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
14
|
+
throw notADoctrinaProject();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What this project is CALLED — the name it recorded at `init`, with the
|
|
20
|
+
* directory as the fallback for a tree that has none.
|
|
21
|
+
*
|
|
22
|
+
* Four modules asked this privately and one of them answered differently:
|
|
23
|
+
* `adapter` read the directory and never the record, so every adapter file
|
|
24
|
+
* installed after `doctrina init --project-name "Minha Carteira"` greeted the
|
|
25
|
+
* agent with the folder's name instead. `init --agent`, holding the name it
|
|
26
|
+
* had just been given, got it right — the same templates, two callers, two
|
|
27
|
+
* answers.
|
|
28
|
+
*
|
|
29
|
+
* Pass `index` when you already hold it; the helper then reads nothing.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
* @param {any} [index] an already-loaded index.json, when the caller has one
|
|
33
|
+
*/
|
|
34
|
+
export function projectName(projectRoot, index) {
|
|
35
|
+
if (index !== undefined) return index?.project ?? path.basename(projectRoot);
|
|
36
|
+
try {
|
|
37
|
+
return load(projectRoot)?.project ?? path.basename(projectRoot);
|
|
38
|
+
} catch {
|
|
39
|
+
return path.basename(projectRoot);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The grammar a change id obeys: lowercase letters, digits and hyphens,
|
|
44
|
+
// opening on a letter or a digit — `0042-short-slug` is the shape `work`
|
|
45
|
+
// derives. Deliberately NOT the capability grammar, which additionally
|
|
46
|
+
// requires a leading letter; two rules that look alike are not one rule.
|
|
47
|
+
//
|
|
48
|
+
// It exists because `change new` had NO grammar (third audit, finding 1).
|
|
49
|
+
// Every other authoring command validated its argument; `change new` joined
|
|
50
|
+
// whatever it was given onto a path, so `change new ../../../elsewhere/evil`
|
|
51
|
+
// wrote a change folder OUTSIDE the project — against the `authoring` spec's
|
|
52
|
+
// own "shall not write outside the project working directory" — and ids like
|
|
53
|
+
// `0003-com espaco` reached `validate` and `index rebuild` as legitimate,
|
|
54
|
+
// producing a `next` action whose command could not be run.
|
|
55
|
+
export const CHANGE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
56
|
+
|
|
57
|
+
export function isChangeId(id) {
|
|
58
|
+
return typeof id === "string" && CHANGE_ID.test(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A reference to an EXISTING change is looser than the id `change new`
|
|
62
|
+
// accepts — a project may hold a change named before that grammar — but it
|
|
63
|
+
// is always a folder NAME under .doctrina/changes/, never a path. Nothing
|
|
64
|
+
// checked that: `change archive ../../victim --force` moved the project's
|
|
65
|
+
// own `victim/` directory into the archive and wrote it into the ledger and
|
|
66
|
+
// the index (change 0196). `archive` is the archive's own folder, not a
|
|
67
|
+
// change.
|
|
68
|
+
export function isChangeRef(id) {
|
|
69
|
+
return typeof id === "string" && id.length > 0 && id !== "." && id !== ".."
|
|
70
|
+
&& id !== "archive" && !/[\/\\\0]/.test(id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The one refusal every command that takes a change reference prints: the
|
|
74
|
+
// USAGE class, before anything is resolved against the filesystem.
|
|
75
|
+
export function refuseChangeRef(id) {
|
|
76
|
+
if (isChangeRef(id)) return null;
|
|
77
|
+
console.error(c.red("error:") + ` "${id}" is not a change — a change is named by its folder under .doctrina/changes/, never by a path`);
|
|
78
|
+
console.error(c.gray("hint: ") + "open changes: `doctrina next`");
|
|
79
|
+
return EXIT.USAGE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve `segments` under `projectRoot` and refuse anything that escapes it.
|
|
84
|
+
*
|
|
85
|
+
* Defence in depth, deliberately separate from the grammar above: the
|
|
86
|
+
* grammar is what tells the USER their id is wrong, and this is what holds
|
|
87
|
+
* even when a caller forgets to ask. A path is inside when it is the root
|
|
88
|
+
* itself or sits beneath it — compared after `path.resolve`, so `..`,
|
|
89
|
+
* absolute arguments and symlink-free trickery all normalise first.
|
|
90
|
+
*
|
|
91
|
+
* Throws rather than returning a code: a caller that reached here with an
|
|
92
|
+
* escaping path has a bug, and the entrypoint's catch turns a thrown error
|
|
93
|
+
* into the gate class with the message intact.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveWithinProject(projectRoot, ...segments) {
|
|
96
|
+
const root = path.resolve(projectRoot);
|
|
97
|
+
const target = path.resolve(root, ...segments);
|
|
98
|
+
const rel = path.relative(root, target);
|
|
99
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`refusing to write outside the project: "${path.join(...segments)}" resolves to ${target}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return target;
|
|
105
|
+
}
|