doctrina-cli 0.18.0 → 0.20.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +73 -4
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/prime.js +76 -7
- package/src/commands/upgrade.js +15 -0
- package/src/commands/work.js +86 -2
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +1 -1
- package/src/lib/adapters.js +80 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +44 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +39 -2
- package/src/lib/task-card.js +102 -0
- package/src/lib/views.js +51 -2
- package/src/lib/work-model.js +58 -0
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +9 -0
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +1 -1
package/src/lib/adapters.js
CHANGED
|
@@ -127,3 +127,83 @@ export function renderAdapterFile(file, tokens) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export { exists };
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Configuration files an adapter SHARES with the user (change 0211).
|
|
133
|
+
//
|
|
134
|
+
// A hook lives in the agent's own settings — `.claude/settings.json`,
|
|
135
|
+
// `.cursor/hooks.json` — which the project may already own: permissions,
|
|
136
|
+
// other hooks, a model choice. Writing the file whole would be the data loss
|
|
137
|
+
// `adapter add` exists to avoid, and skipping it because it exists would
|
|
138
|
+
// leave the hooks out exactly where an agent is configured. So a `.json`
|
|
139
|
+
// adapter file is MERGED: objects recurse, arrays gain the entries they do
|
|
140
|
+
// not already hold, and a scalar the user set stays theirs. Removal takes
|
|
141
|
+
// back only what the template put in.
|
|
142
|
+
|
|
143
|
+
/** Whether an adapter file is merged into a shared configuration file. */
|
|
144
|
+
export function isMergedConfig(file) {
|
|
145
|
+
return file.relativePath.endsWith(".json");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
149
|
+
|
|
150
|
+
/** `ours` merged into `theirs`; neither argument is modified. */
|
|
151
|
+
export function mergeJson(theirs, ours) {
|
|
152
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
153
|
+
return [...theirs, ...ours.filter((o) => !theirs.some((t) => same(t, o)))];
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
156
|
+
const out = { ...theirs };
|
|
157
|
+
for (const [k, v] of Object.entries(ours)) {
|
|
158
|
+
out[k] = k in theirs ? mergeJson(theirs[k], v) : v;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
return theirs;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `theirs` with everything `ours` contributed taken out, and the containers
|
|
167
|
+
* that leaves empty pruned. A scalar is removed only when it equals ours AND
|
|
168
|
+
* nothing of the user's remains beside it. Returns null when nothing is left.
|
|
169
|
+
*/
|
|
170
|
+
export function unmergeJson(theirs, ours) {
|
|
171
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
172
|
+
const left = theirs.filter((t) => !ours.some((o) => same(t, o)));
|
|
173
|
+
return left.length > 0 ? left : null;
|
|
174
|
+
}
|
|
175
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const [k, v] of Object.entries(theirs)) {
|
|
178
|
+
if (!(k in ours) || isScalar(ours[k])) { out[k] = v; continue; }
|
|
179
|
+
const kept = unmergeJson(v, ours[k]);
|
|
180
|
+
if (kept !== null) out[k] = kept;
|
|
181
|
+
}
|
|
182
|
+
// Our scalars (a `"version": 1`) go only when they are all that is left.
|
|
183
|
+
const userKeys = Object.keys(out).filter((k) => !(k in ours && isScalar(ours[k]) && same(out[k], ours[k])));
|
|
184
|
+
return userKeys.length > 0 ? out : null;
|
|
185
|
+
}
|
|
186
|
+
return same(theirs, ours) ? null : theirs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isPlainObject(v) {
|
|
190
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isScalar(v) {
|
|
194
|
+
return v === null || typeof v !== "object";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether `theirs` holds at least one array entry `ours` contributes. */
|
|
198
|
+
export function holdsAnyOf(theirs, ours) {
|
|
199
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) return ours.some((o) => theirs.some((t) => same(t, o)));
|
|
200
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
201
|
+
return Object.keys(ours).some((k) => k in theirs && holdsAnyOf(theirs[k], ours[k]));
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether every entry `ours` contributes is present in `theirs`. */
|
|
207
|
+
export function containsJson(theirs, ours) {
|
|
208
|
+
return same(mergeJson(theirs, ours), theirs);
|
|
209
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { gitLines } from "./git.js";
|
|
6
|
+
|
|
7
|
+
// The INVENTORY of an existing codebase (change 0221): what `intake
|
|
8
|
+
// --from-code` stores as the intake of a repository that already has code
|
|
9
|
+
// and no specs. Deterministic, local and cheap — it reads manifests and
|
|
10
|
+
// counts files; it never interprets prose and never reads a secret.
|
|
11
|
+
//
|
|
12
|
+
// It is the FACTS half of absorbing a project. The agent does the other
|
|
13
|
+
// half — reading the code it points at and writing what the system does
|
|
14
|
+
// today — through the absorb playbook (ADR 0005: the CLI does no
|
|
15
|
+
// interpretation).
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set([
|
|
18
|
+
".git", "node_modules", ".doctrina", "dist", "build", "out", "target", "vendor", "coverage",
|
|
19
|
+
".venv", "venv", "__pycache__", ".next", ".nuxt", ".gradle", ".idea", ".vscode", "bin", "obj",
|
|
20
|
+
".terraform", ".pytest_cache", ".mypy_cache", "Pods", ".dart_tool", ".expo",
|
|
21
|
+
]);
|
|
22
|
+
const MAX_FILES = 20000;
|
|
23
|
+
|
|
24
|
+
// Dependency name → what it tells about the project.
|
|
25
|
+
const JS_SIGNALS = {
|
|
26
|
+
react: "React", next: "Next.js", vue: "Vue", nuxt: "Nuxt", "@angular/core": "Angular", svelte: "Svelte",
|
|
27
|
+
"react-native": "React Native", expo: "Expo", express: "Express", fastify: "Fastify", "@nestjs/core": "NestJS",
|
|
28
|
+
koa: "Koa", graphql: "GraphQL", prisma: "Prisma", typeorm: "TypeORM", sequelize: "Sequelize", mongoose: "Mongoose",
|
|
29
|
+
jest: "Jest", vitest: "Vitest", mocha: "Mocha", "@playwright/test": "Playwright", playwright: "Playwright",
|
|
30
|
+
cypress: "Cypress", "@cucumber/cucumber": "Cucumber", webdriverio: "WebdriverIO", "@testing-library/react": "Testing Library",
|
|
31
|
+
storybook: "Storybook", "@storybook/react": "Storybook", typescript: "TypeScript", "k6": "k6",
|
|
32
|
+
};
|
|
33
|
+
const PY_SIGNALS = {
|
|
34
|
+
django: "Django", flask: "Flask", fastapi: "FastAPI", pytest: "pytest", behave: "Behave",
|
|
35
|
+
"robotframework": "Robot Framework", selenium: "Selenium", playwright: "Playwright", requests: "requests",
|
|
36
|
+
sqlalchemy: "SQLAlchemy", pandas: "pandas", pyspark: "PySpark", locust: "Locust", celery: "Celery",
|
|
37
|
+
};
|
|
38
|
+
const JVM_SIGNALS = {
|
|
39
|
+
"spring-boot": "Spring Boot", junit: "JUnit", "rest-assured": "REST Assured", cucumber: "Cucumber",
|
|
40
|
+
testng: "TestNG", selenium: "Selenium", kotlin: "Kotlin", mockito: "Mockito",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(\.feature$)|(Tests?\.(java|kt|cs)$)|(\.robot$)|(_spec\.rb$)/;
|
|
44
|
+
const CI_FILES = [
|
|
45
|
+
[".github/workflows", "GitHub Actions"], [".gitlab-ci.yml", "GitLab CI"], ["Jenkinsfile", "Jenkins"],
|
|
46
|
+
["azure-pipelines.yml", "Azure Pipelines"], ["bitbucket-pipelines.yml", "Bitbucket Pipelines"], [".circleci", "CircleCI"],
|
|
47
|
+
];
|
|
48
|
+
const INFRA_FILES = [
|
|
49
|
+
["Dockerfile", "Docker"], ["docker-compose.yml", "Docker Compose"], ["docker-compose.yaml", "Docker Compose"],
|
|
50
|
+
["compose.yml", "Docker Compose"], ["helm", "Helm"], ["k8s", "Kubernetes manifests"], ["kubernetes", "Kubernetes manifests"],
|
|
51
|
+
["serverless.yml", "Serverless"], ["Procfile", "Procfile"],
|
|
52
|
+
];
|
|
53
|
+
const ENV_EXAMPLES = [".env.example", ".env.sample", ".env.template", ".env.dist"];
|
|
54
|
+
const AREA_ROOTS = ["src", "app", "lib", "packages", "apps", "services", "modules", "features", "pages", "components", "tests", "test", "e2e", "cypress"];
|
|
55
|
+
|
|
56
|
+
function walk(root) {
|
|
57
|
+
const files = [];
|
|
58
|
+
let truncated = false;
|
|
59
|
+
const stack = [""];
|
|
60
|
+
while (stack.length && !truncated) {
|
|
61
|
+
const rel = stack.pop();
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (!SKIP_DIRS.has(e.name)) stack.push(r);
|
|
72
|
+
} else if (e.isFile()) {
|
|
73
|
+
files.push(r);
|
|
74
|
+
if (files.length >= MAX_FILES) {
|
|
75
|
+
truncated = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { files: files.sort(), truncated };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readJson(p) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(read(p));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function signalsFrom(names, table) {
|
|
93
|
+
const out = new Set();
|
|
94
|
+
for (const n of names) {
|
|
95
|
+
const key = String(n).toLowerCase();
|
|
96
|
+
for (const [dep, label] of Object.entries(table)) {
|
|
97
|
+
// Exact names only (a scope prefix aside): `express` must not read as
|
|
98
|
+
// `expo`, nor `nextjs-toolbox` as Next.js.
|
|
99
|
+
if (key === dep || key.endsWith("/" + dep)) out.add(label);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...out];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every `KEY=` name in an env example file — names only, never a value. */
|
|
106
|
+
export function envNames(text) {
|
|
107
|
+
return String(text).split(/\r?\n/)
|
|
108
|
+
.map((l) => l.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/)?.[1])
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readmeSummary(root) {
|
|
113
|
+
for (const name of ["README.md", "readme.md", "README.MD", "README"]) {
|
|
114
|
+
const p = path.join(root, name);
|
|
115
|
+
if (!isFile(p)) continue;
|
|
116
|
+
const lines = read(p).split(/\r?\n/);
|
|
117
|
+
const title = lines.find((l) => /^#\s+/.test(l))?.replace(/^#\s+/, "").trim() ?? "";
|
|
118
|
+
const para = [];
|
|
119
|
+
let started = false;
|
|
120
|
+
for (const l of lines) {
|
|
121
|
+
if (/^#/.test(l) || /^\s*(<|!\[|\[!\[)/.test(l)) {
|
|
122
|
+
if (started) break;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (l.trim() === "") {
|
|
126
|
+
if (started) break;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
started = true;
|
|
130
|
+
para.push(l.trim());
|
|
131
|
+
}
|
|
132
|
+
return { file: name, title, summary: para.join(" ").slice(0, 400) };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function gitFacts(root) {
|
|
138
|
+
const dates = gitLines(root, ["log", "--format=%cs"]);
|
|
139
|
+
if (!dates || dates.length === 0) return null;
|
|
140
|
+
return { commits: dates.length, first: dates[dates.length - 1], last: dates[0] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The inventory of the codebase at `root`.
|
|
145
|
+
*/
|
|
146
|
+
export function inventory(root) {
|
|
147
|
+
const { files, truncated } = walk(root);
|
|
148
|
+
const has = (rel) => files.includes(rel) || isDir(path.join(root, rel));
|
|
149
|
+
const stack = new Set();
|
|
150
|
+
const signals = new Set();
|
|
151
|
+
const checks = [];
|
|
152
|
+
|
|
153
|
+
const pkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
|
|
154
|
+
if (pkg) {
|
|
155
|
+
stack.add(isFile(path.join(root, "tsconfig.json")) ? "Node.js / TypeScript" : "Node.js");
|
|
156
|
+
const deps = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
|
|
157
|
+
for (const s of signalsFrom(deps, JS_SIGNALS)) signals.add(s);
|
|
158
|
+
const runner = isFile(path.join(root, "pnpm-lock.yaml")) ? "pnpm" : isFile(path.join(root, "yarn.lock")) ? "yarn" : "npm";
|
|
159
|
+
for (const name of ["typecheck", "lint", "test", "build"]) {
|
|
160
|
+
if (pkg.scripts?.[name]) checks.push({ name, command: `${runner} run ${name}`, from: "package.json scripts" });
|
|
161
|
+
}
|
|
162
|
+
for (const [name, cmd] of Object.entries(pkg.scripts ?? {})) {
|
|
163
|
+
if (/^(test:|e2e)/.test(name)) checks.push({ name, command: `${runner} run ${name}`, from: `package.json scripts (${String(cmd).slice(0, 60)})` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const pyFiles = ["pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.py", "Pipfile"].filter((f) => isFile(path.join(root, f)));
|
|
167
|
+
if (pyFiles.length) {
|
|
168
|
+
stack.add("Python");
|
|
169
|
+
const text = pyFiles.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
|
|
170
|
+
const names = text.match(/[a-z][a-z0-9_.-]+/g) ?? [];
|
|
171
|
+
for (const s of signalsFrom([...new Set(names)].filter((n) => PY_SIGNALS[n]), PY_SIGNALS)) signals.add(s);
|
|
172
|
+
if (signals.has("pytest")) checks.push({ name: "test", command: "pytest", from: pyFiles.join(", ") });
|
|
173
|
+
if (signals.has("Behave")) checks.push({ name: "bdd", command: "behave", from: pyFiles.join(", ") });
|
|
174
|
+
}
|
|
175
|
+
const jvm = ["pom.xml", "build.gradle", "build.gradle.kts"].filter((f) => isFile(path.join(root, f)));
|
|
176
|
+
if (jvm.length) {
|
|
177
|
+
stack.add(jvm[0] === "pom.xml" ? "Java / Maven" : "JVM / Gradle");
|
|
178
|
+
const text = jvm.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
|
|
179
|
+
for (const [dep, label] of Object.entries(JVM_SIGNALS)) if (text.includes(dep)) signals.add(label);
|
|
180
|
+
checks.push(jvm[0] === "pom.xml"
|
|
181
|
+
? { name: "test", command: "mvn -q test", from: "pom.xml" }
|
|
182
|
+
: { name: "test", command: isFile(path.join(root, "gradlew")) ? "./gradlew test" : "gradle test", from: jvm[0] });
|
|
183
|
+
}
|
|
184
|
+
if (isFile(path.join(root, "go.mod"))) {
|
|
185
|
+
stack.add("Go");
|
|
186
|
+
checks.push({ name: "test", command: "go test ./...", from: "go.mod" });
|
|
187
|
+
}
|
|
188
|
+
if (isFile(path.join(root, "Cargo.toml"))) {
|
|
189
|
+
stack.add("Rust");
|
|
190
|
+
checks.push({ name: "test", command: "cargo test", from: "Cargo.toml" });
|
|
191
|
+
}
|
|
192
|
+
if (files.some((f) => /\.(csproj|sln)$/.test(f))) {
|
|
193
|
+
stack.add(".NET");
|
|
194
|
+
checks.push({ name: "test", command: "dotnet test", from: "*.csproj" });
|
|
195
|
+
}
|
|
196
|
+
if (isFile(path.join(root, "pubspec.yaml"))) {
|
|
197
|
+
stack.add("Dart / Flutter");
|
|
198
|
+
checks.push({ name: "test", command: "flutter test", from: "pubspec.yaml" });
|
|
199
|
+
}
|
|
200
|
+
if (isFile(path.join(root, "Gemfile"))) stack.add("Ruby");
|
|
201
|
+
if (isFile(path.join(root, "composer.json"))) stack.add("PHP");
|
|
202
|
+
if (isDir(path.join(root, "android"))) signals.add("Android project");
|
|
203
|
+
if (isDir(path.join(root, "ios"))) signals.add("iOS project");
|
|
204
|
+
if (files.some((f) => f.endsWith(".tf"))) signals.add("Terraform");
|
|
205
|
+
|
|
206
|
+
const testFiles = files.filter((f) => TEST_FILE.test(path.basename(f)) || /(^|\/)(cypress|e2e)\/.+\.[cm]?[jt]sx?$/.test(f));
|
|
207
|
+
const testDirs = new Map();
|
|
208
|
+
for (const f of testFiles) {
|
|
209
|
+
const dir = f.includes("/") ? f.split("/").slice(0, Math.min(2, f.split("/").length - 1)).join("/") : ".";
|
|
210
|
+
testDirs.set(dir, (testDirs.get(dir) ?? 0) + 1);
|
|
211
|
+
}
|
|
212
|
+
const ci = CI_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
|
|
213
|
+
const infra = INFRA_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
|
|
214
|
+
const env = [];
|
|
215
|
+
for (const f of ENV_EXAMPLES) {
|
|
216
|
+
if (isFile(path.join(root, f))) env.push({ file: f, names: envNames(read(path.join(root, f))) });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Candidate AREAS: the folders one level under the conventional source
|
|
220
|
+
// roots, with how many files each holds — where capabilities usually are.
|
|
221
|
+
const areas = new Map();
|
|
222
|
+
for (const f of files) {
|
|
223
|
+
const parts = f.split("/");
|
|
224
|
+
if (parts.length < 3 || !AREA_ROOTS.includes(parts[0])) continue;
|
|
225
|
+
const key = `${parts[0]}/${parts[1]}`;
|
|
226
|
+
areas.set(key, (areas.get(key) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
const topDirs = new Map();
|
|
229
|
+
for (const f of files) {
|
|
230
|
+
const top = f.includes("/") ? f.split("/")[0] : null;
|
|
231
|
+
if (top) topDirs.set(top, (topDirs.get(top) ?? 0) + 1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
root,
|
|
236
|
+
files: files.length,
|
|
237
|
+
truncated,
|
|
238
|
+
readme: readmeSummary(root),
|
|
239
|
+
stack: [...stack],
|
|
240
|
+
signals: [...signals].sort(),
|
|
241
|
+
checks,
|
|
242
|
+
tests: { files: testFiles.length, dirs: [...testDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) },
|
|
243
|
+
ci,
|
|
244
|
+
infra,
|
|
245
|
+
env,
|
|
246
|
+
areas: [...areas.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25),
|
|
247
|
+
topDirs: [...topDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15),
|
|
248
|
+
git: gitFacts(root),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The inventory as the Markdown body of an intake. */
|
|
253
|
+
export function inventoryMarkdown(inv, projectName) {
|
|
254
|
+
const L = [];
|
|
255
|
+
L.push(`Code inventory of ${projectName} — the FACTS an agent absorbs the project from.`);
|
|
256
|
+
L.push("Written by `doctrina intake --from-code`: manifests read, files counted, nothing interpreted.");
|
|
257
|
+
L.push("Business intent is NOT in here: what the code cannot say goes to the human as a question.");
|
|
258
|
+
L.push("");
|
|
259
|
+
if (inv.readme) {
|
|
260
|
+
L.push(`## README (${inv.readme.file})`, "");
|
|
261
|
+
if (inv.readme.title) L.push(`- Title: ${inv.readme.title}`);
|
|
262
|
+
if (inv.readme.summary) L.push(`- Opening: ${inv.readme.summary}`);
|
|
263
|
+
L.push("");
|
|
264
|
+
}
|
|
265
|
+
L.push("## Stack", "");
|
|
266
|
+
L.push(`- Languages / build: ${inv.stack.length ? inv.stack.join(", ") : "none detected from manifests"}`);
|
|
267
|
+
L.push(`- Frameworks and tools: ${inv.signals.length ? inv.signals.join(", ") : "none detected"}`);
|
|
268
|
+
L.push(`- Files scanned: ${inv.files}${inv.truncated ? ` (stopped at ${MAX_FILES})` : ""}`);
|
|
269
|
+
if (inv.git) L.push(`- History: ${inv.git.commits} commits, ${inv.git.first} → ${inv.git.last}`);
|
|
270
|
+
L.push("");
|
|
271
|
+
L.push("## Where the code is", "");
|
|
272
|
+
for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} files`);
|
|
273
|
+
if (inv.topDirs.length === 0) L.push("- (flat repository)");
|
|
274
|
+
L.push("");
|
|
275
|
+
L.push("## Candidate areas (one capability each, usually)", "");
|
|
276
|
+
for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n} files`);
|
|
277
|
+
if (inv.areas.length === 0) L.push("- none under the conventional roots — derive areas from the top-level folders");
|
|
278
|
+
L.push("");
|
|
279
|
+
L.push("## Tests", "");
|
|
280
|
+
L.push(`- ${inv.tests.files} test file${inv.tests.files === 1 ? "" : "s"}`);
|
|
281
|
+
for (const [dir, n] of inv.tests.dirs) L.push(`- \`${dir}\` — ${n}`);
|
|
282
|
+
L.push("");
|
|
283
|
+
L.push("## Build and test commands (candidates for `doctrina verify --init`)", "");
|
|
284
|
+
for (const ch of inv.checks) L.push(`- \`${ch.command}\` — ${ch.name}, from ${ch.from}`);
|
|
285
|
+
if (inv.checks.length === 0) L.push("- none detected — ask the human how this project is built and tested");
|
|
286
|
+
L.push("");
|
|
287
|
+
L.push("## Delivery", "");
|
|
288
|
+
for (const c of inv.ci) L.push(`- CI: ${c.label} (\`${c.path}\`)`);
|
|
289
|
+
for (const i of inv.infra) L.push(`- Runtime: ${i.label} (\`${i.path}\`)`);
|
|
290
|
+
if (inv.ci.length + inv.infra.length === 0) L.push("- no CI or runtime manifest found");
|
|
291
|
+
L.push("");
|
|
292
|
+
L.push("## Configuration (names only — values are never read)", "");
|
|
293
|
+
for (const e of inv.env) L.push(`- \`${e.file}\`: ${e.names.length ? e.names.map((n) => `\`${n}\``).join(", ") : "(no KEY= lines)"}`);
|
|
294
|
+
if (inv.env.length === 0) L.push("- no env example file");
|
|
295
|
+
return L.join("\n") + "\n";
|
|
296
|
+
}
|
|
297
|
+
|
package/src/lib/commands.js
CHANGED
|
@@ -19,6 +19,8 @@ export const COMMAND_NAMES = [
|
|
|
19
19
|
"prime", "context", "show", "search", "status", "next", "why", "handoff",
|
|
20
20
|
// gates
|
|
21
21
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
22
|
+
// orchestrate
|
|
23
|
+
"workspace", "delivery",
|
|
22
24
|
// maintenance
|
|
23
25
|
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
|
|
24
26
|
];
|
|
@@ -48,6 +50,7 @@ export const OPERATIONS = [
|
|
|
48
50
|
["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
|
|
49
51
|
["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
|
|
50
52
|
["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
|
|
53
|
+
["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
|
|
51
54
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
52
55
|
["contract list", "List contracts with status and last-updated date"],
|
|
53
56
|
["contract check", "Verify port collisions, env drift, referenced specs"],
|
|
@@ -82,6 +85,19 @@ export const OPERATIONS = [
|
|
|
82
85
|
["status", "One-glance project health dashboard"],
|
|
83
86
|
["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
|
|
84
87
|
["handoff", "Markdown handoff note: open work, task state, resume command"],
|
|
88
|
+
["workspace init", "Create a multi-repository workspace (a private git repo of context)"],
|
|
89
|
+
["workspace add", "Register a repository: in-repo, or shadowed when it cannot carry .doctrina/"],
|
|
90
|
+
["workspace attach", "Re-create a shadow project's links in its repository"],
|
|
91
|
+
["workspace detach", "Remove a shadow project's links; the shadow stays"],
|
|
92
|
+
["workspace list", "Projects with mode, state and open changes"],
|
|
93
|
+
["workspace tool", "Declare a tool (MCP server) the work uses: role, read or write"],
|
|
94
|
+
["workspace guard", "Pre-call hook: allow, deny or ask an MCP call from the declared access"],
|
|
95
|
+
["delivery new", "Open a delivery across repositories; print the orchestration playbook"],
|
|
96
|
+
["delivery route", "Choose the projects a delivery touches, dependencies first"],
|
|
97
|
+
["delivery list", "Deliveries with status, ref and closed/total changes"],
|
|
98
|
+
["delivery status", "Per project: the changes carrying a delivery and their tasks"],
|
|
99
|
+
["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
|
|
100
|
+
["delivery close", "Close a delivery once every project's change is closed"],
|
|
85
101
|
["templates list", "List the templates shipped by the installed CLI"],
|
|
86
102
|
["templates check", "Compare the project against the recommended template shape"],
|
|
87
103
|
["templates update", "Additive fixer for check findings (preview; --write applies)"],
|
|
@@ -127,8 +143,8 @@ export const COMMAND_META = {
|
|
|
127
143
|
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
128
144
|
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
129
145
|
|
|
130
|
-
triage: { moment: "Change", when: "
|
|
131
|
-
work: { moment: "Change", when: "a request
|
|
146
|
+
triage: { moment: "Change", when: "diagnosing an incident or a runtime symptom — `work` classifies every other request itself", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
|
|
147
|
+
work: { moment: "Change", when: "a request changes behaviour — BEFORE editing code; it classifies the lane itself", purpose: "scaffold a change and print the playbook to execute" },
|
|
132
148
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
133
149
|
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
|
|
134
150
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
@@ -146,6 +162,9 @@ export const COMMAND_META = {
|
|
|
146
162
|
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
147
163
|
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
148
164
|
|
|
165
|
+
delivery: { moment: "Orchestrate", when: "a story or screen must land in more than one repository", purpose: "plan, route, track and close one item of work across repositories" },
|
|
166
|
+
workspace: { moment: "Orchestrate", when: "the work spans several repositories, or a repository cannot carry .doctrina/", purpose: "register repositories, in-repo or shadowed, in one versioned workspace" },
|
|
167
|
+
|
|
149
168
|
templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
|
|
150
169
|
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
151
170
|
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
@@ -158,7 +177,7 @@ export const COMMAND_META = {
|
|
|
158
177
|
};
|
|
159
178
|
|
|
160
179
|
// The order moments appear in the generated block.
|
|
161
|
-
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
180
|
+
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
|
|
162
181
|
|
|
163
182
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
164
183
|
// printed surface can never omit an operation the catalog knows about.
|
|
@@ -192,7 +211,10 @@ export function surfaceHelp() {
|
|
|
192
211
|
const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
|
|
193
212
|
const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
|
|
194
213
|
const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
|
|
195
|
-
|
|
214
|
+
const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
|
|
215
|
+
// A compact moment's long invocation stands alone rather than wrapping
|
|
216
|
+
// onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
|
|
217
|
+
out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
|
|
196
218
|
}
|
|
197
219
|
}
|
|
198
220
|
const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
|
|
@@ -317,7 +339,10 @@ export const SURFACE_LINE_BUDGET = 40;
|
|
|
317
339
|
// each its own line pushed the block past its budget. The budget is the
|
|
318
340
|
// forcing function — a surface that cannot describe itself in 40 lines is
|
|
319
341
|
// too large, and the answer is to cut commands (M8), not to raise it.
|
|
320
|
-
|
|
342
|
+
// "Orchestrate" is compact too (change 0220): an orchestrating session reads
|
|
343
|
+
// the workspace's own AGENTS.md, which carries the delivery playbook in full;
|
|
344
|
+
// inside one repository the commands only need to be discoverable.
|
|
345
|
+
const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
|
|
321
346
|
|
|
322
347
|
// The full managed section, heading included, between the markers.
|
|
323
348
|
//
|
|
@@ -517,6 +542,20 @@ export const AGENT_CHANGELOG = {
|
|
|
517
542
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
518
543
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
519
544
|
// the minor that introduced the commands, and never silently erases it.
|
|
545
|
+
"0.20.0": [
|
|
546
|
+
"Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
|
|
547
|
+
"Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
|
|
548
|
+
"An existing codebase with no specs: `doctrina intake --from-code`, then the absorb playbook — describe what the code does today and send what it cannot tell to the human as open questions.",
|
|
549
|
+
"A repository that may not carry `.doctrina/` is a workspace shadow: its `.doctrina/` is a link, hidden from its git — never commit it there; commit the workspace.",
|
|
550
|
+
"MCP tools declared in the workspace are read-only unless declared `write`: a refused or held call means ask the human, never route around it.",
|
|
551
|
+
],
|
|
552
|
+
"0.19.0": [
|
|
553
|
+
"A request that changes behaviour is a change opened BEFORE code, whether or not it names Doctrina; say its id in your first sentence to the human, and repeat the `Tell the human:` line `work` and `close` print.",
|
|
554
|
+
"`doctrina context <cap> --for \"<task>\" --concat` is now lean (no AGENTS.md, ADR digests, the spec sliced to the task) — read it before editing; `prime` is state, not the spec. `--full` gives the whole text.",
|
|
555
|
+
"Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
|
|
556
|
+
"`work` holds (exit 3) a request that repeats an open change or one archived in the last 14 days: resume it, or tell the human it is done and where; `--force` only for genuinely new work.",
|
|
557
|
+
"Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
|
|
558
|
+
],
|
|
520
559
|
"0.18.0": [
|
|
521
560
|
"`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
|
|
522
561
|
"`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { fold } from "./lexicon.js";
|
|
3
|
+
import { getSection } from "./doc-model.js";
|
|
4
|
+
|
|
5
|
+
// The lean read pack (change 0216).
|
|
6
|
+
//
|
|
7
|
+
// A field review: the agent ran `prime` and `work` and skipped `context`,
|
|
8
|
+
// although AGENTS.md calls it the read for ANY task. Measured on this
|
|
9
|
+
// repository, the reason is the price: `context gates --concat` was ~40k
|
|
10
|
+
// tokens — AGENTS.md again (the agent already carries it), the whole spec,
|
|
11
|
+
// and every accepted ADR in full — and `--for "<task>"` cut nothing, because
|
|
12
|
+
// retrieval only degraded the pack once the 50k budget overflowed. These
|
|
13
|
+
// cuts are the default now; `--full` restores the whole text.
|
|
14
|
+
|
|
15
|
+
// How much of an ADR's Decision a digest keeps.
|
|
16
|
+
const ADR_DIGEST_CHARS = 700;
|
|
17
|
+
// How many requirements and criteria a sliced spec keeps at most, each.
|
|
18
|
+
const SLICE_MAX_ITEMS = 25;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An ADR as a reader deciding whether it binds the task needs it: the
|
|
22
|
+
* title and the opening of its Decision section. `doctrina show NNNN`
|
|
23
|
+
* reads the rest.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text
|
|
26
|
+
* @param {string} title
|
|
27
|
+
* @param {string} id four-digit ADR number
|
|
28
|
+
*/
|
|
29
|
+
export function adrDigest(text, title, id) {
|
|
30
|
+
const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
|
|
31
|
+
.split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
|
|
32
|
+
const clipped = decision.length > ADR_DIGEST_CHARS
|
|
33
|
+
? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
|
|
34
|
+
: decision;
|
|
35
|
+
return [`# ${title}`, "", clipped || "(no Decision section)", "",
|
|
36
|
+
`(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A spec cut to what the task is about: its headers, its Purpose, and the
|
|
41
|
+
* requirements and criteria that match the query best — the rest named by
|
|
42
|
+
* count, never silently dropped. Deterministic: an item's score is how many
|
|
43
|
+
* distinct query terms it contains; the items scoring at least half the
|
|
44
|
+
* best score are kept, best first in document order, up to a cap per list.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @param {string[]} queryTerms already folded, stop words removed
|
|
48
|
+
* @param {string} rel the spec's path, for the footer
|
|
49
|
+
* @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
|
|
50
|
+
*/
|
|
51
|
+
export function sliceSpec(text, queryTerms, rel) {
|
|
52
|
+
if (queryTerms.length === 0) return null;
|
|
53
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
54
|
+
// Items: a bullet or numbered entry with its continuation lines.
|
|
55
|
+
/** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
|
|
56
|
+
const items = [];
|
|
57
|
+
let section = "";
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const l = lines[i];
|
|
60
|
+
if (/^##\s/.test(l)) { section = l; continue; }
|
|
61
|
+
const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
|
|
62
|
+
const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
|
|
63
|
+
if (!req && !crit) continue;
|
|
64
|
+
let end = i;
|
|
65
|
+
while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
|
|
66
|
+
const body = fold(lines.slice(i, end + 1).join(" "));
|
|
67
|
+
const score = queryTerms.filter((t) => body.includes(t)).length;
|
|
68
|
+
items.push({ start: i, end, kind: req ? "req" : "crit", score });
|
|
69
|
+
i = end;
|
|
70
|
+
}
|
|
71
|
+
const best = Math.max(0, ...items.map((it) => it.score));
|
|
72
|
+
if (best === 0) return null;
|
|
73
|
+
const floor = Math.max(1, Math.ceil(best / 2));
|
|
74
|
+
const keep = new Set();
|
|
75
|
+
for (const kind of ["req", "crit"]) {
|
|
76
|
+
items.filter((it) => it.kind === kind && it.score >= floor)
|
|
77
|
+
.sort((a, b) => b.score - a.score || a.start - b.start)
|
|
78
|
+
.slice(0, SLICE_MAX_ITEMS)
|
|
79
|
+
.forEach((it) => keep.add(it.start));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = [];
|
|
83
|
+
section = "";
|
|
84
|
+
let omitted = 0;
|
|
85
|
+
const flushOmitted = () => {
|
|
86
|
+
if (omitted > 0) out.push(` … ${omitted} not matching the task`);
|
|
87
|
+
omitted = 0;
|
|
88
|
+
};
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const l = lines[i];
|
|
91
|
+
if (/^##\s/.test(l)) {
|
|
92
|
+
flushOmitted();
|
|
93
|
+
section = l;
|
|
94
|
+
out.push(l);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const item = items.find((it) => it.start === i);
|
|
98
|
+
if (item) {
|
|
99
|
+
if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
|
|
100
|
+
i = item.end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Headers, Purpose and subsection headings are kept; prose in the other
|
|
104
|
+
// sections (Out of scope, Maturity, …) is the first thing a task can do
|
|
105
|
+
// without.
|
|
106
|
+
const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
|
|
107
|
+
const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
|
|
108
|
+
if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
|
|
109
|
+
if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
|
|
110
|
+
if (inLists && l.trim() !== "") continue;
|
|
111
|
+
if (!prose) out.push(l);
|
|
112
|
+
}
|
|
113
|
+
flushOmitted();
|
|
114
|
+
const kept = keep.size;
|
|
115
|
+
out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
|
|
116
|
+
`full text: ${rel}, or \`context --full\`)`);
|
|
117
|
+
return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
|
|
118
|
+
}
|