doctrina-cli 0.16.0 → 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 +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +28 -77
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +65 -10
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +1 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/skill.js +8 -15
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/work.js +10 -5
- package/src/index.js +34 -6
- package/src/lib/actions.js +30 -24
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +3 -1
- package/src/lib/commands.js +88 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/project.js +49 -1
- package/src/lib/scan.js +126 -14
- package/src/lib/snapshot.js +23 -1
- package/src/lib/spec-ops.js +24 -5
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +116 -34
- package/src/lib/views.js +4 -1
- package/src/lib/work-model.js +25 -0
- package/templates/AGENTS.md.template +3 -4
- 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 +15 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/work.md.template +10 -3
- package/src/commands/constitution.js +0 -68
package/src/lib/index-json.js
CHANGED
|
@@ -64,10 +64,45 @@ export function blank(projectName, date) {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// INSERT WHERE A REBUILD WOULD HAVE PUT IT.
|
|
68
|
+
//
|
|
69
|
+
// Every category here is written twice: incrementally, by the command that
|
|
70
|
+
// creates the artifact, and wholesale by `index rebuild`, which walks the
|
|
71
|
+
// directory — `readdirSync(...).sort()` — and writes what it finds, in that
|
|
72
|
+
// order. The two agreed only by luck: appending matches a sorted walk exactly
|
|
73
|
+
// when the new entry happens to sort last.
|
|
74
|
+
//
|
|
75
|
+
// It usually does, which is why this survived. `doctrina spec new alpha` after
|
|
76
|
+
// `spec new zebra` does not, and neither does closing change 0138 after 0139 —
|
|
77
|
+
// that one shipped, and turned all six test legs of CI red on a tree whose own
|
|
78
|
+
// `close` had just reported green.
|
|
79
|
+
//
|
|
80
|
+
// `validate` cannot see it: drift of this kind is only visible by rebuilding
|
|
81
|
+
// and comparing, which is `index rebuild --check`, a different gate. So the
|
|
82
|
+
// tree looked healthy from inside and failed from outside.
|
|
83
|
+
//
|
|
84
|
+
// Each entry's `path` embeds the on-disk name the rebuild sorts by, and the
|
|
85
|
+
// parent directory is constant within a category — so ordering by `path`
|
|
86
|
+
// reproduces the walk exactly, without this module having to know how any
|
|
87
|
+
// category is laid out.
|
|
88
|
+
// The comparison has to be the SAME one: `readdirSync(dir).sort()` takes the
|
|
89
|
+
// default comparator, which orders by UTF-16 code unit, and `localeCompare`
|
|
90
|
+
// does not — it ignores or reweights punctuation, so `0100-a` and `0100_a`
|
|
91
|
+
// can come out in the opposite order. Matching the walk means comparing the
|
|
92
|
+
// way the walk compares.
|
|
93
|
+
function insertInRebuildOrder(list, entry) {
|
|
94
|
+
list.push(entry);
|
|
95
|
+
list.sort((a, b) => {
|
|
96
|
+
const x = String(a.path ?? ""), y = String(b.path ?? "");
|
|
97
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
98
|
+
});
|
|
99
|
+
return list;
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
export function addSkill(index, entry) {
|
|
68
103
|
if (!index.artifacts.skills) index.artifacts.skills = [];
|
|
69
104
|
if (!index.artifacts.skills.some((s) => s.id === entry.id)) {
|
|
70
|
-
index.artifacts.skills
|
|
105
|
+
insertInRebuildOrder(index.artifacts.skills, entry);
|
|
71
106
|
}
|
|
72
107
|
return index;
|
|
73
108
|
}
|
|
@@ -75,7 +110,7 @@ export function addSkill(index, entry) {
|
|
|
75
110
|
export function addContract(index, entry) {
|
|
76
111
|
if (!index.artifacts.contracts) index.artifacts.contracts = [];
|
|
77
112
|
if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
|
|
78
|
-
index.artifacts.contracts
|
|
113
|
+
insertInRebuildOrder(index.artifacts.contracts, entry);
|
|
79
114
|
}
|
|
80
115
|
return index;
|
|
81
116
|
}
|
|
@@ -87,14 +122,14 @@ export function touch(index, date) {
|
|
|
87
122
|
|
|
88
123
|
export function addSpec(index, entry) {
|
|
89
124
|
if (!index.artifacts.specs.some((s) => s.id === entry.id)) {
|
|
90
|
-
index.artifacts.specs
|
|
125
|
+
insertInRebuildOrder(index.artifacts.specs, entry);
|
|
91
126
|
}
|
|
92
127
|
return index;
|
|
93
128
|
}
|
|
94
129
|
|
|
95
130
|
export function addDecision(index, entry) {
|
|
96
131
|
if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
|
|
97
|
-
index.artifacts.decisions
|
|
132
|
+
insertInRebuildOrder(index.artifacts.decisions, entry);
|
|
98
133
|
}
|
|
99
134
|
return index;
|
|
100
135
|
}
|
|
@@ -110,7 +145,7 @@ export function updateDecision(index, id, mutator) {
|
|
|
110
145
|
export function moveChangeToArchive(index, id, archiveEntry) {
|
|
111
146
|
index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
|
|
112
147
|
if (!index.artifacts.changes_archive.some((c) => c.id === id)) {
|
|
113
|
-
index.artifacts.changes_archive
|
|
148
|
+
insertInRebuildOrder(index.artifacts.changes_archive, archiveEntry);
|
|
114
149
|
}
|
|
115
150
|
return index;
|
|
116
151
|
}
|
package/src/lib/intake-model.js
CHANGED
|
@@ -10,6 +10,8 @@ import { exists, isFile, read, relPath, write } from "./fs-ops.js";
|
|
|
10
10
|
import { assessBrief } from "./clarity.js";
|
|
11
11
|
import { c } from "./colors.js";
|
|
12
12
|
import { printPlaybookTemplate } from "./playbook.js";
|
|
13
|
+
import { getHeader, setHeader } from "./doc-model.js";
|
|
14
|
+
import { stateWord } from "./spec-ops.js";
|
|
13
15
|
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
14
16
|
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
15
17
|
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
@@ -39,6 +41,30 @@ export function looksLikePath(value) {
|
|
|
39
41
|
return false;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
// What `init` tells a person to do next (change 0173). The first run used to
|
|
45
|
+
// contradict itself three ways: `init` said "edit AGENTS.md and product.md",
|
|
46
|
+
// `next` asked for the description again as an intake, and AGENTS.md said the
|
|
47
|
+
// agent runs the commands while the human stays passive. One answer now,
|
|
48
|
+
// the same one `next` and AGENTS.md give.
|
|
49
|
+
//
|
|
50
|
+
// `intakeFrom` is "tty" when the description was typed at init's question
|
|
51
|
+
// (it is stored as the intake), or null when init got no intake at all.
|
|
52
|
+
export function nextStepAfterInit(intakeFrom) {
|
|
53
|
+
if (intakeFrom === "tty") {
|
|
54
|
+
return [
|
|
55
|
+
"Next: open your AI agent in this repository and tell it:",
|
|
56
|
+
" read AGENTS.md and run doctrina next",
|
|
57
|
+
"It turns your description into product.md and specs; you review and approve.",
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
"Next: give your AI agent the whole project description. It runs",
|
|
62
|
+
" doctrina intake --text \"<description>\"",
|
|
63
|
+
"and turns it into product.md and specs (AGENTS.md, \"Working from intent\");",
|
|
64
|
+
"for an existing codebase, `doctrina work --from-diff` backfills them from the code.",
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
export function warnIfThinIntake(body) {
|
|
43
69
|
const assessment = assessBrief(body, { kind: "intake" });
|
|
44
70
|
if (!assessment.thin) return;
|
|
@@ -47,8 +73,51 @@ export function warnIfThinIntake(body) {
|
|
|
47
73
|
console.log("");
|
|
48
74
|
}
|
|
49
75
|
|
|
76
|
+
// THE INTAKE'S STATUS IS A CONTROL VALUE, SO IT GETS AN ENUM AND AN OWNER.
|
|
77
|
+
//
|
|
78
|
+
// `next` branches on it: pending means the bootstrap is unfinished, and every
|
|
79
|
+
// other artifact's status in this framework is both written by a command and
|
|
80
|
+
// checked against an enum — `spec set` refuses "Status: nonsense", and
|
|
81
|
+
// `validate` calls it an error. The intake was the one header the playbook
|
|
82
|
+
// told an agent to edit BY HAND, and the one nothing read back.
|
|
83
|
+
//
|
|
84
|
+
// So any token that was not exactly "converted" meant pending, in silence:
|
|
85
|
+
// "convertido" typed in a Portuguese project, "done", or an empty value left
|
|
86
|
+
// by a botched edit. `validate` exited 0 on all of them and `next` went on
|
|
87
|
+
// asking for a bootstrap that had already happened.
|
|
88
|
+
export const INTAKE_STATUSES = ["pending", "converted"];
|
|
89
|
+
|
|
90
|
+
/** The intake's status word, note stripped and folded. Defaults to pending. */
|
|
91
|
+
export function intakeStatus(text) {
|
|
92
|
+
const raw = getHeader(String(text ?? ""), "Status");
|
|
93
|
+
if (raw === null) return "pending";
|
|
94
|
+
return stateWord(raw) || "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** null when the intake's status is a declared value; otherwise the error. */
|
|
98
|
+
export function intakeStatusError(text) {
|
|
99
|
+
const word = intakeStatus(text);
|
|
100
|
+
if (INTAKE_STATUSES.includes(word)) return null;
|
|
101
|
+
const got = getHeader(String(text ?? ""), "Status");
|
|
102
|
+
return `Status must be one of ${INTAKE_STATUSES.join("|")} `
|
|
103
|
+
+ `(got "${String(got ?? "").trim()}") — every other value reads as pending, in silence`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flip the stored intake to `converted`. Returns the path written, or null
|
|
108
|
+
* when there is no intake (or no Status header) to flip.
|
|
109
|
+
*/
|
|
110
|
+
export function markIntakeConverted(projectRoot) {
|
|
111
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
112
|
+
if (!exists(intakePath)) return null;
|
|
113
|
+
const next = setHeader(read(intakePath), "Status", "converted");
|
|
114
|
+
if (next === null) return null;
|
|
115
|
+
write(intakePath, next, { force: true });
|
|
116
|
+
return intakePath;
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
51
|
-
// status header;
|
|
120
|
+
// status header; `doctrina intake --converted` flips it at the end of the
|
|
52
121
|
// playbook, which is how `next` knows the bootstrap is done.
|
|
53
122
|
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
54
123
|
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
@@ -61,7 +130,7 @@ export function writeIntakeFile(projectRoot, { body, source, projectName, date,
|
|
|
61
130
|
"",
|
|
62
131
|
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
63
132
|
" (doctrina intake) converts it into product.md and capability",
|
|
64
|
-
" specs
|
|
133
|
+
" specs. Close it with `doctrina intake --converted`; after that the",
|
|
65
134
|
" specs are the only source of truth; never edit this file to",
|
|
66
135
|
" change requirements. -->",
|
|
67
136
|
"",
|
package/src/lib/lexicon.js
CHANGED
|
@@ -183,9 +183,46 @@ export const CONFIDENT_MARGIN = 10;
|
|
|
183
183
|
// content words, no interpretation (ADR 0005).
|
|
184
184
|
export const SLUG_WORDS = 4;
|
|
185
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
|
+
|
|
186
198
|
export function slugFromPrompt(prompt, { words = SLUG_WORDS } = {}) {
|
|
187
|
-
const content =
|
|
199
|
+
const content = slugTerms(prompt).slice(0, words);
|
|
188
200
|
const slug = slugify(content.join(" "));
|
|
189
201
|
// A prompt of nothing but stopwords ("do it now") still needs an id.
|
|
190
202
|
return slug || slugify(prompt).split("-").slice(0, words).join("-");
|
|
191
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
|
+
}
|
package/src/lib/metrics-model.js
CHANGED
|
@@ -83,6 +83,36 @@ export function round(x) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
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
|
+
|
|
86
116
|
/**
|
|
87
117
|
* A snapshot for a window, or null when there is no history to read.
|
|
88
118
|
*
|
package/src/lib/project.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { exists } from "./fs-ops.js";
|
|
4
|
-
import { notADoctrinaProject } from "./exit-codes.js";
|
|
4
|
+
import { EXIT, notADoctrinaProject } from "./exit-codes.js";
|
|
5
|
+
import { c } from "./colors.js";
|
|
6
|
+
import { load } from "./index-json.js";
|
|
5
7
|
|
|
6
8
|
// The one precondition every command shares: this is a Doctrina project.
|
|
7
9
|
// Six command modules carried an identical private copy of it; a lib
|
|
@@ -13,6 +15,31 @@ export function ensureDoctrinaProject(projectRoot) {
|
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
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
|
+
|
|
16
43
|
// The grammar a change id obeys: lowercase letters, digits and hyphens,
|
|
17
44
|
// opening on a letter or a digit — `0042-short-slug` is the shape `work`
|
|
18
45
|
// derives. Deliberately NOT the capability grammar, which additionally
|
|
@@ -31,6 +58,27 @@ export function isChangeId(id) {
|
|
|
31
58
|
return typeof id === "string" && CHANGE_ID.test(id);
|
|
32
59
|
}
|
|
33
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
|
+
|
|
34
82
|
/**
|
|
35
83
|
* Resolve `segments` under `projectRoot` and refuse anything that escapes it.
|
|
36
84
|
*
|
package/src/lib/scan.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
|
|
3
|
+
import { projectName } from "./project.js";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { readdirSync } from "node:fs";
|
|
5
6
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
6
7
|
import { today } from "./dates.js";
|
|
7
8
|
import { cliVersion, newestVersion } from "./version.js";
|
|
8
9
|
import { load } from "./index-json.js";
|
|
9
|
-
import { parseFrontmatter } from "./doc-model.js";
|
|
10
|
+
import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
|
|
10
11
|
import { parseCapabilityFromDelta } from "./doc-model.js";
|
|
11
12
|
import { parseOperation } from "./doc-model.js";
|
|
13
|
+
import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
|
|
12
14
|
|
|
13
15
|
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
14
16
|
// These two names survive because dozens of call sites use them and the
|
|
@@ -168,6 +170,23 @@ export function changeEntry(proposal, id, prev, date) {
|
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
|
|
173
|
+
// An archived change, as an index entry — the one constructor both the
|
|
174
|
+
// rebuild and `change archive` use. They each built their own, and neither
|
|
175
|
+
// carried the lane: an archived change lost the lane it was born in, so the
|
|
176
|
+
// report's "Lanes" section counted the whole history as unknown (change
|
|
177
|
+
// 0192), which is the question change 0042 recorded the lane to answer.
|
|
178
|
+
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
|
|
182
|
+
path: `.doctrina/changes/archive/${archiveName}`,
|
|
183
|
+
status: "applied",
|
|
184
|
+
applied,
|
|
185
|
+
specs_affected: specsAffected,
|
|
186
|
+
...laneOf(proposal, prev),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
172
191
|
// the source of truth; fields with no on-disk source (project name,
|
|
173
192
|
// framework_version, product metadata) are carried over from `current`.
|
|
@@ -178,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
178
197
|
|
|
179
198
|
const out = {
|
|
180
199
|
$schema_version: current?.$schema_version ?? "0.1.0",
|
|
181
|
-
project:
|
|
200
|
+
project: projectName(projectRoot, current),
|
|
182
201
|
framework_version: current?.framework_version ?? "0.0.0",
|
|
183
202
|
last_updated: current?.last_updated ?? date,
|
|
184
203
|
// Project settings, carried over verbatim. `deriveIndex` rebuilds the
|
|
@@ -284,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
|
|
|
284
303
|
specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
|
|
285
304
|
}
|
|
286
305
|
}
|
|
287
|
-
out.artifacts.changes_archive.push({
|
|
288
|
-
id: m[2],
|
|
289
|
-
|
|
290
|
-
path: `.doctrina/changes/archive/${name}`,
|
|
291
|
-
status: "applied",
|
|
292
|
-
applied: m[1],
|
|
293
|
-
specs_affected: specsAffected,
|
|
294
|
-
});
|
|
306
|
+
out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
|
|
307
|
+
id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
|
|
308
|
+
}));
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
// Contracts — the integration/runtime surface (ports, env, interfaces)
|
|
@@ -310,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
|
|
|
310
324
|
});
|
|
311
325
|
}
|
|
312
326
|
|
|
313
|
-
// Skills — frontmatter description wins.
|
|
327
|
+
// Skills — a WRITTEN frontmatter description wins. The rebuild is the one
|
|
328
|
+
// reconciliation (change 0177 retired `skill sync`, which did a subset of
|
|
329
|
+
// this), so it carries sync's rule: a description still in the scaffold's
|
|
330
|
+
// `<...>` form never overwrites one somebody wrote (change 0111), and the
|
|
331
|
+
// entry's date moves when its description does.
|
|
314
332
|
const skillsDir = path.join(dot, "skills");
|
|
315
333
|
for (const f of walk(skillsDir)) {
|
|
316
334
|
if (!f.endsWith(".md")) continue;
|
|
317
335
|
const id = path.basename(f, ".md");
|
|
318
336
|
const prev = (cur.skills ?? []).find((s) => s.id === id);
|
|
337
|
+
const written = parseFrontmatter(read(f), "description");
|
|
338
|
+
const kept = prev?.description && !isScaffoldValue(prev.description) ? prev.description : null;
|
|
339
|
+
const description = written && !isScaffoldValue(written)
|
|
340
|
+
? written
|
|
341
|
+
: kept ?? written ?? "<edit me — one-sentence summary>";
|
|
319
342
|
out.artifacts.skills.push({
|
|
320
343
|
id,
|
|
321
344
|
path: `.doctrina/skills/${id}.md`,
|
|
322
|
-
description
|
|
323
|
-
|
|
324
|
-
last_updated: prev?.last_updated ?? date,
|
|
345
|
+
description,
|
|
346
|
+
last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
|
|
325
347
|
});
|
|
326
348
|
}
|
|
327
349
|
|
|
@@ -487,3 +509,93 @@ export function dependentsOf(projectRoot, caps) {
|
|
|
487
509
|
}
|
|
488
510
|
return out;
|
|
489
511
|
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------- staged
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The same question as `collectIndexDrift`, asked of the COMMIT instead of
|
|
517
|
+
* the disk.
|
|
518
|
+
*
|
|
519
|
+
* `index.json` is derived from the whole tree, so it cannot describe a
|
|
520
|
+
* subset of it. Stage some of `.doctrina/` and the index that rides along
|
|
521
|
+
* names artifacts the commit does not carry — and every gate that would
|
|
522
|
+
* catch it reads the working tree, where those artifacts are still present.
|
|
523
|
+
* Four commits of this repository shipped that way, each green locally and
|
|
524
|
+
* each red on checkout: `index.json references missing artifact`.
|
|
525
|
+
*
|
|
526
|
+
* Read with git plumbing, never from disk: `:<path>` is the staged blob and
|
|
527
|
+
* `ls-files --cached` is the file list the commit will have.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} projectRoot
|
|
530
|
+
* @returns {{ok: boolean, applicable: boolean, missing: string[], unindexed: string[], reason: string|null}}
|
|
531
|
+
*/
|
|
532
|
+
export function collectStagedIndexDrift(projectRoot) {
|
|
533
|
+
const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
|
|
534
|
+
if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
|
|
535
|
+
|
|
536
|
+
const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
|
|
537
|
+
if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
|
|
538
|
+
// Not staged at all — this commit does not carry an index, so it cannot
|
|
539
|
+
// carry one that disagrees with itself.
|
|
540
|
+
return { ...nothing, reason: "no staged .doctrina/index.json" };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let staged;
|
|
544
|
+
try {
|
|
545
|
+
staged = JSON.parse(blob.stdout);
|
|
546
|
+
} catch {
|
|
547
|
+
return { ok: false, applicable: true, missing: [], unindexed: [],
|
|
548
|
+
reason: "the staged .doctrina/index.json is not valid JSON" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const files = new Set(gitLines(projectRoot, ["ls-files", "--cached", "--", ".doctrina"]));
|
|
552
|
+
// An entry's path is a file for specs, ADRs, skills and contracts, and a
|
|
553
|
+
// DIRECTORY for changes — a directory is in the commit when any file under
|
|
554
|
+
// it is.
|
|
555
|
+
const inCommit = (p) => files.has(p) || [...files].some((f) => f.startsWith(`${p}/`));
|
|
556
|
+
|
|
557
|
+
const artifacts = staged.artifacts ?? {};
|
|
558
|
+
const missing = [];
|
|
559
|
+
for (const [kind, entries] of Object.entries(artifacts)) {
|
|
560
|
+
if (!Array.isArray(entries)) continue;
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
const p = String(entry?.path ?? "");
|
|
563
|
+
if (p && !inCommit(p)) missing.push(`${kind}: ${p}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The mirror case: an artifact staged into the commit that the staged index
|
|
568
|
+
// never heard of. Same shapes `index rebuild` derives entries from.
|
|
569
|
+
const indexed = new Set();
|
|
570
|
+
for (const entries of Object.values(artifacts)) {
|
|
571
|
+
if (Array.isArray(entries)) for (const e of entries) indexed.add(String(e?.path ?? ""));
|
|
572
|
+
}
|
|
573
|
+
const unindexed = [];
|
|
574
|
+
for (const f of [...files].sort()) {
|
|
575
|
+
const owner = artifactPathOf(f);
|
|
576
|
+
if (owner && !indexed.has(owner)) unindexed.push(owner);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const unique = [...new Set(unindexed)];
|
|
580
|
+
return {
|
|
581
|
+
ok: missing.length === 0 && unique.length === 0,
|
|
582
|
+
applicable: true,
|
|
583
|
+
missing,
|
|
584
|
+
unindexed: unique,
|
|
585
|
+
reason: null,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The index entry a staged file belongs to, or null when the file is not an
|
|
590
|
+
// artifact (templates, config.json, product.md, the index itself).
|
|
591
|
+
function artifactPathOf(file) {
|
|
592
|
+
let m = /^\.doctrina\/specs\/[^/]+\/spec\.md$/.exec(file);
|
|
593
|
+
if (m) return file;
|
|
594
|
+
m = /^\.doctrina\/(?:decisions|skills|contracts)\/[^/]+\.md$/.exec(file);
|
|
595
|
+
if (m) return file;
|
|
596
|
+
m = /^(\.doctrina\/changes\/archive\/[^/]+)\/proposal\.md$/.exec(file);
|
|
597
|
+
if (m) return m[1];
|
|
598
|
+
m = /^(\.doctrina\/changes\/(?!archive\/)[^/]+)\/proposal\.md$/.exec(file);
|
|
599
|
+
if (m) return m[1];
|
|
600
|
+
return null;
|
|
601
|
+
}
|
package/src/lib/snapshot.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import { projectName } from "./project.js";
|
|
5
6
|
import * as idx from "./index-json.js";
|
|
6
7
|
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
7
8
|
import { cliVersion } from "./version.js";
|
|
@@ -12,6 +13,8 @@ import { computeActions } from "./actions.js";
|
|
|
12
13
|
import { readLedger } from "./ledger.js";
|
|
13
14
|
import { parseChangeTitle, checklistProgress } from "./doc-model.js";
|
|
14
15
|
import { summarizeSignoffs } from "./signoff.js";
|
|
16
|
+
import { gitWindow, historyState, windowCutoff } from "./git.js";
|
|
17
|
+
import { collectMetrics } from "./metrics-model.js";
|
|
15
18
|
|
|
16
19
|
// ONE collector, several views (audit finding F7).
|
|
17
20
|
//
|
|
@@ -57,7 +60,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
|
57
60
|
const verify = readVerifyConfig(projectRoot);
|
|
58
61
|
|
|
59
62
|
return {
|
|
60
|
-
project:
|
|
63
|
+
project: projectName(projectRoot, index),
|
|
61
64
|
stamp: index?.framework_version ?? null,
|
|
62
65
|
cli: cliVersion(),
|
|
63
66
|
indexState,
|
|
@@ -207,3 +210,22 @@ function countSkills(projectRoot) {
|
|
|
207
210
|
if (!isDir(skillsDir)) return 0;
|
|
208
211
|
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
209
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Everything the period digest reads beyond the snapshot, for a window of
|
|
216
|
+
* `days`. ONE collector for every door that renders the digest: `report`
|
|
217
|
+
* passed the metrics and `status --view report` did not, so the view that
|
|
218
|
+
* promised "never different numbers" printed two lines fewer (change 0176).
|
|
219
|
+
*
|
|
220
|
+
* @param {string} projectRoot
|
|
221
|
+
* @param {number} days
|
|
222
|
+
*/
|
|
223
|
+
export function collectReportWindow(projectRoot, days) {
|
|
224
|
+
return {
|
|
225
|
+
days,
|
|
226
|
+
cutoffIso: windowCutoff(days),
|
|
227
|
+
git: gitWindow(projectRoot, days),
|
|
228
|
+
gitState: historyState(projectRoot),
|
|
229
|
+
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/lib/spec-ops.js
CHANGED
|
@@ -256,7 +256,10 @@ export function setHeader(text, name, value) {
|
|
|
256
256
|
// has a lifecycle of its own (proposed -> applied, accepted, superseded).
|
|
257
257
|
const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
|
|
258
258
|
const domain = listForm ? null : headerValueError(name, value);
|
|
259
|
-
|
|
259
|
+
// `cause: "argument"` marks an error about the value supplied, not about
|
|
260
|
+
// the document: `spec set` answers it as USAGE, a delta as a defect of
|
|
261
|
+
// the work (ADR 0018).
|
|
262
|
+
if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
|
|
260
263
|
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
261
264
|
return { text: next, summary: `set ${name}: ${value}` };
|
|
262
265
|
}
|
|
@@ -309,10 +312,10 @@ export function setCriterionMark(text, n, markRaw) {
|
|
|
309
312
|
const loc = locateCriteria(text);
|
|
310
313
|
if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
|
|
311
314
|
const item = loc.items.find((it) => it.n === n);
|
|
312
|
-
if (!item) return { error: `set-criterion: no criterion #${n} found
|
|
315
|
+
if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
|
|
313
316
|
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
314
317
|
const domain = criterionMarkError(mark);
|
|
315
|
-
if (domain) return { error: `set-criterion: ${domain}
|
|
318
|
+
if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
|
|
316
319
|
const line = loc.lines[item.lineIndex];
|
|
317
320
|
let next;
|
|
318
321
|
if (/\[[^\]]*\]/.test(line)) {
|
|
@@ -481,8 +484,24 @@ export function replaceRequirement(text, section, n, value) {
|
|
|
481
484
|
if (n > real.length) {
|
|
482
485
|
return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
483
486
|
}
|
|
484
|
-
|
|
485
|
-
|
|
487
|
+
// REPLACE THE WHOLE ITEM, continuation lines included.
|
|
488
|
+
//
|
|
489
|
+
// Writing only the first line leaves the old bullet's wrapped prose sitting
|
|
490
|
+
// under the new one, indented, reading as part of it. That is not cosmetic:
|
|
491
|
+
// the orphan is the PREVIOUS version of the requirement, so the spec ends up
|
|
492
|
+
// stating a contract and then contradicting it two lines later, and a spec
|
|
493
|
+
// is the one artifact this framework asks everyone to trust.
|
|
494
|
+
//
|
|
495
|
+
// It had already happened four times in this repository's own tree before
|
|
496
|
+
// anyone noticed — twice in `gates` (a closing sequence missing its review
|
|
497
|
+
// and runtime steps, a doctor set missing its runtime row) and once in
|
|
498
|
+
// `insight`, all three describing older behaviour beside the current one.
|
|
499
|
+
//
|
|
500
|
+
// `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
|
|
501
|
+
// was written afterwards and never picked it up.
|
|
502
|
+
const start = real[n - 1];
|
|
503
|
+
const indent = loc.lines[start].match(/^(\s*)/)[1];
|
|
504
|
+
loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
|
|
486
505
|
return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
|
|
487
506
|
}
|
|
488
507
|
|