doctrina-cli 0.16.0 → 0.18.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 +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- 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 +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- 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/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -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/git.js +59 -2
- 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/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -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 +15 -0
- package/templates/change/spec-delta.md.template +2 -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/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- package/src/commands/constitution.js +0 -68
package/src/lib/gates.js
CHANGED
|
@@ -24,10 +24,10 @@ import { appendLedgerLine, forcedLine } from "./ledger.js";
|
|
|
24
24
|
// A gate is a named precondition returning human-readable blockers.
|
|
25
25
|
export const GATES = {
|
|
26
26
|
// Structural validity: the change's own files parse and point at real
|
|
27
|
-
// targets. The same checks `doctrina
|
|
27
|
+
// targets. The same checks `doctrina change check` reports first.
|
|
28
28
|
structure: {
|
|
29
29
|
label: "structure",
|
|
30
|
-
rerun: (id) => `doctrina
|
|
30
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
31
31
|
blockers(projectRoot, changeDir) {
|
|
32
32
|
return collectAnalysis(projectRoot, changeDir)
|
|
33
33
|
.filter((r) => r.kind === "fail")
|
|
@@ -49,7 +49,7 @@ export const GATES = {
|
|
|
49
49
|
// history recorded six hollow proposals reaching the archive.
|
|
50
50
|
integrity: {
|
|
51
51
|
label: "integrity",
|
|
52
|
-
rerun: (id) => `doctrina
|
|
52
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
53
53
|
blockers(projectRoot, changeDir) {
|
|
54
54
|
return collectAnalysis(projectRoot, changeDir)
|
|
55
55
|
.filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
|
|
@@ -58,8 +58,9 @@ export const GATES = {
|
|
|
58
58
|
},
|
|
59
59
|
|
|
60
60
|
// Verification: the work is claimed complete. Every checkbox in
|
|
61
|
-
// tasks.md
|
|
62
|
-
// "##
|
|
61
|
+
// tasks.md and in the proposal's "## Verification" section — except a
|
|
62
|
+
// legacy "## Closing steps" list, which names what the close itself does
|
|
63
|
+
// (change 0195).
|
|
63
64
|
verification: {
|
|
64
65
|
label: "verification",
|
|
65
66
|
rerun: (id) => `doctrina change tick ${id}`,
|
|
@@ -72,7 +73,7 @@ export const GATES = {
|
|
|
72
73
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
73
74
|
if (isFile(tasksPath)) {
|
|
74
75
|
const n = countUnchecked(read(tasksPath));
|
|
75
|
-
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md
|
|
76
|
+
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md`);
|
|
76
77
|
}
|
|
77
78
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
78
79
|
if (isFile(proposalPath)) {
|
|
@@ -158,7 +159,11 @@ export const SEQUENCES = {
|
|
|
158
159
|
// The closing sequence. `close` runs these in order, in-process where it
|
|
159
160
|
// can, stopping at the first blocking failure with the step's rerun line.
|
|
160
161
|
close: [
|
|
161
|
-
|
|
162
|
+
// The structural pre-flight. Its id stays "analyze" (the module it runs
|
|
163
|
+
// in-process), but a reader sees "structure", the gate it holds: change
|
|
164
|
+
// 0182 deprecated `doctrina analyze`, and the command a person reruns is
|
|
165
|
+
// `change check`, which reports the same checks first (change 0183).
|
|
166
|
+
{ id: "analyze", label: "structure", level: "blocking", argv: ["analyze", "<id>"], rerun: "doctrina change check <id>" },
|
|
162
167
|
{ id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
|
|
163
168
|
// Conformance review (audit finding F3). It is the richest analysis the
|
|
164
169
|
// project has — capabilities whose code moved while their spec did not,
|
|
@@ -179,6 +184,20 @@ export const SEQUENCES = {
|
|
|
179
184
|
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
180
185
|
{ id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
|
|
181
186
|
{ id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
|
|
187
|
+
// AFTER the archive, because the archive is the last step that WRITES the
|
|
188
|
+
// index, and a gate placed before the step it guards cannot guard it.
|
|
189
|
+
//
|
|
190
|
+
// The drift check already ran, inside `verify` — and that is exactly why
|
|
191
|
+
// it missed: `verify` is five steps earlier, so it certified an index the
|
|
192
|
+
// archive had not yet rewritten. Change 0138 closed green on a tree whose
|
|
193
|
+
// index had drifted, the commit shipped, and all six test legs of CI went
|
|
194
|
+
// red on the next push. The close was not wrong about what it checked; it
|
|
195
|
+
// checked before the damage.
|
|
196
|
+
//
|
|
197
|
+
// `validate` cannot stand in for it: drift of that kind is only visible by
|
|
198
|
+
// rebuilding the index and comparing, which validate deliberately does not
|
|
199
|
+
// do.
|
|
200
|
+
{ id: "index-drift", label: "index drift", level: "blocking", argv: ["index", "rebuild", "--check"] },
|
|
182
201
|
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
183
202
|
],
|
|
184
203
|
|
|
@@ -191,7 +210,7 @@ export const SEQUENCES = {
|
|
|
191
210
|
{ id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
|
|
192
211
|
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
193
212
|
{ id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
|
|
194
|
-
{ id: "templates", label: "templates", level: "advisory", argv: ["
|
|
213
|
+
{ id: "templates", label: "templates", level: "advisory", argv: ["upgrade"] },
|
|
195
214
|
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
196
215
|
{ id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
|
|
197
216
|
{ id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
|
|
@@ -265,6 +284,31 @@ export function sequence(name) {
|
|
|
265
284
|
return steps;
|
|
266
285
|
}
|
|
267
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The sequence as a reader sees it: each step's label, in order, with the
|
|
289
|
+
* forceable ones marked the way the advisory ones already are in their own
|
|
290
|
+
* labels.
|
|
291
|
+
*
|
|
292
|
+
* Every surface that SHOWS the sequence renders it from here. Four copies of
|
|
293
|
+
* the close sequence were written out by hand — `close --help`, two adapter
|
|
294
|
+
* command files, and the flow page in both languages — and all four had gone
|
|
295
|
+
* stale, each to a different list: one named four steps of thirteen, another
|
|
296
|
+
* eleven. A sequence a reader can count is a sequence a reader will trust, so
|
|
297
|
+
* it has exactly one author.
|
|
298
|
+
*/
|
|
299
|
+
export function sequenceLabels(name) {
|
|
300
|
+
// The marker comes from `level`, never from the label text: labels had
|
|
301
|
+
// already drifted among themselves — `trace` is advisory and did not say so
|
|
302
|
+
// while `ADR checkpoint` did — so any marker already written in is stripped
|
|
303
|
+
// and re-derived. One rule, applied to every step.
|
|
304
|
+
return sequence(name).map((s) => {
|
|
305
|
+
const base = s.label.replace(/\s*\((?:advisory|forceable)\)\s*$/, "");
|
|
306
|
+
if (s.level === "advisory") return `${base} (advisory)`;
|
|
307
|
+
if (s.level === "forceable") return `${base} (forceable)`;
|
|
308
|
+
return base;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
268
312
|
/**
|
|
269
313
|
* The command that runs one step on its own — what a surface prints when it
|
|
270
314
|
* tells the operator how to clear the gate. Derived from `argv` so the
|
|
@@ -272,7 +316,7 @@ export function sequence(name) {
|
|
|
272
316
|
* not a single command.
|
|
273
317
|
*/
|
|
274
318
|
export function stepRerun(step, changeId = "<id>") {
|
|
275
|
-
if (step.rerun) return step.rerun;
|
|
319
|
+
if (step.rerun) return step.rerun.replace(/<id>/g, changeId);
|
|
276
320
|
if (!step.argv) return `doctrina ${step.id}`;
|
|
277
321
|
return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
|
|
278
322
|
}
|
package/src/lib/git.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
3
8
|
|
|
4
9
|
// One place that knows how to ask git a question and how to interpret not
|
|
5
10
|
// getting an answer (audit item C8).
|
|
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
|
|
|
32
37
|
|
|
33
38
|
// Run a git command. Never throws. Returns
|
|
34
39
|
// { state, stdout, lines, stderr, code }.
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {{ env?: Record<string, string> }} [opts]
|
|
44
|
+
*/
|
|
45
|
+
export function git(cwd, args, { env } = {}) {
|
|
46
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
37
47
|
if (r.error) {
|
|
38
48
|
// ENOENT here means the binary is missing, not that the command failed.
|
|
39
49
|
return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
|
|
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
|
|
|
227
237
|
const date = tags[0] ?? "";
|
|
228
238
|
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
229
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A content fingerprint of the working tree under `cwd` — tracked AND
|
|
243
|
+
* untracked files, .gitignore respected, uncommitted edits included — with
|
|
244
|
+
* the top-level entries named in `exclude` left out (change 0203). Null when
|
|
245
|
+
* git cannot answer.
|
|
246
|
+
*
|
|
247
|
+
* Built from git's own object model rather than by reading files: the real
|
|
248
|
+
* index is copied to a scratch one, `git add -A` stages the whole tree into
|
|
249
|
+
* the copy (reusing the stat cache, so only changed files are hashed), and
|
|
250
|
+
* `git write-tree` names the result. The real index, HEAD and the working
|
|
251
|
+
* tree are never touched; the only side effect is loose objects for changed
|
|
252
|
+
* files, which `git gc` reclaims.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} cwd
|
|
255
|
+
* @param {{ exclude?: string[] }} [opts]
|
|
256
|
+
* @returns {string | null}
|
|
257
|
+
*/
|
|
258
|
+
export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
|
|
259
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
260
|
+
const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
|
|
261
|
+
if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
|
|
262
|
+
const root = top.stdout.trim();
|
|
263
|
+
const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
|
|
264
|
+
try {
|
|
265
|
+
const real = path.join(dir.stdout.trim(), "index");
|
|
266
|
+
if (existsSync(real)) copyFileSync(real, scratch);
|
|
267
|
+
const env = { GIT_INDEX_FILE: scratch };
|
|
268
|
+
if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
|
|
269
|
+
const tree = git(root, ["write-tree"], { env });
|
|
270
|
+
if (tree.state !== GIT_STATE.OK) return null;
|
|
271
|
+
// Asked of git, never computed with path.relative: on macOS the tmpdir
|
|
272
|
+
// is `/var/…`, a symlink to `/private/var/…`, and git answers with the
|
|
273
|
+
// resolved toplevel — the relative path between the two spellings is
|
|
274
|
+
// `../..`, which names nothing in the tree (the 0.18 macOS CI run).
|
|
275
|
+
const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
|
|
276
|
+
if (prefixed.state !== GIT_STATE.OK) return null;
|
|
277
|
+
const prefix = prefixed.stdout.trim().replace(/\/$/, "");
|
|
278
|
+
const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
|
|
279
|
+
if (listing.state !== GIT_STATE.OK) return null;
|
|
280
|
+
const skip = new Set(exclude);
|
|
281
|
+
const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
|
|
282
|
+
return createHash("sha256").update(kept.join("\n")).digest("hex");
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(scratch, { force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
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/playbook.js
CHANGED
|
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
-
export const PLAYBOOKS = ["work", "chore", "bootstrap"];
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate"];
|
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/runtime.js
CHANGED
|
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
|
|
|
631
631
|
// `{a,b}` alternates. Enough for the "where do my selectors live" and "which
|
|
632
632
|
// code is mine" declarations, with no dependency and no surprises.
|
|
633
633
|
export function globToRegExp(glob) {
|
|
634
|
+
// A pattern ending in `/` names a DIRECTORY, and a directory claims what
|
|
635
|
+
// is under it (change 0199). Compiled as written it matched only the
|
|
636
|
+
// literal path `src/health/` — which no file has — so the most natural way
|
|
637
|
+
// to declare a module's ownership claimed nothing, and `review` reported
|
|
638
|
+
// every file in it as ownerless.
|
|
639
|
+
if (/\/$/.test(String(glob))) glob = `${glob}**`;
|
|
634
640
|
// Always compile the EXPANSION, never the original. Using it only when it
|
|
635
641
|
// produced more than one alternative left `src/{a}.js` compiling with its
|
|
636
642
|
// braces as literal characters, so a one-element group matched nothing
|