doctrina-cli 0.21.0 → 0.22.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -57,6 +57,7 @@ const JS_SIGNALS = {
57
57
  const PY_SIGNALS = {
58
58
  django: "Django", djangorestframework: "Django REST framework", flask: "Flask", fastapi: "FastAPI",
59
59
  sqlmodel: "SQLModel", pydantic: "Pydantic", pytest: "pytest", behave: "Behave", robotframework: "Robot Framework",
60
+ ruff: "Ruff", mypy: "mypy",
60
61
  selenium: "Selenium", playwright: "Playwright", requests: "requests", httpx: "HTTPX", sqlalchemy: "SQLAlchemy",
61
62
  alembic: "Alembic", pandas: "pandas", pyspark: "PySpark", locust: "Locust", celery: "Celery", grpcio: "gRPC",
62
63
  streamlit: "Streamlit", langchain: "LangChain", openai: "OpenAI SDK", anthropic: "Anthropic SDK", uvicorn: "Uvicorn",
@@ -144,7 +145,7 @@ function walk(root) {
144
145
  let truncated = false;
145
146
  const stack = [""];
146
147
  while (stack.length && !truncated) {
147
- const rel = stack.pop();
148
+ const rel = stack.pop() ?? "";
148
149
  let entries;
149
150
  try {
150
151
  entries = readdirSync(path.join(root, rel), { withFileTypes: true });
@@ -300,14 +301,14 @@ function readUnits(root, files) {
300
301
  const kind = MANIFESTS.find(([re]) => re.test(baseOf(f)))?.[1];
301
302
  if (!kind) continue;
302
303
  if (!byDir.has(dir)) byDir.set(dir, new Set());
303
- byDir.get(dir).add(kind);
304
+ byDir.get(dir)?.add(kind);
304
305
  manifestsOf.set(dir, [...(manifestsOf.get(dir) ?? []), baseOf(f)]);
305
306
  }
306
307
  const rootPkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
307
308
  const units = [];
308
309
  for (const dir of [...byDir.keys()].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))) {
309
- const kinds = [...byDir.get(dir)].sort();
310
- const manifests = manifestsOf.get(dir).sort();
310
+ const kinds = [...(byDir.get(dir) ?? [])].sort();
311
+ const manifests = (manifestsOf.get(dir) ?? []).sort();
311
312
  const signals = new Set();
312
313
  let runner = null;
313
314
  let scripts = {};
@@ -396,6 +397,22 @@ function unitCommands(root, unit, units, workspaces, files) {
396
397
  }
397
398
  } else if (kind === "python") {
398
399
  const own = files.filter((f) => unitOf(units, f) === unit);
400
+ const from = `${unit.dir ? unit.dir + "/" : ""}${unit.manifests.join(", ")}`;
401
+ const pyproject = readText(root, unit.dir ? `${unit.dir}/pyproject.toml` : "pyproject.toml");
402
+ const setupCfg = readText(root, unit.dir ? `${unit.dir}/setup.cfg` : "setup.cfg");
403
+ const locked = (/** @type {string} */ f) => at(f) || isFile(path.join(root, f));
404
+ const via = locked("uv.lock") ? "uv run " : locked("poetry.lock") ? "poetry run " : locked("Pipfile.lock") ? "pipenv run " : "";
405
+ // The linter and the type checker the project DECLARES (seventh audit,
406
+ // change 0250): a Python project was seeded with its tests and nothing
407
+ // else, where a Node one gets `lint` and `typecheck` from its scripts.
408
+ // Python has no scripts table, so the declaration is the dependency or
409
+ // the tool's own configuration.
410
+ if (unit.signals.includes("Ruff") || at("ruff.toml") || at(".ruff.toml") || /\[tool\.ruff/.test(pyproject)) {
411
+ out.push({ name: `lint${tag}`, command: `${via}ruff check .`, cwd, from, seed: true });
412
+ }
413
+ if (unit.signals.includes("mypy") || at("mypy.ini") || /\[tool\.mypy/.test(pyproject) || /^\[mypy\]/m.test(setupCfg)) {
414
+ out.push({ name: `typecheck${tag}`, command: `${via}mypy .`, cwd, from, seed: true });
415
+ }
399
416
  const hasTests = own.some((f) => /(^|\/)(test_[^/]*|[^/]*_test)\.py$/.test(f));
400
417
  if (!hasTests) continue; // pytest declared with nothing to run is not a gate
401
418
  // Seed the runner the project DECLARES. Test files alone say nothing
@@ -479,7 +496,7 @@ export function inventory(root) {
479
496
  // Languages, counted by file extension — what is actually written here.
480
497
  const languages = new Map();
481
498
  for (const f of files) {
482
- const ext = baseOf(f).includes(".") ? baseOf(f).split(".").pop().toLowerCase() : "";
499
+ const ext = baseOf(f).includes(".") ? (baseOf(f).split(".").pop() ?? "").toLowerCase() : "";
483
500
  const lang = LANG_BY_EXT[ext];
484
501
  if (lang) languages.set(lang, (languages.get(lang) ?? 0) + 1);
485
502
  }
@@ -12,7 +12,7 @@
12
12
  // Keep this sorted by the workflow order used in `--help`, not alphabetically.
13
13
  export const COMMAND_NAMES = [
14
14
  // bootstrap / day-to-day
15
- "init", "intake", "triage", "work",
15
+ "init", "ui", "onboard", "intake", "triage", "work",
16
16
  // authoring
17
17
  "spec", "change", "contract", "decision", "skill", "intent", "adapter",
18
18
  // read / orient
@@ -37,6 +37,8 @@ export const COMMAND_NAMES = [
37
37
  // every op.
38
38
  export const OPERATIONS = [
39
39
  ["init", "Scaffold AGENTS.md and .doctrina/ in the current directory"],
40
+ ["ui", "The console: type requests, run commands, watch the agent work"],
41
+ ["onboard", "Walk the whole cycle once on this repository, step by step"],
40
42
  ["intake", "Store the full project description; print the bootstrap playbook"],
41
43
  ["triage", "Classify a request into a lane, and check the runtime surface"],
42
44
  ["work", "Brief prompt -> scaffolded change + guided work playbook"],
@@ -49,6 +51,7 @@ export const OPERATIONS = [
49
51
  ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
50
52
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
51
53
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
54
+ ["change renumber", "Give an open change the next free number (two branches took the same one)"],
52
55
  ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
53
56
  ["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
54
57
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
@@ -104,6 +107,7 @@ export const OPERATIONS = [
104
107
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
105
108
  ["hooks install", "Install the pre-commit hook"],
106
109
  ["index rebuild", "Regenerate index.json from the artifacts on disk"],
110
+ ["index merge", "The merge driver git runs for index.json (set up by hooks install)"],
107
111
  ["next", "Print the recommended next workflow actions"],
108
112
  ["watch", "Re-run validate --fix + next on every change (--once for one pass)"],
109
113
  ["metrics", "Local git-derived adoption metrics (no network)"],
@@ -132,6 +136,8 @@ export const OPERATIONS = [
132
136
  // `templates check` fails when either is missing.
133
137
  export const COMMAND_META = {
134
138
  init: { moment: "Bootstrap", when: "starting a project that has no AGENTS.md yet", purpose: "scaffold AGENTS.md and .doctrina/" },
139
+ ui: { moment: "Bootstrap", when: "a human wants to drive the project from one window instead of typing commands", purpose: "the interactive console: requests to the agent, commands, the project's state" },
140
+ onboard: { moment: "Bootstrap", when: "the first session in a project, or showing it to someone new", purpose: "walk the whole cycle once, step by step, on this repository" },
135
141
  intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
136
142
  adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
137
143
 
@@ -351,7 +357,40 @@ const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
351
357
  // trigger: an agent scanning it learns WHEN to reach for a command, which
352
358
  // a name index never told it. Same shape `context` prints for skills —
353
359
  // description plus `when:`, body on demand.
354
- export function surfaceMarkdown() {
360
+ // TWO PROFILES OF THE SAME CATALOG (change 0251).
361
+ //
362
+ // `full` names every command, by moment. It is what this block has always
363
+ // been, and it costs 37 lines of a file capped at 150 that an agent reads
364
+ // on every turn — for 39 commands, most of which a session never reaches
365
+ // for. `core` names the loop a session actually runs and defers the rest to
366
+ // `doctrina --help`, which is where the playbook a command prints already
367
+ // sends the agent for the next step. A project chooses in
368
+ // `.doctrina/config.json` ("surface"); `init` writes `core` for a new one.
369
+ export const SURFACE_PROFILES = Object.freeze(["full", "core"]);
370
+ export const CORE_SURFACE = Object.freeze(["prime", "work", "context", "close", "next", "validate", "doctor", "handoff"]);
371
+
372
+ function coreSurfaceMarkdown() {
373
+ const lines = [
374
+ "## Doctrina command surface (generated — reach for these, don't hand-author)",
375
+ "",
376
+ "The loop a session runs. Each command prints the next step; the CLI",
377
+ "scaffolds from canonical templates and syncs `index.json`.",
378
+ ];
379
+ for (const cmd of CORE_SURFACE) {
380
+ const meta = COMMAND_META[cmd];
381
+ const hint = SURFACE_HINTS[cmd] ? ` ${SURFACE_HINTS[cmd]}` : "";
382
+ lines.push(`- \`doctrina ${cmd}${hint}\` — ${meta.purpose}. *When:* ${meta.when}.`);
383
+ }
384
+ lines.push("**Everything else** — specs, changes by hand, decisions, contracts, skills, the single gates, " +
385
+ "workspaces and deliveries: `doctrina --help` lists every command with its moment, and " +
386
+ "`doctrina <command> --help` its flags. (`\"surface\": \"full\"` in `.doctrina/config.json`, then " +
387
+ "`doctrina upgrade --write`, lists them all here.)");
388
+ return lines.join("\n");
389
+ }
390
+
391
+ /** @param {string} [profile] "full" (every command) or "core" (the session loop) */
392
+ export function surfaceMarkdown(profile = "full") {
393
+ if (profile === "core") return coreSurfaceMarkdown();
355
394
  // A deprecated operation stays in the catalog — `--help` and the shell
356
395
  // completions must still know the name someone types — but leaves this
357
396
  // block: it is the list of commands to REACH FOR, and pointing an agent at
@@ -400,8 +439,8 @@ export function surfaceMarkdown() {
400
439
  }
401
440
 
402
441
  // The block as written to AGENTS.md: markers wrapping the generated section.
403
- export function surfaceBlock() {
404
- return `${SURFACE_BEGIN}\n${surfaceMarkdown()}\n${SURFACE_END}`;
442
+ export function surfaceBlock(profile = "full") {
443
+ return `${SURFACE_BEGIN}\n${surfaceMarkdown(profile)}\n${SURFACE_END}`;
405
444
  }
406
445
 
407
446
  // The block's CANONICAL POSITION, defined once — by the shipped template,
@@ -543,6 +582,13 @@ export const AGENT_CHANGELOG = {
543
582
  // Each entry lists only ITS OWN delta. The series carries the rest
544
583
  // forward (see agentChangelogEntries), so a patch never has to restate
545
584
  // the minor that introduced the commands, and never silently erases it.
585
+ "0.22.0": [
586
+ "An edit that changes nothing a spec says opens no change: `doctrina work` answers `direct` and exits 0 — edit, run `doctrina verify`, commit. `--force` only when a sentence of a spec does change.",
587
+ "Run `doctrina hooks install` once in each clone and commit the `.gitattributes` it writes: `index.json` and the ledger then merge across branches. Two open changes with one number: `doctrina change renumber <id>`.",
588
+ "A requirement that claims something of every input is proven by a property-based test: claim it with `doctrina change delta <id> <cap> --property \"…\"`; an example test cited for `[property]` fails `coverage --strict`. `doctrina coverage --properties` lists the candidates.",
589
+ "A request \"sent by the Doctrina console <name>\" is done in this repository and your last message is its report — never hand it on. Told other consoles are open, `doctrina ui --send <name> \"<self-contained task>\"` hands one a task and waits for the report.",
590
+ "A new project's AGENTS.md lists the session loop only (`surface: core`); `doctrina --help` has every command. `doctrina onboard` walks a newcomer through the cycle; `init --preset <dir>` lays an organisation's preset.",
591
+ ],
546
592
  "0.21.0": [
547
593
  "At a workspace root, `next`, `handoff`, `status`, `validate`, `context`, `doctor` and `metrics` answer for the workspace; never `doctrina init` there or in a shadowed repository's clone — the commands name `doctrina workspace attach` instead.",
548
594
  "A task card carries the delta, the delivery's story and every binding decision, global ones included: as an executor, stop and report when the story or the task contradicts one; a card on placeholder tasks means the planner writes them first (`doctrina work --resume <id>`).",
package/src/lib/config.js CHANGED
@@ -20,6 +20,8 @@ import { isFile, read } from "./fs-ops.js";
20
20
 
21
21
  export const CONFIG_REL = ".doctrina/config.json";
22
22
  export const RULES_REL = ".doctrina/rules.json";
23
+ /** The values `surface` takes — the profiles of lib/commands.js, named here so the config has no import to cycle on. */
24
+ const SURFACE_VALUES = ["full", "core"];
23
25
 
24
26
  // The built-in default for every option. `language: null` means "detect per
25
27
  // file", which is what the absence of a declaration has always meant.
@@ -27,6 +29,7 @@ export const DEFAULTS = Object.freeze({
27
29
  language: null,
28
30
  context_budget: 15000,
29
31
  rules: [],
32
+ surface: "full",
30
33
  });
31
34
 
32
35
  // Where a value came from, most authoritative first. The strings are printed
@@ -53,8 +56,8 @@ export function configPath(projectRoot) {
53
56
  *
54
57
  * @param {string} projectRoot
55
58
  * @returns {{
56
- * language: string|null, context_budget: number, rules: any[],
57
- * sources: {language: string, context_budget: string, rules: string},
59
+ * language: string|null, context_budget: number, rules: any[], surface: string,
60
+ * sources: {language: string, context_budget: string, rules: string, surface: string},
58
61
  * errors: string[],
59
62
  * unknown: string[]
60
63
  * }}
@@ -65,12 +68,12 @@ export function loadConfig(projectRoot) {
65
68
  // Keys the CLI does not know (change 0115). `{"context_budjet": 10}` was
66
69
  // silence at every door, and the author believed the budget was set.
67
70
  // Reported, never rejected: the effective values are unchanged.
68
- const KNOWN_KEYS = new Set(["language", "context_budget", "rules", "$comment", "$schema"]);
71
+ const KNOWN_KEYS = new Set(["language", "context_budget", "rules", "surface", "preset", "$comment", "$schema"]);
69
72
  const unknown = cfg && typeof cfg === "object" && !Array.isArray(cfg)
70
73
  ? Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
71
74
  : [];
72
- /** @type {{language: string, context_budget: string, rules: string}} */
73
- const sources = { language: SOURCES.default, context_budget: SOURCES.default, rules: SOURCES.default };
75
+ /** @type {{language: string, context_budget: string, rules: string, surface: string}} */
76
+ const sources = { language: SOURCES.default, context_budget: SOURCES.default, rules: SOURCES.default, surface: SOURCES.default };
74
77
 
75
78
  // language — config.json has always been its only home.
76
79
  //
@@ -79,6 +82,7 @@ export function loadConfig(projectRoot) {
79
82
  // "default" would tell a reader the file does not say what it plainly
80
83
  // says. Only a key that is absent, or one whose value is rejected, falls
81
84
  // back to the default.
85
+ /** @type {string | null} */
82
86
  let language = DEFAULTS.language;
83
87
  if (cfg && "language" in cfg) {
84
88
  const rawLang = cfg.language;
@@ -130,7 +134,18 @@ export function loadConfig(projectRoot) {
130
134
  }
131
135
  }
132
136
 
133
- return { language, context_budget, rules, sources, errors, unknown };
137
+ // surface — which commands AGENTS.md lists (change 0251).
138
+ let surface = DEFAULTS.surface;
139
+ if (cfg && "surface" in cfg) {
140
+ if (SURFACE_VALUES.includes(cfg.surface)) {
141
+ surface = cfg.surface;
142
+ sources.surface = SOURCES.config;
143
+ } else {
144
+ errors.push(`${CONFIG_REL}: unknown surface ${JSON.stringify(cfg.surface)} (use "core" or "full")`);
145
+ }
146
+ }
147
+
148
+ return { language, context_budget, rules, surface, sources, errors, unknown };
134
149
  }
135
150
 
136
151
  /**
@@ -148,6 +163,7 @@ export function configRows(projectRoot) {
148
163
  { option: "language", value: show(cfg.language), source: cfg.sources.language, configured: cfg.sources.language !== SOURCES.default },
149
164
  { option: "context_budget", value: `${cfg.context_budget} tokens`, source: cfg.sources.context_budget, configured: cfg.sources.context_budget !== SOURCES.default },
150
165
  { option: "rules", value: `${cfg.rules.length} rule${cfg.rules.length === 1 ? "" : "s"}`, source: cfg.sources.rules, configured: cfg.sources.rules !== SOURCES.default },
166
+ { option: "surface", value: cfg.surface === "core" ? "core (the session loop)" : "full (every command)", source: cfg.sources.surface, configured: cfg.sources.surface !== SOURCES.default },
151
167
  ];
152
168
  }
153
169
 
@@ -193,14 +209,17 @@ function readJson(file, rel, errors) {
193
209
  *
194
210
  * @returns {string} the file's contents, newline-terminated
195
211
  */
196
- export function scaffoldConfig() {
212
+ export function scaffoldConfig({ surface = /** @type {string | null} */ (null) } = {}) {
197
213
  return JSON.stringify({
198
214
  $comment:
199
- "Doctrina project configuration. Nothing is declared here yet — add a key to " +
215
+ "Doctrina project configuration — add a key to " +
200
216
  "change it. Options: \"language\" (\"en\" | \"pt\", or null to detect per file); " +
217
+ "\"surface\" (\"core\": AGENTS.md lists the session loop and defers the rest to `doctrina --help`; " +
218
+ "\"full\": it lists every command; then `doctrina upgrade --write`); " +
201
219
  `"context_budget" (positive integer, default ${DEFAULTS.context_budget}); ` +
202
220
  "\"rules\" (array of { id, forbid, paths, message } — permanent project " +
203
221
  "constraints enforced by `doctrina validate`). Run `doctrina doctor` to see the " +
204
222
  "effective value of each option and where it came from.",
223
+ ...(surface ? { surface } : {}),
205
224
  }, null, 2) + "\n";
206
225
  }
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
5
+
6
+ // A MERGE THAT STOPPED INSIDE AN ARTIFACT (seventh audit, change 0250).
7
+ //
8
+ // Two branches each added a requirement to the same section of a spec, the
9
+ // merge stopped with `<<<<<<<` / `=======` / `>>>>>>>` in the file, and
10
+ // `validate` answered "0 errors": the markers are not headers, bullets or
11
+ // criteria, so no check had a reason to look at them. A spec is read as
12
+ // written — by the agent, by `context`, by `apply` — markers and both
13
+ // halves included.
14
+
15
+ /**
16
+ * The line of the first conflict marker, or null. A file is conflicted when
17
+ * it holds an opening AND a closing marker at the start of a line, outside a
18
+ * fenced block (a document may show the markers as an example).
19
+ */
20
+ export function conflictMarkerLine(text) {
21
+ let fenced = false;
22
+ let opened = null;
23
+ const lines = String(text).split(/\r?\n/);
24
+ for (let i = 0; i < lines.length; i++) {
25
+ if (/^[ \t]*(```|~~~)/.test(lines[i])) fenced = !fenced;
26
+ if (fenced) continue;
27
+ if (opened === null && /^<{7}( |$)/.test(lines[i])) opened = i + 1;
28
+ else if (opened !== null && /^>{7}( |$)/.test(lines[i])) return opened;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** The Markdown a session reads as current truth: the hub, the product, specs, decisions, skills, contracts, open changes. */
34
+ export function livingArtifacts(projectRoot) {
35
+ const root = path.join(projectRoot, ".doctrina");
36
+ const out = [path.join(projectRoot, "AGENTS.md"), path.join(root, "product.md")].filter(isFile);
37
+ for (const dir of ["specs", "decisions", "skills", "contracts"]) {
38
+ out.push(...walk(path.join(root, dir)).filter((f) => f.endsWith(".md")));
39
+ }
40
+ const changes = path.join(root, "changes");
41
+ if (isDir(changes)) {
42
+ for (const entry of readdirSync(changes)) {
43
+ if (entry === "archive" || entry.startsWith(".")) continue;
44
+ out.push(...walk(path.join(changes, entry)).filter((f) => f.endsWith(".md")));
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Every living artifact a merge stopped in.
52
+ * @returns {{ file: string, line: number }[]}
53
+ */
54
+ export function conflictedArtifacts(projectRoot) {
55
+ const out = [];
56
+ for (const file of livingArtifacts(projectRoot)) {
57
+ const line = conflictMarkerLine(read(file));
58
+ if (line !== null) out.push({ file, line });
59
+ }
60
+ return out;
61
+ }