doctrina-cli 0.17.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.
Files changed (57) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +131 -7
  6. package/src/commands/close.js +92 -28
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/next.js +1 -0
  14. package/src/commands/prime.js +76 -7
  15. package/src/commands/review.js +47 -8
  16. package/src/commands/skill.js +3 -127
  17. package/src/commands/upgrade.js +15 -0
  18. package/src/commands/verify.js +59 -6
  19. package/src/commands/work.js +139 -5
  20. package/src/commands/workspace.js +409 -0
  21. package/src/index.js +5 -7
  22. package/src/lib/actions.js +9 -5
  23. package/src/lib/adapters.js +80 -0
  24. package/src/lib/change-ops.js +92 -0
  25. package/src/lib/code-inventory.js +297 -0
  26. package/src/lib/commands.js +52 -5
  27. package/src/lib/context-slice.js +118 -0
  28. package/src/lib/delivery.js +208 -0
  29. package/src/lib/git.js +59 -2
  30. package/src/lib/metrics-model.js +23 -1
  31. package/src/lib/playbook.js +1 -1
  32. package/src/lib/runtime.js +6 -0
  33. package/src/lib/scan.js +13 -0
  34. package/src/lib/signoff.js +24 -0
  35. package/src/lib/skill-candidates.js +162 -0
  36. package/src/lib/skill-export.js +95 -0
  37. package/src/lib/snapshot.js +76 -2
  38. package/src/lib/spec-ops.js +117 -1
  39. package/src/lib/task-card.js +102 -0
  40. package/src/lib/validation-model.js +1 -1
  41. package/src/lib/verify-cache.js +76 -0
  42. package/src/lib/views.js +56 -2
  43. package/src/lib/work-model.js +90 -12
  44. package/src/lib/workspace-agents.js +259 -0
  45. package/src/lib/workspace-tools.js +113 -0
  46. package/src/lib/workspace.js +371 -0
  47. package/templates/AGENTS.md.template +17 -14
  48. package/templates/README.md +11 -0
  49. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  50. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  51. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  52. package/templates/change/spec-delta.md.template +2 -0
  53. package/templates/delivery.md.template +39 -0
  54. package/templates/playbooks/absorb.md.template +40 -0
  55. package/templates/playbooks/consolidate.md.template +28 -0
  56. package/templates/playbooks/delivery.md.template +38 -0
  57. package/templates/playbooks/work.md.template +4 -1
package/src/index.js CHANGED
@@ -48,6 +48,8 @@ import * as upgrade from "./commands/upgrade.js";
48
48
  import * as adapter from "./commands/adapter.js";
49
49
  import * as triage from "./commands/triage.js";
50
50
  import * as ci from "./commands/ci.js";
51
+ import * as workspace from "./commands/workspace.js";
52
+ import * as delivery from "./commands/delivery.js";
51
53
 
52
54
  const COMMANDS = {
53
55
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -56,18 +58,14 @@ const COMMANDS = {
56
58
  status, close, review, watch, why,
57
59
  prime, handoff, show, doctor, report, completion,
58
60
  intent, upgrade, adapter, triage, ci,
61
+ workspace, delivery,
59
62
  };
60
63
 
61
- const TOP_HELP = `
62
- Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
64
+ const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
63
65
 
64
66
  ${surfaceHelp()}
65
67
 
66
- Global flags:
67
- --help, -h Show this message (or per-command help if after a command)
68
- --version, -v Print the version
69
- --debug On an unexpected error, also print the stack trace
70
-
68
+ Global flags: --help/-h (this, or a command's own) · --version/-v · --debug (stack traces)
71
69
  Exit codes (a contract — an agent reads these to decide what to do next):
72
70
  ${exitCodeHelp()}
73
71
  `;
@@ -151,7 +151,7 @@ export function computeActions(projectRoot, gates = {}) {
151
151
  severity: "blocking",
152
152
  why: "no capability is specced yet, so nothing states what this project must do",
153
153
  text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
154
- "for an existing codebase, `doctrina work --from-diff` backfills from the code instead",
154
+ "for an existing codebase, `doctrina intake --from-code` takes its inventory as the intake (`doctrina work --from-diff` backfills one change from code already written)",
155
155
  }));
156
156
  }
157
157
 
@@ -199,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
199
199
  }
200
200
  const status = listHeader(read(proposalPath), "Status") ?? "proposed";
201
201
  if (status === "applied") {
202
+ // The close, not the archive (change 0201). 0.17 retired the
203
+ // hand-driven tail — `close` skips the apply it already did and
204
+ // still runs verify, coverage and the docs gate — but this line kept
205
+ // teaching `change archive`, the one path past every one of them.
202
206
  actions.push(action({
203
207
  id: "change-archive-pending",
204
- command: "change archive",
208
+ command: "close",
205
209
  args: [id],
206
210
  gate: "archive",
207
211
  runnable: true,
@@ -239,11 +243,11 @@ export function computeActions(projectRoot, gates = {}) {
239
243
  } else {
240
244
  actions.push(action({
241
245
  id: "change-deltas-missing",
242
- command: "change apply",
246
+ command: "close",
243
247
  args: [id],
244
- why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
248
+ why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
245
249
  text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
246
- `or apply as metadata-only: doctrina change apply ${id}`,
250
+ `or close it as metadata-only: doctrina close ${id}`,
247
251
  }));
248
252
  }
249
253
  }
@@ -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
+ }
@@ -15,6 +15,10 @@ import { flagBool, flagString } from "./args.js";
15
15
  import { c } from "./colors.js";
16
16
  import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
17
17
  import { changeEntry } from "./scan.js";
18
+ import { parseOp, insertOps, extractOps, applyOps } from "./spec-ops.js";
19
+ import { parseOperation } from "./doc-model.js";
20
+ import { substitute } from "./templates.js";
21
+ import { EXIT } from "./exit-codes.js";
18
22
  export function changeNew(args, flags) {
19
23
  const id = args[0];
20
24
  const title = args.slice(1).join(" ").trim();
@@ -131,3 +135,91 @@ export function reindexChange(projectRoot, id, date = today()) {
131
135
  idx.touch(index, date);
132
136
  idx.save(projectRoot, index);
133
137
  }
138
+
139
+ /**
140
+ * Add op lines to a change's MODIFIED delta for one capability (change 0205).
141
+ *
142
+ * A field review of 0.17 asked for "a command to add a requirement and a
143
+ * criterion without writing the ops blocks by hand". The grammar is
144
+ * `apply`'s (ADR 0021): every line is parsed by the same parser, and the
145
+ * whole block — what was there plus what is added — is dry-run against the
146
+ * current spec before anything is written, so an op that would not apply is
147
+ * refused now rather than at the close. Creates the delta from the template
148
+ * when the change has none for the capability yet; a second `bump-version`
149
+ * replaces the first instead of bumping twice.
150
+ *
151
+ * @param {string} projectRoot
152
+ * @param {string} id change id (already refused/resolved by the caller)
153
+ * @param {string} capability
154
+ * @param {string[]} lines op lines, one operation each
155
+ * @returns {{ code: number, error?: string, hint?: string, details?: string[],
156
+ * deltaRel?: string, created?: boolean, block?: string[] }}
157
+ */
158
+ export function addDeltaOps(projectRoot, id, capability, lines) {
159
+ if (lines.length === 0) {
160
+ return { code: EXIT.USAGE, error: "nothing to add",
161
+ hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
162
+ }
163
+ const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
164
+ if (bad.length > 0) {
165
+ return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
166
+ }
167
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
168
+ if (!isFile(specPath)) {
169
+ return { code: EXIT.USAGE, error: `capability "${capability}" has no spec at ${relPath(projectRoot, specPath)}`,
170
+ hint: `a NEW capability is an ADDED delta with the full body — \`doctrina spec new ${capability}\`, then write it` };
171
+ }
172
+ const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
173
+ const created = !isFile(deltaPath);
174
+ let text;
175
+ if (created) {
176
+ const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
177
+ text = substitute(tpl, { CAPABILITY: capability }).replace(/^\*\*Operation:\*\*.*$/m, "**Operation:** MODIFIED");
178
+ } else {
179
+ text = read(deltaPath);
180
+ const op = parseOperation(text);
181
+ if (op && op !== "MODIFIED") {
182
+ return { code: EXIT.PRECONDITION, error: `${relPath(projectRoot, deltaPath)} is an ${op} delta — ops apply only to a MODIFIED one`,
183
+ hint: op === "ADDED" ? "write the requirement into the ADDED body itself" : "a REMOVED delta deletes the spec; there is nothing to add to" };
184
+ }
185
+ }
186
+ const next = insertOps(text, lines);
187
+ const dry = applyOps(read(specPath), extractOps(next));
188
+ if (dry.errors.length > 0) {
189
+ return { code: EXIT.GATE, error: `the ops block would not apply to ${relPath(projectRoot, specPath)} — nothing written`, details: dry.errors };
190
+ }
191
+ mkdirp(path.dirname(deltaPath));
192
+ write(deltaPath, next, { force: true });
193
+ addAffectedSpec(projectRoot, id, capability);
194
+ return { code: EXIT.OK, deltaRel: relPath(projectRoot, deltaPath), created, block: extractOps(next).map((o) => o.verb) };
195
+ }
196
+
197
+ /**
198
+ * Name `capability` in the proposal's `Affects specs:` header when the
199
+ * header does not already (change 0207). A change whose delta targets a
200
+ * capability affects that spec, and a header left empty while the delta
201
+ * folder said otherwise was the one fact `prime`, `status` and the review
202
+ * of the proposal disagreed on. A chore's "(none — chore)" is left alone:
203
+ * that is a declaration, and the structure gate is where a chore carrying a
204
+ * delta gets questioned.
205
+ *
206
+ * @param {string} projectRoot
207
+ * @param {string} id
208
+ * @param {string} capability
209
+ * @returns {boolean} whether the header changed
210
+ */
211
+ export function addAffectedSpec(projectRoot, id, capability) {
212
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
213
+ if (!isFile(proposalPath)) return false;
214
+ const text = read(proposalPath);
215
+ const m = text.match(/^(-[ \t]+\*\*Affects specs:\*\*)([^\r\n]*)/m);
216
+ if (!m) return false;
217
+ const current = m[2].trim();
218
+ if (/^\(none/i.test(current)) return false;
219
+ const named = current.split(",").map((s) => s.trim().replace(/`/g, "")).filter(Boolean);
220
+ if (named.includes(capability)) return false;
221
+ const value = [...named, capability].join(", ");
222
+ write(proposalPath, text.replace(m[0], `${m[1]} ${value}`), { force: true });
223
+ reindexChange(projectRoot, id);
224
+ return true;
225
+ }
@@ -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
+