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
|
@@ -9,7 +9,7 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./
|
|
|
9
9
|
import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
|
|
10
10
|
import { readTemplate } from "./templates.js";
|
|
11
11
|
import { PLAYBOOKS } from "./playbook.js";
|
|
12
|
-
import { COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
|
|
12
|
+
import { COMMAND_META, COMMAND_NAMES, DEPRECATED, REMOVED, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
|
|
13
13
|
import { declaredBudget } from "./runtime.js";
|
|
14
14
|
import { agentsMdBudget } from "./validation-model.js";
|
|
15
15
|
/**
|
|
@@ -80,6 +80,16 @@ export const PRODUCT_SECTIONS = [
|
|
|
80
80
|
"## Success criteria",
|
|
81
81
|
];
|
|
82
82
|
|
|
83
|
+
// The first deprecated or removed operation a text tells you to run, as
|
|
84
|
+
// `doctrina <op>`, or null.
|
|
85
|
+
function retiredCommandIn(text) {
|
|
86
|
+
for (const op of [...Object.keys(DEPRECATED), ...Object.keys(REMOVED)]) {
|
|
87
|
+
const name = op.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
if (new RegExp(`doctrina ${name}(?![a-z-])`).test(text)) return op;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
// The findings `templates check` reports, as structured records with an
|
|
84
94
|
// executable remedy. Exported so a test can seed each finding, run the
|
|
85
95
|
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
@@ -111,16 +121,16 @@ export function collectFindings(projectRoot) {
|
|
|
111
121
|
: ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
|
|
112
122
|
`AGENTS.md has ${budget.slack} left of its declared ceiling`),
|
|
113
123
|
remedy: budget.fits
|
|
114
|
-
? "doctrina
|
|
115
|
-
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina
|
|
124
|
+
? "doctrina upgrade --write"
|
|
125
|
+
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina upgrade --write\``,
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
128
|
// Command-surface block: present and current vs the installed catalog.
|
|
119
129
|
const block = findSurfaceBlock(text);
|
|
120
130
|
if (!block) {
|
|
121
|
-
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina
|
|
131
|
+
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
|
|
122
132
|
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
123
|
-
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina
|
|
133
|
+
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
|
|
124
134
|
} else {
|
|
125
135
|
ok.push("AGENTS.md: doctrina:surface block current");
|
|
126
136
|
}
|
|
@@ -134,7 +144,7 @@ export function collectFindings(projectRoot) {
|
|
|
134
144
|
const text = read(productPath);
|
|
135
145
|
for (const heading of PRODUCT_SECTIONS) {
|
|
136
146
|
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
137
|
-
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina
|
|
147
|
+
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina upgrade --write" });
|
|
138
148
|
}
|
|
139
149
|
} else {
|
|
140
150
|
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
@@ -151,7 +161,24 @@ export function collectFindings(projectRoot) {
|
|
|
151
161
|
for (const file of spec.files) {
|
|
152
162
|
const installed = path.join(projectRoot, file.relativePath);
|
|
153
163
|
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
154
|
-
if (!isHubPointer(file))
|
|
164
|
+
if (!isHubPointer(file)) {
|
|
165
|
+
// A command shim is copied into the project once, so an upgrade
|
|
166
|
+
// of the CLI leaves it teaching whatever the CLI taught then: the
|
|
167
|
+
// 0.16 `/doctrina-work` still ran `analyze` → `change apply` after
|
|
168
|
+
// 0.17 deprecated one and handed the other to the close (change
|
|
169
|
+
// 0187). A shim naming a retired command is stale; the same rewrite
|
|
170
|
+
// from the template that repairs a pointer repairs it.
|
|
171
|
+
const retired = retiredCommandIn(read(installed));
|
|
172
|
+
if (retired) {
|
|
173
|
+
findings.push({
|
|
174
|
+
message: `adapter ${file.relativePath} still teaches \`doctrina ${retired}\`, which this CLI ${DEPRECATED[retired] ? "deprecated" : "removed"} — the agent running it follows the old flow`,
|
|
175
|
+
remedy: `doctrina adapter add ${name} --force`,
|
|
176
|
+
});
|
|
177
|
+
} else {
|
|
178
|
+
ok.push(`adapter ${file.relativePath}: teaches live commands only`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
155
182
|
if (read(installed).includes("AGENTS.md")) {
|
|
156
183
|
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
157
184
|
} else {
|
|
@@ -195,7 +222,7 @@ export function collectFindings(projectRoot) {
|
|
|
195
222
|
const idx = JSON.parse(read(indexPath));
|
|
196
223
|
for (const field of INDEX_FIELDS) {
|
|
197
224
|
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
198
|
-
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina
|
|
225
|
+
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina upgrade --write" });
|
|
199
226
|
}
|
|
200
227
|
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
201
228
|
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { readdirSync } from "node:fs";
|
|
18
18
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
|
|
19
|
+
import { fold, terms } from "./lexicon.js";
|
|
20
|
+
import { intakeStatusError } from "./intake-model.js";
|
|
19
21
|
import * as idx from "./index-json.js";
|
|
20
22
|
import { SCHEMA_VERSION } from "./index-json.js";
|
|
21
23
|
import { cliVersion, newestVersion } from "./version.js";
|
|
22
24
|
import { today } from "./dates.js";
|
|
23
|
-
import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue } from "./doc-model.js";
|
|
25
|
+
import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue, isScaffoldValue } from "./doc-model.js";
|
|
24
26
|
import { checkEars, isEarsSpec } from "./ears.js";
|
|
25
27
|
import { parseAdrScope, parseSourceGlobs, parseDependsOn, specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "./scan.js";
|
|
26
28
|
import { COMMAND_NAMES, referencedCommands, DEPRECATED } from "./commands.js";
|
|
@@ -144,6 +146,21 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
144
146
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
145
147
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
146
148
|
|
|
149
|
+
// 2b. intake.md's status, when there is one.
|
|
150
|
+
//
|
|
151
|
+
// `next` branches on this value to decide whether the bootstrap is done,
|
|
152
|
+
// and the playbook has the agent write it BY HAND — so it is the one
|
|
153
|
+
// control value in the tree that nothing read back. Anything but the two
|
|
154
|
+
// declared words meant "pending" in silence: "convertido" typed in a
|
|
155
|
+
// Portuguese project, or an empty value left by a botched edit, both left
|
|
156
|
+
// `next` asking forever for a bootstrap that had already happened.
|
|
157
|
+
// A spec's Status gets an enum and an error; so does this one.
|
|
158
|
+
const intakeMd = path.join(projectRoot, ".doctrina", "intake.md");
|
|
159
|
+
if (isFile(intakeMd)) {
|
|
160
|
+
const badStatus = intakeStatusError(read(intakeMd));
|
|
161
|
+
if (badStatus) errors.push(`.doctrina/intake.md: ${badStatus}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
// Header repair (M3). One grammar means a non-canonical header can be
|
|
148
165
|
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
149
166
|
// `- **Status:** x`, and a spec written with list-style headers is
|
|
@@ -435,7 +452,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
435
452
|
if (ph > 0) {
|
|
436
453
|
warnings.push(
|
|
437
454
|
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
438
|
-
`the change was opened but never planned (replace them with real tasks;
|
|
455
|
+
`the change was opened but never planned (replace them with real tasks; \`change check\` and the close refuse them)`,
|
|
439
456
|
);
|
|
440
457
|
}
|
|
441
458
|
}
|
|
@@ -443,10 +460,15 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
443
460
|
// capabilities a change touches, and a name with no spec behind it
|
|
444
461
|
// — a typo, or a capability that was never created — passed
|
|
445
462
|
// analyze, check and close in silence. A capability this change is
|
|
446
|
-
// about to CREATE (an ADDED delta) is not a ghost.
|
|
463
|
+
// about to CREATE (an ADDED delta) is not a ghost. Neither is the
|
|
464
|
+
// header's own "none": a chore is scaffolded with "(none — chore)",
|
|
465
|
+
// and reading its words as names warned twice on every chore (change
|
|
466
|
+
// 0190). A parenthesised aside is a note, not a capability.
|
|
447
467
|
if (isFile(proposal)) {
|
|
448
|
-
const
|
|
449
|
-
|
|
468
|
+
const header = (listHeader(read(proposal), "Affects specs") ?? "").trim();
|
|
469
|
+
const affects = /^\(?\s*(none|n\/a)\b/i.test(header)
|
|
470
|
+
? []
|
|
471
|
+
: header.replace(/\([^)]*\)/g, "").match(/[a-z][a-z0-9-]*/g) ?? [];
|
|
450
472
|
const specsRoot = path.join(projectRoot, ".doctrina", "specs");
|
|
451
473
|
for (const cap of affects) {
|
|
452
474
|
if (isFile(path.join(specsRoot, cap, "spec.md"))) continue;
|
|
@@ -464,12 +486,12 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
464
486
|
if (!m) {
|
|
465
487
|
warnings.push(
|
|
466
488
|
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
467
|
-
|
|
489
|
+
`\`change apply\` and the close will refuse it (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
468
490
|
);
|
|
469
491
|
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
470
492
|
warnings.push(
|
|
471
493
|
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
472
|
-
|
|
494
|
+
`\`change apply\` and the close will refuse it`,
|
|
473
495
|
);
|
|
474
496
|
}
|
|
475
497
|
}
|
|
@@ -732,10 +754,25 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
732
754
|
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
733
755
|
if (isFile(productPath)) {
|
|
734
756
|
const seen = new Map();
|
|
757
|
+
// The mirror case: ONE intent under TWO ids. Realize either and the
|
|
758
|
+
// other stays "dropped" forever, so `trace --strict` — a CI gate —
|
|
759
|
+
// fails on a gap nobody can close. Compared folded, so case, accents
|
|
760
|
+
// and spacing do not hide a twin.
|
|
761
|
+
const seenText = new Map();
|
|
735
762
|
const lines = read(productPath).split(/\r?\n/);
|
|
736
763
|
for (let i = 0; i < lines.length; i++) {
|
|
737
|
-
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s
|
|
764
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.*)$/);
|
|
738
765
|
if (!m) continue;
|
|
766
|
+
const key = intentKey(m[2]);
|
|
767
|
+
if (key && seenText.has(key) && seenText.get(key).id !== m[1]) {
|
|
768
|
+
const first = seenText.get(key);
|
|
769
|
+
warnings.push(
|
|
770
|
+
`.doctrina/product.md:${i + 1} intent [${m[1]}] states the same intent as [${first.id}] (line ${first.line}) — ` +
|
|
771
|
+
`realizing one leaves the other dropped, so \`trace --strict\` fails on a gap nobody can close; delete the twin`,
|
|
772
|
+
);
|
|
773
|
+
} else if (key && !seenText.has(key)) {
|
|
774
|
+
seenText.set(key, { id: m[1], line: i + 1 });
|
|
775
|
+
}
|
|
739
776
|
if (seen.has(m[1])) {
|
|
740
777
|
errors.push(
|
|
741
778
|
`.doctrina/product.md:${i + 1} intent anchor [${m[1]}] is declared twice (first at line ${seen.get(m[1])}) — ` +
|
|
@@ -972,11 +1009,11 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
972
1009
|
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
973
1010
|
|
|
974
1011
|
// 15. Skill description drift — index.json must mirror the
|
|
975
|
-
// frontmatter description (
|
|
1012
|
+
// frontmatter description (`doctrina index rebuild` does).
|
|
976
1013
|
if (descField && index?.artifacts?.skills) {
|
|
977
1014
|
const entry = index.artifacts.skills.find((s) => s.id === baseName);
|
|
978
1015
|
if (entry && entry.description !== descField) {
|
|
979
|
-
warnings.push(`${rel} description differs from index.json (run \`doctrina
|
|
1016
|
+
warnings.push(`${rel} description differs from index.json (run \`doctrina index rebuild\`)`);
|
|
980
1017
|
}
|
|
981
1018
|
}
|
|
982
1019
|
}
|
|
@@ -1012,7 +1049,9 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
1012
1049
|
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
1013
1050
|
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
1014
1051
|
// survives sessions because it is an artifact, not a memory.
|
|
1015
|
-
|
|
1052
|
+
const ruleFindings = checkProjectRules(projectRoot);
|
|
1053
|
+
for (const line of ruleFindings.errors) errors.push(line);
|
|
1054
|
+
for (const line of ruleFindings.warnings) warnings.push(line);
|
|
1016
1055
|
for (const key of loadConfig(projectRoot).unknown) {
|
|
1017
1056
|
warnings.push(`${CONFIG_REL}: unknown key "${key}" is ignored — the keys are language, context_budget, rules (a typo here is a setting that never applied)`);
|
|
1018
1057
|
}
|
|
@@ -1043,27 +1082,45 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
1043
1082
|
// something concrete — a path or glob, a command, a quoted error string, a
|
|
1044
1083
|
// file extension, an ALL_CAPS identifier, or simply enough distinctive
|
|
1045
1084
|
// keywords to match on.
|
|
1046
|
-
|
|
1085
|
+
// One list per language, because this framework runs on Portuguese projects
|
|
1086
|
+
// too — and the vagueness it exists to catch is written in the project's own
|
|
1087
|
+
// language. Change 0158 found the same split in `clarify`'s lexicons; this is
|
|
1088
|
+
// the same asymmetry one module over. Matched against the FOLDED text, so
|
|
1089
|
+
// "necessário" and "necessario" are one phrase.
|
|
1090
|
+
const VAGUE_TRIGGER_EN = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?|makes?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|sense|right|as needed|if needed)\.?$/i;
|
|
1091
|
+
const VAGUE_TRIGGER_PT =
|
|
1092
|
+
/^(?:(?:sempre\s+)?que|quando|se)?\s*(?:isso|isto|voce|o agente)?\s*(?:for|foi|parecer|fizer|achar|julgar)?\s*(?:relevante|apropriad[oa]|necessari[oa]|util|aplicavel|preciso|sentido|cert[oa])\.?$/i;
|
|
1047
1093
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1094
|
+
function looksVague(text) {
|
|
1095
|
+
const folded = fold(text).trim();
|
|
1096
|
+
if (folded === "") return true;
|
|
1097
|
+
return VAGUE_TRIGGER_EN.test(folded) || VAGUE_TRIGGER_PT.test(folded);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Owned by the document model since change 0177 (the rebuild needs it and
|
|
1101
|
+
// cannot import this module); re-exported so existing callers stay put.
|
|
1102
|
+
export { isScaffoldValue };
|
|
1103
|
+
|
|
1104
|
+
/** An intent's text, folded for comparison: case, accents and spacing ignored. */
|
|
1105
|
+
export function intentKey(text) {
|
|
1106
|
+
return fold(String(text ?? "")).replace(/\s+/g, " ").replace(/[.;:!\s]+$/, "").trim();
|
|
1051
1107
|
}
|
|
1052
1108
|
|
|
1053
1109
|
export function hasDetectableTrigger(when) {
|
|
1054
1110
|
const text = String(when ?? "").trim();
|
|
1055
|
-
if (
|
|
1111
|
+
if (looksVague(text)) return false;
|
|
1056
1112
|
// The scaffold's own placeholder has enough words to rank on and names
|
|
1057
1113
|
// nothing (change 0111): a trigger nobody wrote fires for nobody.
|
|
1058
1114
|
if (isScaffoldValue(text)) return false;
|
|
1059
1115
|
// Anything structural is inherently matchable.
|
|
1060
|
-
if (/[\/\]|\*|`|"|'|\.\w{2,4}
|
|
1061
|
-
// Otherwise: enough distinctive words to rank on
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1116
|
+
if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
|
|
1117
|
+
// Otherwise: enough distinctive words to rank on — counted by the SHARED
|
|
1118
|
+
// lexicon, which folds and carries the connective tissue of both languages.
|
|
1119
|
+
// The private stop list here was English-only, so every Portuguese function
|
|
1120
|
+
// word ("quando", "que", "para") counted as distinctive and a two-word
|
|
1121
|
+
// Portuguese phrase cleared the bar on grammar alone. One lexicon, the way
|
|
1122
|
+
// `work` and `context` already read text (audit findings F5/F6).
|
|
1123
|
+
return terms(text).length >= 2;
|
|
1067
1124
|
}
|
|
1068
1125
|
|
|
1069
1126
|
// Enforce the project's rules — permanent constraints as forbid-regexes over
|
|
@@ -1073,9 +1130,16 @@ export function hasDetectableTrigger(when) {
|
|
|
1073
1130
|
// "message": "white-label product; use a generic placeholder" } ] }
|
|
1074
1131
|
// Declared in .doctrina/config.json; still read from the legacy
|
|
1075
1132
|
// .doctrina/rules.json when that is where a project put them (change 0047).
|
|
1076
|
-
// Returns
|
|
1077
|
-
// mass violation stays readable)
|
|
1078
|
-
// file
|
|
1133
|
+
// Returns { errors, warnings }: an error per offending file+rule (capped per
|
|
1134
|
+
// rule so a mass violation stays readable), and a warning for a rule that
|
|
1135
|
+
// reached no file at all. No rules → nothing to enforce; a malformed file is
|
|
1136
|
+
// reported once, by the reader.
|
|
1137
|
+
//
|
|
1138
|
+
// The dead-scope warning is the same finding 8h makes about a `**Source:**`
|
|
1139
|
+
// glob and RT05 makes about a selector: a declaration that matches nothing
|
|
1140
|
+
// reads as coverage and provides none. A rule is the strongest of the three
|
|
1141
|
+
// — a permanent constraint the project believes is enforced — and it was the
|
|
1142
|
+
// one that said nothing, while `doctor` went on counting it as configured. Binary-ish and vendored dirs are
|
|
1079
1143
|
// skipped by the same bounded walk validate already uses elsewhere.
|
|
1080
1144
|
const RULES_SKIP_DIRS = new Set([
|
|
1081
1145
|
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
@@ -1086,6 +1150,7 @@ const RULES_MAX_HITS_PER_RULE = 10;
|
|
|
1086
1150
|
function checkProjectRules(projectRoot) {
|
|
1087
1151
|
const cfg = loadConfig(projectRoot);
|
|
1088
1152
|
const out = [...cfg.errors];
|
|
1153
|
+
const warn = [];
|
|
1089
1154
|
const where = cfg.sources.rules === SOURCES.config ? CONFIG_REL : RULES_REL;
|
|
1090
1155
|
const compiled = [];
|
|
1091
1156
|
for (const r of cfg.rules) {
|
|
@@ -1100,10 +1165,16 @@ function checkProjectRules(projectRoot) {
|
|
|
1100
1165
|
out.push(`${where}: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
1101
1166
|
continue;
|
|
1102
1167
|
}
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1168
|
+
const declared = Array.isArray(r.paths) && r.paths.length ? r.paths : null;
|
|
1169
|
+
const paths = declared ?? ["**"];
|
|
1170
|
+
compiled.push({
|
|
1171
|
+
id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp),
|
|
1172
|
+
message: r.message ?? "", hits: 0,
|
|
1173
|
+
// Only a DECLARED scope can be dead; the implicit `**` is not a claim.
|
|
1174
|
+
declared, scanned: 0,
|
|
1175
|
+
});
|
|
1105
1176
|
}
|
|
1106
|
-
if (compiled.length === 0) return out;
|
|
1177
|
+
if (compiled.length === 0) return { errors: out, warnings: warn };
|
|
1107
1178
|
|
|
1108
1179
|
// One bounded walk; each text file is read at most once.
|
|
1109
1180
|
const stack = [projectRoot];
|
|
@@ -1123,7 +1194,11 @@ function checkProjectRules(projectRoot) {
|
|
|
1123
1194
|
}
|
|
1124
1195
|
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
1125
1196
|
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
1126
|
-
|
|
1197
|
+
// Count the reach BEFORE the hit cap: a rule that stopped reporting
|
|
1198
|
+
// because it hit the cap has plainly reached files.
|
|
1199
|
+
const inScope = compiled.filter((cr) => cr.matchers.some((m) => m.test(rel)));
|
|
1200
|
+
for (const cr of inScope) cr.scanned += 1;
|
|
1201
|
+
const applicable = inScope.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE);
|
|
1127
1202
|
if (applicable.length === 0) continue;
|
|
1128
1203
|
let text;
|
|
1129
1204
|
try {
|
|
@@ -1145,8 +1220,15 @@ function checkProjectRules(projectRoot) {
|
|
|
1145
1220
|
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
1146
1221
|
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
1147
1222
|
}
|
|
1223
|
+
if (cr.declared && cr.scanned === 0) {
|
|
1224
|
+
warn.push(
|
|
1225
|
+
`${where}: rule "${cr.id}" scopes to ${cr.declared.map((g) => `\`${g}\``).join(", ")}, ` +
|
|
1226
|
+
"which matches no file — the constraint is declared and never enforced; " +
|
|
1227
|
+
"correct the paths or drop the rule",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1148
1230
|
}
|
|
1149
|
-
return out;
|
|
1231
|
+
return { errors: out, warnings: warn };
|
|
1150
1232
|
}
|
|
1151
1233
|
|
|
1152
1234
|
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
@@ -1168,9 +1250,9 @@ function globToRegExp(glob) {
|
|
|
1168
1250
|
// artifact whose recorded metadata no longer matches its file.
|
|
1169
1251
|
function metadataDrift(current, derived) {
|
|
1170
1252
|
const lines = [];
|
|
1171
|
-
// Skills are deliberately excluded: their description
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1253
|
+
// Skills are deliberately excluded: their description is reconciled by
|
|
1254
|
+
// `doctrina index rebuild` and stays an advisory warning (see section
|
|
1255
|
+
// 15), not a hard error.
|
|
1174
1256
|
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
1175
1257
|
for (const cat of cats) {
|
|
1176
1258
|
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
package/src/lib/views.js
CHANGED
|
@@ -222,6 +222,9 @@ export function handoff(s) {
|
|
|
222
222
|
out.push("## Open work");
|
|
223
223
|
out.push("");
|
|
224
224
|
out.push("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
|
|
225
|
+
// The note is pasted into another session, so the section has to END like
|
|
226
|
+
// the loop below ends: a heading glued to a list item is not a heading.
|
|
227
|
+
out.push("");
|
|
225
228
|
} else {
|
|
226
229
|
for (const ch of s.openChanges) {
|
|
227
230
|
out.push(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
|
|
@@ -405,6 +408,6 @@ export function report(s, { days = 7, cutoffIso = "", git = null, gitState = nul
|
|
|
405
408
|
}
|
|
406
409
|
|
|
407
410
|
out.push("");
|
|
408
|
-
out.push(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
|
|
411
|
+
out.push(`*Generated read-only by \`doctrina status --view report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
|
|
409
412
|
return out;
|
|
410
413
|
}
|
package/src/lib/work-model.js
CHANGED
|
@@ -44,6 +44,31 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
|
|
|
44
44
|
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The capabilities whose `Source:` header CLAIMS a file — ADR 0027's question
|
|
49
|
+
* asked directly, with no inference in the answer.
|
|
50
|
+
*
|
|
51
|
+
* `rankCapabilitiesByDiff` deliberately falls back to inferences so a project
|
|
52
|
+
* that has not declared anything still gets a hint. That makes it the wrong
|
|
53
|
+
* function to enforce the declaration with: a file no spec names still comes
|
|
54
|
+
* back with an owner, scored 5 instead of 10, and a test asking "does this
|
|
55
|
+
* have an owner" cannot tell the two apart. `lib/names.js` sat undeclared
|
|
56
|
+
* behind exactly that.
|
|
57
|
+
*/
|
|
58
|
+
export function declaredOwners(projectRoot, file) {
|
|
59
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
60
|
+
if (!isDir(specsDir)) return [];
|
|
61
|
+
const norm = String(file).replace(/\\/g, "/");
|
|
62
|
+
const owners = [];
|
|
63
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
64
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
65
|
+
if (!isFile(specPath)) continue;
|
|
66
|
+
const globs = parseSourceGlobs(read(specPath)).map((g) => globToRegExp(g));
|
|
67
|
+
if (globs.some((re) => re.test(norm))) owners.push(cap);
|
|
68
|
+
}
|
|
69
|
+
return owners;
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
// Deterministic term overlap: fold prompt and spec text with the SHARED
|
|
48
73
|
// lexicon, then score. This is a hint for the agent, never a decision
|
|
49
74
|
// (ADR 0005).
|
|
@@ -46,13 +46,12 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
|
|
|
46
46
|
- `doctrina triage` — classify the lane (product/runtime/chore) and check the declared runtime surface. *When:* a request arrives — BEFORE scaffolding, especially if it smells like an incident.
|
|
47
47
|
- `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)` — scaffold a change and print the playbook to execute. *When:* a request arrives that changes behaviour (triage says PRODUCT).
|
|
48
48
|
- `doctrina spec new|list|set` — create, list, and edit capability specs. *When:* a capability needs creating or its headers advancing.
|
|
49
|
-
- `doctrina change new|apply|archive|check|tick|abandon` —
|
|
49
|
+
- `doctrina change new|apply|archive|check|tick|abandon` — drive a change by hand — check --verbose previews every delta. *When:* driving a change through its lifecycle by hand.
|
|
50
50
|
- `doctrina contract new|list|check` — own and verify the integration surface. *When:* the change touches ports, env vars, or public endpoints.
|
|
51
51
|
- `doctrina decision new|accept|land|supersede|list|scope` — record, accept, land, scope, and supersede ADRs. *When:* the change decides something a later session must not relitigate.
|
|
52
|
-
- `doctrina skill new|list|
|
|
52
|
+
- `doctrina skill new|list|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
|
|
53
53
|
- `doctrina intent add|list` — append and list product intent anchors. *When:* new product intent appears after the intake.
|
|
54
54
|
**Gate**
|
|
55
|
-
- `doctrina analyze` — structural pre-flight on a change folder. *When:* before applying a change.
|
|
56
55
|
- `doctrina clarify --all (--lang pt|en)` — smell-test Markdown for ambiguity. *When:* before applying, or before opening a PR.
|
|
57
56
|
- `doctrina validate (--fix)` — schema, structure, EARS, and index drift. *When:* after any artifact edit, and before considering work done.
|
|
58
57
|
- `doctrina coverage --strict` — acceptance criteria against cited evidence. *When:* before claiming a capability is proven.
|
|
@@ -61,7 +60,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
|
|
|
61
60
|
- `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
|
|
62
61
|
- `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
|
|
63
62
|
- `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
|
|
64
|
-
**Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list
|
|
63
|
+
**Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
|
|
65
64
|
<!-- doctrina:surface:end -->
|
|
66
65
|
|
|
67
66
|
## Stack and tooling
|
|
@@ -9,9 +9,12 @@ Drive the Doctrina spec-driven workflow for **{{PROJECT_NAME}}**.
|
|
|
9
9
|
(No prompt, but you already edited code? Use `doctrina work --from-diff`.)
|
|
10
10
|
2. Execute the printed playbook in order: read the pack with
|
|
11
11
|
`doctrina context <capability> --concat`, write the spec delta, fill
|
|
12
|
-
`tasks.md`, implement
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
`tasks.md`, implement. `doctrina change check <id>` previews everything
|
|
13
|
+
the close would refuse.
|
|
14
|
+
3. Close it: `doctrina close <id>` — one attested pass over every gate. The
|
|
15
|
+
sequence is declared in one place and printed by `doctrina close --help`;
|
|
16
|
+
read it there rather than from a copy. Resolve every `error:` before
|
|
17
|
+
reporting the work done.
|
|
15
18
|
|
|
16
19
|
Reach for the CLI instead of hand-authoring artifacts. Ask the human only on
|
|
17
20
|
genuine ambiguity.
|
|
@@ -5,8 +5,9 @@ Turn the request into a Doctrina change and drive it through the gates.
|
|
|
5
5
|
1. Run `doctrina work "<the request>"` (or `doctrina work --from-diff` if you
|
|
6
6
|
already edited code) and follow the printed playbook.
|
|
7
7
|
2. `doctrina context <capability> --concat` → write the spec delta → fill
|
|
8
|
-
`tasks.md` → implement → `doctrina
|
|
9
|
-
3. `doctrina close <id>`
|
|
10
|
-
|
|
8
|
+
`tasks.md` → implement → `doctrina change check <id>` (the close's preview).
|
|
9
|
+
3. `doctrina close <id>` — one attested pass over every gate; the sequence is
|
|
10
|
+
declared in one place and printed by `doctrina close --help`, so read it
|
|
11
|
+
there and not from a copy. Resolve every `error:` before reporting done.
|
|
11
12
|
|
|
12
13
|
Prefer the CLI over hand-authoring artifacts. Ask only on genuine ambiguity.
|
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
- **Lane:**
|
|
7
7
|
- **Affects specs:**
|
|
8
8
|
|
|
9
|
+
<!--
|
|
10
|
+
Optional, and usually absent. The closing docs gate reads COMMAND and FLAG
|
|
11
|
+
names out of the prose below and asks for documentation when it finds any.
|
|
12
|
+
It cannot tell a change from a mention: explaining an effect, or writing a
|
|
13
|
+
Scope boundaries line about what this deliberately does NOT touch, names
|
|
14
|
+
things just as loudly as changing them would.
|
|
15
|
+
|
|
16
|
+
When that happens, say so on the record instead of forcing the close:
|
|
17
|
+
|
|
18
|
+
- **Documented surface:** n/a — names two commands to explain an effect; alters neither
|
|
19
|
+
|
|
20
|
+
`none` reads the same as `n/a`, and a BARE one silences nothing — the
|
|
21
|
+
reason is the declaration.
|
|
22
|
+
-->
|
|
23
|
+
|
|
9
24
|
## Why
|
|
10
25
|
|
|
11
26
|
<!-- The user-visible or technical reason this change exists. -->
|
|
@@ -2,16 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
<!--
|
|
4
4
|
Each task is a single checkable item. Keep tasks small (under a few hours
|
|
5
|
-
of work). The change is done when every box is checked
|
|
6
|
-
|
|
5
|
+
of work). The change is done when every box is checked and
|
|
6
|
+
`doctrina close {{CHANGE_ID}}` succeeds — the close applies the deltas,
|
|
7
|
+
archives the change and updates the index, so those are not boxes here.
|
|
7
8
|
-->
|
|
8
9
|
|
|
9
10
|
- [ ]
|
|
10
11
|
- [ ]
|
|
11
12
|
- [ ]
|
|
12
|
-
|
|
13
|
-
## Closing steps
|
|
14
|
-
|
|
15
|
-
- [ ] Apply the change: merge each delta into the corresponding spec.
|
|
16
|
-
- [ ] Archive the change folder to `.doctrina/changes/archive/{{DATE}}-{{CHANGE_ID}}/`.
|
|
17
|
-
- [ ] Update `.doctrina/index.json` with new or modified artifacts.
|
|
@@ -107,8 +107,8 @@ Example (Behave): features/**/*.feature with pattern @([a-z0-9_-]+)
|
|
|
107
107
|
<!--
|
|
108
108
|
Ceilings that are a CONTRACT, not a preference: token limits, payload
|
|
109
109
|
caps, timeouts. Direction distinguishes what goes IN from what comes
|
|
110
|
-
OUT, because they fail differently — and `doctrina
|
|
111
|
-
change that "fixes" an overflow by raising an OUTPUT ceiling, which
|
|
110
|
+
OUT, because they fail differently — and `doctrina change check` and the
|
|
111
|
+
close refuse a change that "fixes" an overflow by raising an OUTPUT ceiling, which
|
|
112
112
|
buys headroom by truncating what mattered rather than sending less.
|
|
113
113
|
-->
|
|
114
114
|
|
|
@@ -26,3 +26,13 @@ $DOCTRINA validate --fix || exit 1
|
|
|
26
26
|
if [ -f .doctrina/index.json ]; then
|
|
27
27
|
git add .doctrina/index.json
|
|
28
28
|
fi
|
|
29
|
+
|
|
30
|
+
# ...and then check that the index this commit carries describes THIS COMMIT.
|
|
31
|
+
#
|
|
32
|
+
# Everything above reads the working tree. index.json is derived from the
|
|
33
|
+
# whole tree, so it cannot describe a subset of one: stage part of
|
|
34
|
+
# .doctrina/ and the index that just got staged names artifacts the commit
|
|
35
|
+
# does not contain. On your disk they are all still there, so `validate`,
|
|
36
|
+
# `index rebuild --check` and the close all pass — and the checkout in CI
|
|
37
|
+
# fails with "index.json references missing artifact".
|
|
38
|
+
$DOCTRINA index rebuild --check --staged || exit 1
|
|
@@ -35,8 +35,12 @@ Execute in order, in a single linear pass:
|
|
|
35
35
|
If something in the intake is genuinely ambiguous, ask the user
|
|
36
36
|
before assuming.
|
|
37
37
|
|
|
38
|
-
7. Mark the intake consumed
|
|
39
|
-
|
|
38
|
+
7. Mark the intake consumed — through the CLI, not by hand:
|
|
39
|
+
[[c]]doctrina intake --converted[[/c]]
|
|
40
|
+
[[g]] `next` branches on that header and `validate` refuses any other[[/g]]
|
|
41
|
+
[[g]] value, because every word but the two declared ones used to read[[/g]]
|
|
42
|
+
[[g]] as pending in silence. It refuses while `validate` reports an[[/g]]
|
|
43
|
+
[[g]] error — the step-6 gates are what "done" means.[[/g]]
|
|
40
44
|
|
|
41
45
|
8. [[c]]doctrina next[[/c]] — follow the recommendation. Implementation then
|
|
42
46
|
flows through [[c]]doctrina work "<brief prompt>"[[/c]], one change at a time.
|
|
@@ -6,14 +6,19 @@ Execute in order, in a single linear pass:
|
|
|
6
6
|
|
|
7
7
|
1. Replace the placeholder tasks in .doctrina/changes/{{CHANGE_ID}}/tasks.md
|
|
8
8
|
with small, checkable steps; implement them, checking each box.
|
|
9
|
-
[[g]] (Do it before implementing —
|
|
9
|
+
[[g]] (Do it before implementing — the close refuses leftover scaffold[[/g]]
|
|
10
10
|
[[g]] placeholders, and `change tick` will not tick an empty box.)[[/g]]
|
|
11
|
-
2. Prove it (
|
|
11
|
+
2. Prove it (the close refuses unchecked boxes):
|
|
12
12
|
[[c]]doctrina verify[[/c]] — the project's typecheck/test/build gate
|
|
13
13
|
[[c]]doctrina verify --clean[[/c]] — clean-checkout reproducibility lint
|
|
14
14
|
Then check the proposal's ## Verification boxes and every task.
|
|
15
15
|
3. This is a chore: no spec changes. If you find yourself needing a
|
|
16
16
|
spec delta, it is not a chore — reopen with [[c]]doctrina work "<prompt>"[[/c]].
|
|
17
|
-
4.
|
|
18
|
-
|
|
17
|
+
4. Close in one attested pass — the same close as any change; a chore
|
|
18
|
+
simply has no delta to apply:
|
|
19
|
+
[[c]]doctrina close {{CHANGE_ID}}[[/c]]
|
|
20
|
+
[[g]] It stops at the first failure with the exact rerun command, and the[[/g]]
|
|
21
|
+
[[g]] sequence is printed by the close itself. A chore that alters a[[/g]]
|
|
22
|
+
[[g]] documented surface still owes the docs and a changelog entry — or[[/g]]
|
|
23
|
+
[[g]] `- **Documented surface:** n/a — <why>` in the proposal.[[/g]]
|
|
19
24
|
[[g]] If this fix taught a lesson worth not relearning, capture it: [[/g]][[c]]doctrina skill new <slug>[[/c]][[g]].[[/g]]
|
|
@@ -45,7 +45,7 @@ Execute in order, in a single linear pass:
|
|
|
45
45
|
4. Replace the placeholder tasks in .doctrina/changes/{{CHANGE_ID}}/tasks.md
|
|
46
46
|
with small, checkable implementation tasks (a few hours each, max),
|
|
47
47
|
and record the change's What/Scope in its proposal.md. Do this BEFORE
|
|
48
|
-
implementing —
|
|
48
|
+
implementing — `change check` and close refuse a change whose scaffold
|
|
49
49
|
placeholders were never replaced, and `change tick` will not tick them.
|
|
50
50
|
|
|
51
51
|
5. Implement task by task, checking each box as it lands. Advance the
|
|
@@ -67,9 +67,16 @@ Execute in order, in a single linear pass:
|
|
|
67
67
|
[[g]] It self-reviews on the way through: the close runs `doctrina review`[[/g]]
|
|
68
68
|
[[g]] before the apply, where its findings can still change what gets[[/g]]
|
|
69
69
|
[[g]] written. Advisory — it reports and the close continues.[[/g]]
|
|
70
|
-
[[g]]
|
|
70
|
+
[[g]] The sequence is declared in one place and printed by the close itself,[[/g]]
|
|
71
|
+
[[g]] so read it there rather than from a copy that goes stale.[[/g]]
|
|
71
72
|
Before it: check the proposal's ## Verification boxes and every task,
|
|
72
|
-
|
|
73
|
+
and bump Implementation to verified.
|
|
74
|
+
Two gates ask for WRITING, and both refuse late if you leave them:
|
|
75
|
+
a change that alters a documented surface needs the documentation to
|
|
76
|
+
move with it, AND an entry in the project's changelog — one says how it
|
|
77
|
+
works, the other says that it changed. If the command and flag names in
|
|
78
|
+
your prose are only MENTIONED, declare it instead of forcing:
|
|
79
|
+
[[c]]- **Documented surface:** n/a — <why>[[/c]] in the proposal.
|
|
73
80
|
|
|
74
81
|
8. [[c]]doctrina next[[/c]] for the follow-up.
|
|
75
82
|
[[g]] If this change taught a reusable lesson (a fix you'd hate to relearn,[[/g]]
|