doctrina-cli 0.15.1 → 0.16.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 +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Drafting the agent-facing changelog from what actually shipped.
|
|
3
|
+
//
|
|
4
|
+
// `AGENT_CHANGELOG` in lib/commands.js is the block `upgrade --write` writes
|
|
5
|
+
// into AGENTS.md: at most five bullets telling an arriving agent what it must
|
|
6
|
+
// now DO differently. It is an object literal somebody edits by hand at every
|
|
7
|
+
// release, in parallel with a 57 KB prose CHANGELOG describing the same
|
|
8
|
+
// changes for people — and the comment above it already admits the approach
|
|
9
|
+
// "worked once and does not scale" (audit finding F22).
|
|
10
|
+
//
|
|
11
|
+
// This module does not write it. It DRAFTS candidates, from the signals the
|
|
12
|
+
// docs gate already extracts per archived change — the commands, flags and
|
|
13
|
+
// exit codes that change said it touched. Authorship stays human, because
|
|
14
|
+
// what an agent must do differently is a judgement about behaviour, and the
|
|
15
|
+
// CLI does not make those (ADR 0005). What it removes is the remembering.
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { isDir } from "./fs-ops.js";
|
|
18
|
+
import { readLedger } from "./ledger.js";
|
|
19
|
+
import { documentedSurfaceSignals } from "./docs-impact.js";
|
|
20
|
+
import { AGENT_CHANGELOG_MAX_BULLETS } from "./commands.js";
|
|
21
|
+
import { lastTagDate, windowCutoff } from "./git.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which window a draft covers, and why that window.
|
|
25
|
+
*
|
|
26
|
+
* "Since the last tag" is the honest default: the block describes the
|
|
27
|
+
* release being cut, and the last tag is where the previous one ended. With
|
|
28
|
+
* no tags (a young project, a shallow clone) it falls back to a day window
|
|
29
|
+
* and SAYS which it used — a draft that quietly covered the wrong period
|
|
30
|
+
* would be worse than one that names its edges.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} projectRoot
|
|
33
|
+
* @param {{days?: number|null}} [options]
|
|
34
|
+
* @returns {{since: string, basis: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function draftWindow(projectRoot, { days = null } = {}) {
|
|
37
|
+
if (days !== null) return { since: windowCutoff(days), basis: `the last ${days} days` };
|
|
38
|
+
const tag = lastTagDate(projectRoot);
|
|
39
|
+
if (tag) return { since: tag, basis: "changes archived since the last tag" };
|
|
40
|
+
return { since: windowCutoff(30), basis: "the last 30 days (no tag to measure from)" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Candidate bullets for the agent changelog, newest first.
|
|
45
|
+
*
|
|
46
|
+
* One candidate per archived change in the window that touched a documented
|
|
47
|
+
* surface. A change that touched none produces nothing — the block is for
|
|
48
|
+
* what an agent must do differently, and a change no agent can observe is
|
|
49
|
+
* not that.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} projectRoot
|
|
52
|
+
* @param {{days?: number|null, max?: number}} [options]
|
|
53
|
+
* @returns {{
|
|
54
|
+
* since: string, basis: string, max: number,
|
|
55
|
+
* candidates: {id: string, date: string, title: string, signals: string[], bullet: string}[],
|
|
56
|
+
* silent: {id: string, date: string, title: string}[],
|
|
57
|
+
* truncated: number
|
|
58
|
+
* }}
|
|
59
|
+
*/
|
|
60
|
+
export function draftAgentChangelog(projectRoot, { days = null, max = AGENT_CHANGELOG_MAX_BULLETS } = {}) {
|
|
61
|
+
const { since, basis } = draftWindow(projectRoot, { days });
|
|
62
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
63
|
+
|
|
64
|
+
const candidates = [];
|
|
65
|
+
const silent = [];
|
|
66
|
+
// The ledger is the record of what landed and when (change 0046); the
|
|
67
|
+
// archived folder is where the change's own artifacts still are.
|
|
68
|
+
const landed = readLedger(projectRoot).entries
|
|
69
|
+
.filter((e) => e.kind === "archived" && e.date >= since)
|
|
70
|
+
.sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
|
|
71
|
+
|
|
72
|
+
for (const entry of landed) {
|
|
73
|
+
const dir = path.join(archiveDir, `${entry.date}-${entry.id}`);
|
|
74
|
+
const signals = isDir(dir) ? documentedSurfaceSignals(dir, projectRoot) : [];
|
|
75
|
+
if (signals.length === 0) {
|
|
76
|
+
silent.push({ id: entry.id, date: entry.date, title: entry.title });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
candidates.push({
|
|
80
|
+
id: entry.id,
|
|
81
|
+
date: entry.date,
|
|
82
|
+
title: entry.title,
|
|
83
|
+
signals,
|
|
84
|
+
bullet: `${entry.title} (${signals.join("; ")}).`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const truncated = Math.max(0, candidates.length - max);
|
|
89
|
+
return { since, basis, max, candidates: candidates.slice(0, max), silent, truncated };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The draft, rendered as the block a human edits into lib/commands.js.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately shaped as the literal it will become, so the step is "cut and
|
|
96
|
+
* reword", not "translate". The bullets are drafts of the SHAPE — a title and
|
|
97
|
+
* the surface it touched — and every one of them is expected to be rewritten
|
|
98
|
+
* into what an agent must now do.
|
|
99
|
+
*
|
|
100
|
+
* @param {ReturnType<typeof draftAgentChangelog>} draft
|
|
101
|
+
* @param {string} version
|
|
102
|
+
* @returns {string[]} lines
|
|
103
|
+
*/
|
|
104
|
+
export function renderDraft(draft, version) {
|
|
105
|
+
const out = [];
|
|
106
|
+
out.push(`# Agent changelog draft for ${version} — ${draft.basis} (since ${draft.since})`);
|
|
107
|
+
out.push("");
|
|
108
|
+
if (draft.candidates.length === 0) {
|
|
109
|
+
out.push("No archived change in this window touched a documented surface.");
|
|
110
|
+
out.push("Nothing to tell an arriving agent — which is a valid answer, not an empty one.");
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
out.push("Proposed, newest first. REWRITE each into what an agent must now DO;");
|
|
114
|
+
out.push("this draft only knows what surface the change touched.");
|
|
115
|
+
out.push("");
|
|
116
|
+
out.push(` "${version}": [`);
|
|
117
|
+
for (const cand of draft.candidates) {
|
|
118
|
+
out.push(` // ${cand.date} ${cand.id}`);
|
|
119
|
+
out.push(` ${JSON.stringify(cand.bullet)},`);
|
|
120
|
+
}
|
|
121
|
+
out.push(" ],");
|
|
122
|
+
if (draft.truncated > 0) {
|
|
123
|
+
out.push("");
|
|
124
|
+
out.push(`${draft.truncated} further candidate${draft.truncated === 1 ? "" : "s"} did not fit the ${draft.max}-bullet cap.`);
|
|
125
|
+
out.push("The cap is AGENTS.md's line budget, not a preference: choose, do not raise it.");
|
|
126
|
+
}
|
|
127
|
+
if (draft.silent.length > 0) {
|
|
128
|
+
out.push("");
|
|
129
|
+
out.push(`${draft.silent.length} change${draft.silent.length === 1 ? "" : "s"} touched no documented surface and are not proposed:`);
|
|
130
|
+
for (const s of draft.silent) out.push(` ${s.date} ${s.id}`);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
4
|
+
import { c } from "./colors.js";
|
|
5
|
+
import { checklistProgress, isUntouchedScaffold } from "./doc-model.js";
|
|
6
|
+
import { collectBudgets } from "./runtime.js";
|
|
7
|
+
import { extractOps, applyOps } from "./spec-ops.js";
|
|
8
|
+
|
|
9
|
+
// The structural ANALYSIS of a change folder, as data.
|
|
10
|
+
//
|
|
11
|
+
// `lib/gates.js` enforces these findings on every lifecycle transition, so
|
|
12
|
+
// the library depended on a command module to compute them — a lib->command
|
|
13
|
+
// edge that made the dependency graph a cycle waiting to happen (audit
|
|
14
|
+
// finding F7). The findings are what the gate map is built on; they belong
|
|
15
|
+
// here, and `commands/analyze.js` is the renderer that prints them.
|
|
16
|
+
|
|
17
|
+
// The structural findings for a change, as data. Exported so the shared
|
|
18
|
+
// gate map (`lib/gates.js`) can enforce the SAME checks on every
|
|
19
|
+
// transition that needs them, instead of each command deciding for itself
|
|
20
|
+
// — `change apply` used to mutate a change that `analyze` had just
|
|
21
|
+
// refused (audit item C6).
|
|
22
|
+
export function collectAnalysis(projectRoot, changeDir) {
|
|
23
|
+
const results = [];
|
|
24
|
+
|
|
25
|
+
// proposal.md
|
|
26
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
27
|
+
if (!isFile(proposalPath)) {
|
|
28
|
+
results.push(fail("proposal.md missing"));
|
|
29
|
+
} else {
|
|
30
|
+
results.push(pass("proposal.md present"));
|
|
31
|
+
const text = read(proposalPath);
|
|
32
|
+
if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
|
|
33
|
+
else results.push(fail(`proposal.md missing "## Why" section`));
|
|
34
|
+
|
|
35
|
+
// A section that still holds only its scaffold comment was never
|
|
36
|
+
// written. The check above only proved the HEADING survived, which is
|
|
37
|
+
// why six changes closed in one session with every rationale section
|
|
38
|
+
// empty: the planning step wrote to the file with a pattern that did
|
|
39
|
+
// not match its line endings, and nothing downstream looked inside.
|
|
40
|
+
// For a framework whose whole premise is recoverable provenance, an
|
|
41
|
+
// archived change that cannot say why it happened is the defect.
|
|
42
|
+
// Only `Why` and `What` are required. They are what makes a change
|
|
43
|
+
// recoverable a year later — the reason it exists and the shape it
|
|
44
|
+
// took. `Scope boundaries` and `Open questions` are legitimately empty
|
|
45
|
+
// on a change that has neither, and demanding the word "None." there
|
|
46
|
+
// is friction that buys nothing.
|
|
47
|
+
const hollow = [];
|
|
48
|
+
for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
|
|
49
|
+
const heading = m[1].trim();
|
|
50
|
+
if (!/^(Why|What)$/i.test(heading)) continue;
|
|
51
|
+
const start = m.index + m[0].length;
|
|
52
|
+
const next = text.slice(start).search(/^##\s+/m);
|
|
53
|
+
const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
|
|
54
|
+
// Strip comments; anything left that is not whitespace is real prose.
|
|
55
|
+
if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
|
|
56
|
+
}
|
|
57
|
+
if (hollow.length > 0) {
|
|
58
|
+
results.push(fail(
|
|
59
|
+
`proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
|
|
60
|
+
`(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
|
|
61
|
+
));
|
|
62
|
+
} else {
|
|
63
|
+
results.push(pass("proposal.md states why and what"));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// tasks.md
|
|
68
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
69
|
+
if (!isFile(tasksPath)) {
|
|
70
|
+
results.push(fail("tasks.md missing"));
|
|
71
|
+
} else {
|
|
72
|
+
results.push(pass("tasks.md present"));
|
|
73
|
+
const text = read(tasksPath);
|
|
74
|
+
// Scaffold placeholders never replaced ("- [ ]" with no text, checked or
|
|
75
|
+
// not) mean the change was OPENED but never PLANNED — the agent went
|
|
76
|
+
// straight to implementing on a hollow change and nothing barked
|
|
77
|
+
// (operator report 2026-07-19). A hard failure here blocks `change
|
|
78
|
+
// check` and `close` until the plan is real; ticking an empty box does
|
|
79
|
+
// not help, `change tick` refuses those too.
|
|
80
|
+
const placeholders = checklistProgress(text).placeholders;
|
|
81
|
+
if (placeholders > 0) {
|
|
82
|
+
results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
|
|
83
|
+
}
|
|
84
|
+
if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
|
|
85
|
+
else results.push(info("tasks.md has no unchecked tasks (already done?)"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// design.md (optional)
|
|
89
|
+
const designPath = path.join(changeDir, "design.md");
|
|
90
|
+
if (isFile(designPath)) results.push(info("design.md present"));
|
|
91
|
+
else results.push(info("design.md absent (optional)"));
|
|
92
|
+
|
|
93
|
+
// Spec deltas
|
|
94
|
+
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
95
|
+
if (deltaFiles.length === 0) {
|
|
96
|
+
results.push(info("0 spec deltas (metadata-only change)"));
|
|
97
|
+
} else {
|
|
98
|
+
results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
|
|
99
|
+
for (const deltaPath of deltaFiles) {
|
|
100
|
+
const rel = relPath(projectRoot, deltaPath);
|
|
101
|
+
const text = read(deltaPath);
|
|
102
|
+
const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
103
|
+
const op = opMatch ? opMatch[1] : null;
|
|
104
|
+
const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
105
|
+
const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
|
|
106
|
+
|
|
107
|
+
if (!op) {
|
|
108
|
+
results.push(fail(` ${rel}: Operation header missing or malformed`));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
|
|
112
|
+
results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
116
|
+
const targetRel = relPath(projectRoot, targetSpec);
|
|
117
|
+
if (op === "ADDED") {
|
|
118
|
+
// Mirrors `change apply`: an existing target that is still the
|
|
119
|
+
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
120
|
+
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
121
|
+
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
122
|
+
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, "pre-apply"));
|
|
123
|
+
} else if (exists(targetSpec)) {
|
|
124
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
|
|
125
|
+
} else {
|
|
126
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
|
|
127
|
+
}
|
|
128
|
+
} else if (op === "MODIFIED") {
|
|
129
|
+
if (!exists(targetSpec)) {
|
|
130
|
+
results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, "pre-apply"));
|
|
131
|
+
} else {
|
|
132
|
+
results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
|
|
133
|
+
// THE OPS, EXECUTED (third audit, finding 2). Until now this said
|
|
134
|
+
// "ready to apply" for a delta whose ops block `apply` then refused:
|
|
135
|
+
// the pre-flight inspected everything about the delta except the one
|
|
136
|
+
// part that does the work. `change check` already ran this dry-run,
|
|
137
|
+
// so the answer existed — the gate `close` and `apply` consult was
|
|
138
|
+
// simply not the one asking.
|
|
139
|
+
//
|
|
140
|
+
// Scoped pre-apply: after a successful apply the target holds what
|
|
141
|
+
// these ops just wrote, and re-running them against it is a question
|
|
142
|
+
// with no meaning. `archive`'s integrity gate excludes this scope for
|
|
143
|
+
// exactly that reason.
|
|
144
|
+
for (const line of opsFindings(read(targetSpec), text, rel)) results.push(line);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
if (!exists(targetSpec)) {
|
|
148
|
+
results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, "pre-apply"));
|
|
149
|
+
} else {
|
|
150
|
+
results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
|
|
157
|
+
// a preference — and the tempting fix for "the output blew the limit" is
|
|
158
|
+
// to raise the limit. That buys headroom by truncating what mattered
|
|
159
|
+
// instead of sending less, and it is the discussion that comes back every
|
|
160
|
+
// quarter because nothing ever recorded that it had been settled. Raising
|
|
161
|
+
// an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
|
|
162
|
+
// resolve an overflow is the move this refuses.
|
|
163
|
+
for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
|
|
164
|
+
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Compare the ceilings a change's proposal/deltas propose against the ones
|
|
169
|
+
// the contracts declare today. Text-scanned: a change states a new value in
|
|
170
|
+
// prose long before any code moves, which is exactly when it should be
|
|
171
|
+
// argued about.
|
|
172
|
+
function checkBudgetRaises(projectRoot, changeDir) {
|
|
173
|
+
const results = [];
|
|
174
|
+
let declared;
|
|
175
|
+
try {
|
|
176
|
+
declared = collectBudgets(projectRoot);
|
|
177
|
+
} catch {
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
180
|
+
if (declared.size === 0) return results;
|
|
181
|
+
|
|
182
|
+
const texts = [];
|
|
183
|
+
for (const f of walk(changeDir)) {
|
|
184
|
+
if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [name, budget] of declared) {
|
|
188
|
+
if (budget.direction !== "output") continue;
|
|
189
|
+
for (const { rel, text } of texts) {
|
|
190
|
+
// "<limit> ... <number>" on one line: the shape a proposal uses when
|
|
191
|
+
// it restates a ceiling ("raise ai-summary to 2000").
|
|
192
|
+
const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
|
|
193
|
+
for (const m of text.matchAll(re)) {
|
|
194
|
+
const proposed = Number.parseInt(m[1], 10);
|
|
195
|
+
if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
|
|
196
|
+
results.push(fail(
|
|
197
|
+
`${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
|
|
198
|
+
`(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
|
|
199
|
+
`truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
|
|
200
|
+
));
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return results;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A limit name comes from a contract table cell, so it is user text and
|
|
209
|
+
// must not be spliced into a pattern unescaped.
|
|
210
|
+
function escapeRe(s) {
|
|
211
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run a MODIFIED delta's ops block against its target spec IN MEMORY and
|
|
216
|
+
* report what `apply` would report.
|
|
217
|
+
*
|
|
218
|
+
* The whole finding behind this: `apply` executes these ops all-or-none and
|
|
219
|
+
* refuses on the first error, so an unappliable block is knowable before
|
|
220
|
+
* anything is touched — and the pre-flight was not looking. A delta with no
|
|
221
|
+
* ops block is not a failure: `apply` prints a manual-merge pointer for it,
|
|
222
|
+
* and saying so here is what lets a close be planned around it rather than
|
|
223
|
+
* surprised by it.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Array<{kind: string, line: string, scope?: string}>}
|
|
226
|
+
*/
|
|
227
|
+
function opsFindings(specText, deltaText, rel) {
|
|
228
|
+
const ops = extractOps(deltaText);
|
|
229
|
+
if (ops.length === 0) {
|
|
230
|
+
return [info(` ${rel}: no ops block — apply will print a manual-merge pointer`)];
|
|
231
|
+
}
|
|
232
|
+
const result = applyOps(specText, ops);
|
|
233
|
+
if (result.errors.length === 0) {
|
|
234
|
+
const n = result.applied.length;
|
|
235
|
+
return [pass(` ${rel}: ${n} op${n === 1 ? "" : "s"} would apply cleanly`)];
|
|
236
|
+
}
|
|
237
|
+
return [
|
|
238
|
+
fail(` ${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} ` +
|
|
239
|
+
`(apply would refuse): ${result.errors.join("; ")}`, "pre-apply"),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function pass(msg) {
|
|
244
|
+
return { kind: "pass", line: c.green("✓ ") + msg };
|
|
245
|
+
}
|
|
246
|
+
// `scope` says WHEN a failure is a real answer.
|
|
247
|
+
//
|
|
248
|
+
// Most of these questions are true at every point in a change's life: a
|
|
249
|
+
// proposal with an unwritten `## What` is hollow before an apply and just as
|
|
250
|
+
// hollow after one. A few are only meaningful BEFORE the apply — "an ADDED
|
|
251
|
+
// delta's target must not already hold real content" is proof of a problem
|
|
252
|
+
// beforehand and proof the apply WORKED afterwards.
|
|
253
|
+
//
|
|
254
|
+
// The gate map used to exclude the whole structure gate from `archive` to
|
|
255
|
+
// dodge that handful, which also dropped the hollow-proposal check — the
|
|
256
|
+
// exact defect this repository's own history records as having "shipped six
|
|
257
|
+
// hollow proposals past every gate". Tagging the pre-apply ones lets
|
|
258
|
+
// `archive` keep the rest.
|
|
259
|
+
function fail(msg, scope = "always") {
|
|
260
|
+
return { kind: "fail", scope, line: c.red("✗ ") + msg };
|
|
261
|
+
}
|
|
262
|
+
function info(msg) {
|
|
263
|
+
return { kind: "info", line: c.gray("- ") + msg };
|
|
264
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Scaffolding a change folder.
|
|
3
|
+
//
|
|
4
|
+
// `work` opens a change as its first act and called into `commands/change.js`
|
|
5
|
+
// to do it (audit finding F7). Creating the folder is an OPERATION over the
|
|
6
|
+
// tree, not a rendering of the `change` command, so both entry points perform
|
|
7
|
+
// it from here.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "./fs-ops.js";
|
|
11
|
+
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "./templates.js";
|
|
12
|
+
import * as idx from "./index-json.js";
|
|
13
|
+
import { today } from "./dates.js";
|
|
14
|
+
import { flagBool, flagString } from "./args.js";
|
|
15
|
+
import { c } from "./colors.js";
|
|
16
|
+
import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
|
|
17
|
+
import { changeEntry } from "./scan.js";
|
|
18
|
+
export function changeNew(args, flags) {
|
|
19
|
+
const id = args[0];
|
|
20
|
+
const title = args.slice(1).join(" ").trim();
|
|
21
|
+
if (!id) {
|
|
22
|
+
console.error(c.red("error:") + " change new requires <id> and \"<title>\"");
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
if (!title) {
|
|
26
|
+
console.error(c.red("error:") + " change new requires a title (quote it if it contains spaces)");
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
// The id names a DIRECTORY, so it obeys the same grammar every other
|
|
30
|
+
// authoring command enforces on its argument (third audit, finding 1).
|
|
31
|
+
// Without this `change new ../../../elsewhere/evil` scaffolded outside the
|
|
32
|
+
// project, and `0003-com espaco` was accepted here and then carried by
|
|
33
|
+
// `validate`, `index rebuild` and `next` as a legitimate id.
|
|
34
|
+
if (!isChangeId(id)) {
|
|
35
|
+
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
36
|
+
console.error(c.gray("hint: ") + "ids look like `0042-short-slug`; `doctrina work \"<prompt>\"` derives one for you");
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const force = flagBool(flags, "force", false);
|
|
41
|
+
// A chore is a spec-less change (infra / docs / build / migration) — review
|
|
42
|
+
// G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
|
|
43
|
+
// history shows it) without forcing a fake spec delta. The empty specs/ dir
|
|
44
|
+
// is kept so `change apply`'s zero-delta path flips it to applied unchanged.
|
|
45
|
+
const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
|
|
46
|
+
const projectRoot = process.cwd();
|
|
47
|
+
ensureDoctrinaProject(projectRoot);
|
|
48
|
+
|
|
49
|
+
// Belt and braces: the grammar above already refuses a traversing id, and
|
|
50
|
+
// this holds if a future caller reaches this line another way.
|
|
51
|
+
const changeDir = resolveWithinProject(projectRoot, ".doctrina", "changes", id);
|
|
52
|
+
if (exists(changeDir) && !force) {
|
|
53
|
+
console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const templatesDir = locateTemplatesDir();
|
|
58
|
+
const date = today();
|
|
59
|
+
const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
|
|
60
|
+
|
|
61
|
+
// design.md is opt-in (--design): in practice it scaffolded on every change
|
|
62
|
+
// and stayed empty — nine changes out of nine in the 0.11.0 field review. A
|
|
63
|
+
// change that needs a design doc asks for one; the rest stop carrying a
|
|
64
|
+
// blank file through apply/archive/ledger.
|
|
65
|
+
const wantDesign = flagBool(flags, "design", false);
|
|
66
|
+
const tree = loadTemplateTree(templatesDir, "change");
|
|
67
|
+
for (const entry of tree) {
|
|
68
|
+
if (entry.relativePath === "spec-delta.md.template") continue;
|
|
69
|
+
if (entry.relativePath === "design.md.template" && !wantDesign) continue;
|
|
70
|
+
const written = materialiseEntry(entry, changeDir, tokens, { force });
|
|
71
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
72
|
+
}
|
|
73
|
+
// No empty `specs/` here (change 0081). The directory used to be created
|
|
74
|
+
// unconditionally as somewhere "ready" for deltas, and when the CLI could
|
|
75
|
+
// not name a capability — a new prompt, a project whose specs do not match
|
|
76
|
+
// — nothing was ever written into it. An empty directory is not an absence:
|
|
77
|
+
// it says deltas live here, or lived here, and the next reader spends
|
|
78
|
+
// attention checking. The delta writer creates its own path when there is
|
|
79
|
+
// a delta to put in it.
|
|
80
|
+
|
|
81
|
+
// Stamp the proposal so a chore is honest in the artifact, not just the CLI.
|
|
82
|
+
if (chore) {
|
|
83
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
84
|
+
if (exists(proposalPath)) {
|
|
85
|
+
const txt = read(proposalPath);
|
|
86
|
+
const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
|
|
87
|
+
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Derive the entry from the proposal on disk rather than assembling one
|
|
92
|
+
// here: a field the deriver knows about and this writer does not is index
|
|
93
|
+
// drift the moment the change is opened (change 0076 — that is exactly what
|
|
94
|
+
// `lane` did). One constructor, in `scan.js`, for one record shape.
|
|
95
|
+
reindexChange(projectRoot, id, date);
|
|
96
|
+
|
|
97
|
+
console.log("");
|
|
98
|
+
if (chore) {
|
|
99
|
+
console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
|
|
100
|
+
c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
|
|
101
|
+
} else {
|
|
102
|
+
console.log(c.bold("Change opened.") + " Add spec deltas under " +
|
|
103
|
+
c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Register — or re-derive — one open change's index entry from its proposal.
|
|
110
|
+
*
|
|
111
|
+
* `work` writes the proposal in two passes: `changeNew` scaffolds it, then the
|
|
112
|
+
* command stamps the lane it classified and the specs it pinned. Indexing at
|
|
113
|
+
* the end of the FIRST pass reads a proposal that is not finished yet, which
|
|
114
|
+
* is why every `doctrina work` used to be followed by a `validate` error and
|
|
115
|
+
* an `index rebuild` nobody asked for. Calling this again after the stamping
|
|
116
|
+
* costs one file write and makes the tree honest on the next command.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} projectRoot
|
|
119
|
+
* @param {string} id The change id (its directory name).
|
|
120
|
+
* @param {string} [date] Fallback for a proposal carrying no Date header.
|
|
121
|
+
*/
|
|
122
|
+
export function reindexChange(projectRoot, id, date = today()) {
|
|
123
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
124
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
125
|
+
const index = idx.load(projectRoot);
|
|
126
|
+
const prev = index.artifacts.changes.find((ch) => ch.id === id) ?? null;
|
|
127
|
+
index.artifacts.changes = index.artifacts.changes.filter((ch) => ch.id !== id);
|
|
128
|
+
index.artifacts.changes.push(changeEntry(proposal, id, prev, date));
|
|
129
|
+
idx.touch(index, date);
|
|
130
|
+
idx.save(projectRoot, index);
|
|
131
|
+
}
|
package/src/lib/commands.js
CHANGED
|
@@ -20,7 +20,7 @@ export const COMMAND_NAMES = [
|
|
|
20
20
|
// gates
|
|
21
21
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
22
22
|
// maintenance
|
|
23
|
-
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
|
|
23
|
+
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
// The full operation surface — every `doctrina <command> [<subcommand>]` a
|
|
@@ -93,6 +93,7 @@ export const OPERATIONS = [
|
|
|
93
93
|
["metrics", "Local git-derived adoption metrics (no network)"],
|
|
94
94
|
["report", "Markdown digest for a period: changes, gates, git summary"],
|
|
95
95
|
["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
|
|
96
|
+
["ci", "Emit the CI pipeline for the declared gate sequence (--emit github)"],
|
|
96
97
|
["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
|
|
97
98
|
];
|
|
98
99
|
|
|
@@ -118,7 +119,7 @@ export const COMMAND_META = {
|
|
|
118
119
|
intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
|
|
119
120
|
adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
|
|
120
121
|
|
|
121
|
-
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates,
|
|
122
|
+
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, open work and next steps in one read (--rules for the full standing rules)" },
|
|
122
123
|
status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
|
|
123
124
|
next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
|
|
124
125
|
context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
|
|
@@ -131,7 +132,7 @@ export const COMMAND_META = {
|
|
|
131
132
|
triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
|
|
132
133
|
work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
|
|
133
134
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
134
|
-
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick /
|
|
135
|
+
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check (--verbose) / tick / abandon" },
|
|
135
136
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
136
137
|
contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
|
|
137
138
|
intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
|
|
@@ -155,6 +156,7 @@ export const COMMAND_META = {
|
|
|
155
156
|
metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
|
|
156
157
|
report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
|
|
157
158
|
completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
|
|
159
|
+
ci: { moment: "Maintain", when: "the declared gate sequence changed and CI must follow", purpose: "emit the CI pipeline from the gate declaration" },
|
|
158
160
|
};
|
|
159
161
|
|
|
160
162
|
// The order moments appear in the generated block.
|
|
@@ -195,10 +197,48 @@ const SURFACE_HINTS = {
|
|
|
195
197
|
clarify: "--all (--lang pt|en)",
|
|
196
198
|
close: "<id...>",
|
|
197
199
|
upgrade: "--write",
|
|
200
|
+
ci: "--emit github",
|
|
198
201
|
prime: "(session start)",
|
|
199
202
|
handoff: "(before compaction/handover)",
|
|
200
203
|
};
|
|
201
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Deprecations (change 0049).
|
|
207
|
+
//
|
|
208
|
+
// The surface has a hard 40-line budget precisely so that adding a command
|
|
209
|
+
// forces the question "what comes off?" — and for two releases the answer was
|
|
210
|
+
// "nothing", while the budget was met by compressing a whole moment onto one
|
|
211
|
+
// line (audit finding F23). These are the first two removals, and both are
|
|
212
|
+
// MERGES: the surviving command already does everything the retired one did,
|
|
213
|
+
// which is why they can go without waiting for usage evidence. Usage counts
|
|
214
|
+
// can tell you a command is unloved; only redundancy can tell you it is
|
|
215
|
+
// unnecessary, and an unloved command that does something nothing else does
|
|
216
|
+
// has to stay.
|
|
217
|
+
//
|
|
218
|
+
// A deprecated operation keeps working, warns once on the way through, and
|
|
219
|
+
// leaves the surface block — an agent should not reach for it, and the block
|
|
220
|
+
// is the list of things to reach for. Removal is a later, separate change.
|
|
221
|
+
export const DEPRECATED = Object.freeze({
|
|
222
|
+
"constitution": {
|
|
223
|
+
since: "0.16.0",
|
|
224
|
+
use: "doctrina prime --rules",
|
|
225
|
+
why: "prime renders the same standing-rules view, from the same collection",
|
|
226
|
+
},
|
|
227
|
+
"change diff": {
|
|
228
|
+
since: "0.16.0",
|
|
229
|
+
use: "doctrina change check --verbose",
|
|
230
|
+
why: "check runs every ops block against the target spec and now prints the same per-delta preview",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** The deprecation record for an invocation, or null. */
|
|
235
|
+
export function deprecationFor(argv) {
|
|
236
|
+
const words = argv.filter((a) => !a.startsWith("-"));
|
|
237
|
+
const two = words.slice(0, 2).join(" ");
|
|
238
|
+
const one = words[0] ?? "";
|
|
239
|
+
return DEPRECATED[two] ?? DEPRECATED[one] ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
// The declared size budget for the generated block. A surface that cannot
|
|
203
243
|
// describe itself in this many lines is too large — the answer is to cut
|
|
204
244
|
// commands (M8), never to raise the number.
|
|
@@ -219,8 +259,13 @@ const COMPACT_MOMENTS = new Set(["Maintain"]);
|
|
|
219
259
|
// a name index never told it. Same shape `context` prints for skills —
|
|
220
260
|
// description plus `when:`, body on demand.
|
|
221
261
|
export function surfaceMarkdown() {
|
|
262
|
+
// A deprecated operation stays in the catalog — `--help` and the shell
|
|
263
|
+
// completions must still know the name someone types — but leaves this
|
|
264
|
+
// block: it is the list of commands to REACH FOR, and pointing an agent at
|
|
265
|
+
// a name that warns when used is worse than not listing it.
|
|
222
266
|
const subsByCmd = new Map();
|
|
223
267
|
for (const [op] of OPERATIONS) {
|
|
268
|
+
if (DEPRECATED[op]) continue;
|
|
224
269
|
const [cmd, sub] = op.split(" ");
|
|
225
270
|
if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
|
|
226
271
|
if (sub) subsByCmd.get(cmd).push(sub);
|
|
@@ -242,7 +287,7 @@ export function surfaceMarkdown() {
|
|
|
242
287
|
"canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
|
|
243
288
|
];
|
|
244
289
|
for (const moment of MOMENTS) {
|
|
245
|
-
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
|
|
290
|
+
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment && !DEPRECATED[n]);
|
|
246
291
|
if (cmds.length === 0) continue;
|
|
247
292
|
if (COMPACT_MOMENTS.has(moment)) {
|
|
248
293
|
// Each name still carries the `doctrina ` prefix: the compact line is
|
|
@@ -405,6 +450,13 @@ export const AGENT_CHANGELOG = {
|
|
|
405
450
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
406
451
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
407
452
|
// the minor that introduced the commands, and never silently erases it.
|
|
453
|
+
"0.16.0": [
|
|
454
|
+
"A reference or path that does not resolve now answers exit 2 (USAGE) everywhere — `clarify`, `show`, `why`, `analyze`, `context`, `change check`, `spec set`, `decision accept|scope`. Correct the invocation; retrying it unchanged never succeeds.",
|
|
455
|
+
"The `--json` envelope names the OPERATION in `command` and carries arguments in `args` — branch on those two, not on a joined string. An undeclared flag now answers with `{ok: false, exit_code: 2}` instead of an empty stdout.",
|
|
456
|
+
"`analyze` executes a MODIFIED delta's `ops` block, so `analyze`, `change apply` and `close` all refuse the same unappliable block. Fix the ops before closing; a delta with no ops block is still a legitimate manual merge.",
|
|
457
|
+
"`validate` refuses more: an active spec declaring no acceptance criteria, an empty artifact, a scaffold section left unwritten, a reference that resolves to nothing. Write the criteria before flipping a spec to `active`.",
|
|
458
|
+
"`change new <id>` requires the `NNNN-slug` shape (lowercase letters, digits, hyphens) and writes nothing otherwise — `doctrina work \"<prompt>\"` derives one for you.",
|
|
459
|
+
],
|
|
408
460
|
"0.15.1": [
|
|
409
461
|
"Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
|
|
410
462
|
],
|