doctrina-cli 0.20.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 (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -0,0 +1,80 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
6
+
7
+ // WHICH `doctrina` A TERMINAL RUNS (change 0243).
8
+ //
9
+ // The hooks the adapters write call `doctrina` by name, so they run whatever
10
+ // the PATH finds first — and a machine with two Node versions, or an npm
11
+ // prefix that moved, holds two installs. `npm install -g` updated one, the
12
+ // PATH still found the other, and `doctrina --version` answered the old
13
+ // number while the registry and the package both said the new one. Nothing
14
+ // in the CLI could say so: it only knew its own version.
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const real = (/** @type {string} */ p) => {
18
+ try {
19
+ return realpathSync(p);
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+ const versionAt = (/** @type {string} */ pkgDir) => {
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8"));
27
+ return pkg.name === "doctrina-cli" ? String(pkg.version ?? "") : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ };
32
+
33
+ /** The package directory of the CLI that is running. */
34
+ export function runningPackageDir() {
35
+ return real(path.resolve(here, "..", "..")) ?? path.resolve(here, "..", "..");
36
+ }
37
+
38
+ /**
39
+ * The package a `doctrina` found in `dir` belongs to: through the symlink npm
40
+ * writes on POSIX, or beside the `.cmd` shim it writes on Windows.
41
+ * @returns {{ bin: string, pkgDir: string | null, version: string | null } | null}
42
+ */
43
+ function installIn(dir, platform) {
44
+ const names = platform === "win32" ? ["doctrina.cmd", "doctrina.ps1", "doctrina"] : ["doctrina"];
45
+ const bin = names.map((n) => path.join(dir, n)).find((p) => existsSync(p));
46
+ if (!bin) return null;
47
+ const candidates = [];
48
+ const target = real(bin);
49
+ // npm on POSIX: bin/doctrina -> ../lib/node_modules/doctrina-cli/src/index.js
50
+ if (target && target !== bin) candidates.push(path.resolve(path.dirname(target), ".."));
51
+ candidates.push(path.join(dir, "node_modules", "doctrina-cli")); // npm on Windows, and a project's .bin
52
+ candidates.push(path.join(dir, "..", "doctrina-cli")); // node_modules/.bin
53
+ candidates.push(path.join(dir, "..", "lib", "node_modules", "doctrina-cli")); // a POSIX shim that is not a link
54
+ for (const c of candidates) {
55
+ const version = versionAt(c);
56
+ if (version !== null) return { bin, pkgDir: real(c) ?? c, version };
57
+ }
58
+ return { bin, pkgDir: null, version: null };
59
+ }
60
+
61
+ /**
62
+ * Every `doctrina` the PATH holds, in the order a shell would find them, one
63
+ * per package. The first is the one a bare `doctrina` runs.
64
+ * @returns {{ bin: string, pkgDir: string | null, version: string | null, running: boolean }[]}
65
+ */
66
+ export function installsOnPath(env = process.env, platform = process.platform) {
67
+ const sep = platform === "win32" ? ";" : ":";
68
+ const running = runningPackageDir();
69
+ const seen = new Set();
70
+ const out = [];
71
+ for (const dir of String(env.PATH ?? env.Path ?? "").split(sep).filter(Boolean)) {
72
+ const found = installIn(dir, platform);
73
+ if (!found) continue;
74
+ const key = found.pkgDir ?? found.bin;
75
+ if (seen.has(key)) continue;
76
+ seen.add(key);
77
+ out.push({ ...found, running: found.pkgDir === running });
78
+ }
79
+ return out;
80
+ }
@@ -15,7 +15,7 @@ import { stateWord } from "./spec-ops.js";
15
15
  // Clarification gate (review Topic A). A description too thin to spec from is
16
16
  // the moment to ask the user, not to let the agent invent requirements. Prints
17
17
  // the specific gaps before the bootstrap playbook; advisory, never blocking —
18
- // the intake is still captured verbatim (the playbook step 6 resolves it).
18
+ // the intake is still captured verbatim (the playbook step 8 resolves it).
19
19
  /**
20
20
  * Does this argument read as a FILE PATH, or as the description itself?
21
21
  *
@@ -55,13 +55,16 @@ export function nextStepAfterInit(intakeFrom) {
55
55
  "Next: open your AI agent in this repository and tell it:",
56
56
  " read AGENTS.md and run doctrina next",
57
57
  "It turns your description into product.md and specs; you review and approve.",
58
+ "New to Doctrina? `doctrina onboard` walks the whole cycle once, on this repository.",
58
59
  ];
59
60
  }
60
61
  return [
61
62
  "Next: give your AI agent the whole project description. It runs",
62
63
  " doctrina intake --text \"<description>\"",
63
64
  "and turns it into product.md and specs (AGENTS.md, \"Working from intent\");",
64
- "for an existing codebase, `doctrina work --from-diff` backfills them from the code.",
65
+ "for an existing codebase, `doctrina intake --from-code` takes its inventory as the intake",
66
+ "(`doctrina work --from-diff` backfills one change from code already written).",
67
+ "New to Doctrina? `doctrina onboard` walks the whole cycle once, on this repository.",
65
68
  ];
66
69
  }
67
70
 
@@ -71,12 +71,14 @@ export function deferJson() {
71
71
  * Write the held payload with the exit code the command actually returned,
72
72
  * and leave deferred mode. A no-op when the command emitted nothing.
73
73
  */
74
+ /** Write the held payload; false when the command held none. */
74
75
  export function flushJson(code) {
75
76
  deferring = false;
76
- if (!held) return;
77
+ if (!held) return false;
77
78
  const { command, data } = held;
78
79
  held = null;
79
80
  writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
81
+ return true;
80
82
  }
81
83
 
82
84
  // Print a structured payload. `command` is the invocation, `data` whatever
@@ -165,6 +167,12 @@ export async function captureOutput(fn) {
165
167
  try {
166
168
  const code = await fn();
167
169
  return { code: code ?? EXIT.OK, stdout, stderr };
170
+ } catch (err) {
171
+ // A thrown precondition still answers inside the envelope (change 0234):
172
+ // it escaped as English on stderr, with no JSON on stdout at all.
173
+ stderr.push(`error: ${err?.message ?? err}`);
174
+ if (err?.remedy) stderr.push(`hint: run ${err.remedy} first, then retry`);
175
+ return { code: err?.exitCode ?? EXIT.GATE, stdout, stderr, error: err };
168
176
  } finally {
169
177
  console.log = origLog;
170
178
  console.error = origError;
package/src/lib/ledger.js CHANGED
@@ -14,8 +14,7 @@
14
14
  // match the grammar is not a corrupt ledger — it is a person writing a note.
15
15
  // Such lines are counted, never thrown, and never rewritten.
16
16
  import path from "node:path";
17
- import { appendFileSync } from "node:fs";
18
- import { exists, isFile, mkdirp, read, write } from "./fs-ops.js";
17
+ import { appendLine, exists, isFile, mkdirp, read, write } from "./fs-ops.js";
19
18
  import { today } from "./dates.js";
20
19
 
21
20
  export const LEDGER_REL = ".doctrina/changes/archive/LEDGER.md";
@@ -157,7 +156,7 @@ export function appendLedgerLine(projectRoot, line) {
157
156
  mkdirp(path.dirname(p));
158
157
  write(p, HEADER);
159
158
  }
160
- appendFileSync(p, `${line}\n`);
159
+ appendLine(p, line);
161
160
  return p;
162
161
  }
163
162
 
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { mkdirSync, rmdirSync, statSync } from "node:fs";
4
+
5
+ // ONE AT A TIME, FOR THE FEW STEPS THAT MUST BE (seventh audit, change 0250).
6
+ //
7
+ // "One writer per repository" is the model; a planner and an executor in the
8
+ // same clone, or an agent that fires its tool calls in parallel, is what
9
+ // happens. Eight `doctrina work` at once opened three changes numbered 0003
10
+ // and two numbered 0004: each read the highest number on disk before any of
11
+ // the others had created its folder.
12
+ //
13
+ // The lock is a directory — `mkdir` either creates it or fails, atomically,
14
+ // on every filesystem this runs on. It is held for milliseconds. A lock left
15
+ // behind by a process that died is taken over once it is old.
16
+
17
+ const WAIT_MS = 25;
18
+ const GIVE_UP_MS = 5000;
19
+ const STALE_MS = 30000;
20
+
21
+ function sleep(ms) {
22
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
+ }
24
+
25
+ /**
26
+ * Run `fn` holding the project's lock. If the lock cannot be had in time the
27
+ * function runs anyway: a stuck lock must never stop the work it protects.
28
+ * @template T
29
+ * @param {string} projectRoot
30
+ * @param {() => T} fn
31
+ * @returns {T}
32
+ */
33
+ export function withProjectLock(projectRoot, fn) {
34
+ const lock = path.join(projectRoot, ".doctrina", ".lock");
35
+ let held = false;
36
+ for (let waited = 0; waited <= GIVE_UP_MS; waited += WAIT_MS) {
37
+ try {
38
+ mkdirSync(lock);
39
+ held = true;
40
+ break;
41
+ } catch (err) {
42
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "EEXIST") break; // no .doctrina/, read-only tree: nothing to serialise
43
+ try {
44
+ if (Date.now() - statSync(lock).mtimeMs > STALE_MS) rmdirSync(lock);
45
+ } catch { /* released between the two calls */ }
46
+ sleep(WAIT_MS);
47
+ }
48
+ }
49
+ try {
50
+ return fn();
51
+ } finally {
52
+ if (held) {
53
+ try {
54
+ rmdirSync(lock);
55
+ } catch { /* already gone */ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,55 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { exists, read, write } from "./fs-ops.js";
4
+ import { git, GIT_STATE } from "./git.js";
5
+
6
+ // WHAT A REPOSITORY NEEDS SO TWO BRANCHES MERGE THEIR ARTIFACTS (change 0242).
7
+ //
8
+ // Two files are rewritten by every change, so two branches always meet on
9
+ // them: `index.json`, which is derived, and the ledger, which is a list.
10
+ // `.gitattributes` names how each merges — it is committed, so every clone
11
+ // reads it — and the index's driver is a command, which git keeps in the
12
+ // clone's own config: `doctrina hooks install` writes both.
13
+
14
+ export const MERGE_DRIVER = "doctrina-index";
15
+ export const ATTRIBUTE_LINES = [
16
+ `**/.doctrina/index.json merge=${MERGE_DRIVER}`,
17
+ "**/.doctrina/changes/archive/LEDGER.md merge=union",
18
+ ];
19
+ const HEADING = "# Doctrina: the derived index merges as data, the ledger as a list (`doctrina hooks install`)";
20
+
21
+ /** The lines `.gitattributes` still lacks, in order. */
22
+ export function missingAttributes(projectRoot) {
23
+ const file = path.join(projectRoot, ".gitattributes");
24
+ const have = exists(file) ? read(file).split(/\r?\n/).map((l) => l.trim()) : [];
25
+ return ATTRIBUTE_LINES.filter((line) => !have.includes(line));
26
+ }
27
+
28
+ /** Append the missing lines to `.gitattributes`. Returns how many were added. */
29
+ export function writeAttributes(projectRoot) {
30
+ const missing = missingAttributes(projectRoot);
31
+ if (missing.length === 0) return 0;
32
+ const file = path.join(projectRoot, ".gitattributes");
33
+ const before = exists(file) ? read(file) : "";
34
+ const eol = before.includes("\r\n") ? "\r\n" : "\n";
35
+ const gap = before === "" || before.endsWith("\n") ? "" : eol;
36
+ const head = before.includes(HEADING) ? [] : [HEADING];
37
+ write(file, before + gap + (before === "" ? "" : eol) + [...head, ...missing].join(eol) + eol, { force: true });
38
+ return missing.length;
39
+ }
40
+
41
+ /** The driver command this clone runs for the index, or null when it has none. */
42
+ export function configuredDriver(projectRoot) {
43
+ const r = git(projectRoot, ["config", "--get", `merge.${MERGE_DRIVER}.driver`]);
44
+ return r.state === GIT_STATE.OK && r.stdout.trim() !== "" ? r.stdout.trim() : null;
45
+ }
46
+
47
+ /**
48
+ * Point this clone's merge driver at `command` (the CLI, pinned by path as
49
+ * the hook is). Returns false when git refused.
50
+ */
51
+ export function configureDriver(projectRoot, command) {
52
+ const set = (/** @type {string} */ k, /** @type {string} */ v) =>
53
+ git(projectRoot, ["config", `merge.${MERGE_DRIVER}.${k}`, v]).state === GIT_STATE.OK;
54
+ return set("name", "Doctrina index merge") && set("driver", `${command} index merge %O %A %B`);
55
+ }
@@ -90,7 +90,7 @@ export function parseLog(stdout) {
90
90
  for (const line of stdout.split("\n")) {
91
91
  if (line.startsWith("@@")) {
92
92
  const [hash, ts, ...rest] = line.slice(2).split("|");
93
- current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
93
+ current = { hash, ts: Number(ts), subject: rest.join("|"), files: /** @type {string[]} */ ([]) };
94
94
  commits.push(current);
95
95
  } else if (line.trim() !== "" && current) {
96
96
  current.files.push(line.trim());
@@ -181,7 +181,7 @@ export function readSeries(projectRoot) {
181
181
  }
182
182
 
183
183
  /** The most recent saved snapshot, excluding one basename. */
184
- export function latestSnapshot(projectRoot, excludeBase = null) {
184
+ export function latestSnapshot(projectRoot, excludeBase = /** @type {string | null} */ (null)) {
185
185
  const series = readSeries(projectRoot).filter((s) => `${s.generated}.json` !== excludeBase);
186
186
  return series.length > 0 ? series[series.length - 1] : null;
187
187
  }
@@ -0,0 +1,197 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { checklistProgress, parseChangeTitle } from "./doc-model.js";
6
+ import { intakeStatus } from "./intake-model.js";
7
+ import { parseAcceptanceCriteria } from "./criteria.js";
8
+ import { collect } from "./coverage-model.js";
9
+ import { gitLines } from "./git.js";
10
+
11
+ // THE CYCLE ONCE, ON THE REAL REPOSITORY (change 0256).
12
+ //
13
+ // A newcomer had the reference (2,900 lines), a getting-started page, and
14
+ // `next`, which names one action and assumes the reader knows why. Nothing
15
+ // walked the whole cycle once. OpenSpec's `onboard` does that with a model
16
+ // narrating; here the CLI reads where the project stands and prints the
17
+ // walk — what each step is for, what to run, what to tell the human, and
18
+ // where to stop and ask — and the agent is the guide.
19
+ //
20
+ // Every "done" below is read from the tree, never stored: a tour that
21
+ // remembers its own progress is a second place for a fact to go stale.
22
+
23
+ const CODE_FILE = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|java|kt|rb|php|cs|swift|scala|c|cc|cpp|h|hpp|sh)$/i;
24
+ const TODO_LINE = /\b(TODO|FIXME)\b[:\s-]*(.{0,90})/;
25
+ const FILES_READ = 400;
26
+ const FILE_BYTES = 200_000;
27
+ export const CANDIDATES_SHOWN = 3;
28
+
29
+ /**
30
+ * Where the project stands, read from the tree.
31
+ * @param {string} projectRoot
32
+ */
33
+ export function onboardState(projectRoot) {
34
+ const dir = path.join(projectRoot, ".doctrina");
35
+ const installed = isDir(dir);
36
+ const specsDir = path.join(dir, "specs");
37
+ const specs = isDir(specsDir)
38
+ ? readdirSync(specsDir).filter((cap) => isFile(path.join(specsDir, cap, "spec.md"))).sort()
39
+ : [];
40
+ const intake = path.join(dir, "intake.md");
41
+ const intakePending = isFile(intake) && intakeStatus(read(intake)) === "pending";
42
+ const changesDir = path.join(dir, "changes");
43
+ const open = [];
44
+ if (isDir(changesDir)) {
45
+ for (const id of readdirSync(changesDir).filter((d) => /^\d{4}-/.test(d)).sort()) {
46
+ const proposal = path.join(changesDir, id, "proposal.md");
47
+ if (!isFile(proposal)) continue;
48
+ const tasksPath = path.join(changesDir, id, "tasks.md");
49
+ const p = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, placeholders: 0 };
50
+ open.push({
51
+ id,
52
+ title: parseChangeTitle(read(proposal)) ?? "",
53
+ planned: p.total > 0 && p.placeholders === 0,
54
+ ticked: p.total > 0 && p.placeholders === 0 && p.done === p.total,
55
+ tasks: { total: p.total, done: p.done },
56
+ });
57
+ }
58
+ }
59
+ const archiveDir = path.join(changesDir, "archive");
60
+ const archived = isDir(archiveDir)
61
+ ? readdirSync(archiveDir).filter((d) => /^\d{4}-\d{2}-\d{2}-\d{4}-/.test(d)).sort()
62
+ : [];
63
+ const hasCode = codeFiles(projectRoot, 1).length > 0;
64
+ return {
65
+ installed, specs, intakePending, hasCode, open,
66
+ closed: archived.length,
67
+ lastClosed: archived.length > 0 ? `.doctrina/changes/archive/${archived[archived.length - 1]}/` : null,
68
+ };
69
+ }
70
+
71
+ /** Tracked code files, as git lists them; none outside a repository. */
72
+ function codeFiles(projectRoot, limit) {
73
+ const out = [];
74
+ for (const f of gitLines(projectRoot, ["ls-files", "--cached", "--others", "--exclude-standard"])) {
75
+ if (f.startsWith(".doctrina/") || /(^|\/)(node_modules|vendor|dist|build)\//.test(f) || !CODE_FILE.test(f)) continue;
76
+ out.push(f);
77
+ if (out.length >= limit) break;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Small, real first tasks read from this repository: a criterion with no
84
+ * proof yet, then a TODO somebody left in the code.
85
+ * @param {string} projectRoot
86
+ * @returns {{ kind: "criterion" | "todo", where: string, text: string, prompt: string }[]}
87
+ */
88
+ export function firstTaskCandidates(projectRoot) {
89
+ const out = [];
90
+ for (const report of collect(projectRoot)) {
91
+ if (out.length >= CANDIDATES_SHOWN) break;
92
+ const criteria = parseAcceptanceCriteria(read(report.specPath));
93
+ for (const row of report.rows) {
94
+ if (out.length >= CANDIDATES_SHOWN) break;
95
+ const kind = "was" in row ? row.was : row.kind;
96
+ if (kind !== "bare" && kind !== "dangling") continue;
97
+ const body = String(criteria[row.n - 1]?.body ?? "").replace(/^\[[a-z]+\]\s*/i, "").replace(/\s+—\s+(verified|proven|evidenced|demonstrated) by .*$/i, "").trim();
98
+ if (body === "") continue;
99
+ out.push({
100
+ kind: /** @type {"criterion"} */ ("criterion"),
101
+ where: `${report.cap} #${row.n}`,
102
+ text: `${kind === "bare" ? "no proof cited" : "the proof it cites is missing"}: ${clip(body, 90)}`,
103
+ prompt: `prove ${report.cap} criterion ${row.n}: ${clip(body, 70)}`,
104
+ });
105
+ }
106
+ }
107
+ let read_ = 0;
108
+ for (const f of codeFiles(projectRoot, FILES_READ)) {
109
+ if (out.length >= CANDIDATES_SHOWN * 2) break;
110
+ const abs = path.join(projectRoot, f);
111
+ if (!isFile(abs)) continue;
112
+ let text;
113
+ try {
114
+ text = read(abs);
115
+ } catch {
116
+ continue;
117
+ }
118
+ if (text.length > FILE_BYTES || ++read_ > FILES_READ) continue;
119
+ const lines = text.split("\n");
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const m = lines[i].match(TODO_LINE);
122
+ if (!m || m[2].trim().length < 8) continue;
123
+ const note = m[2].trim().replace(/\s*(\*\/|-->)\s*$/, "");
124
+ out.push({ kind: /** @type {"todo"} */ ("todo"), where: `${f}:${i + 1}`, text: `${m[1]} ${clip(note, 90)}`, prompt: clip(note, 70) });
125
+ break;
126
+ }
127
+ }
128
+ return out.slice(0, CANDIDATES_SHOWN * 2);
129
+ }
130
+
131
+ const clip = (/** @type {string} */ s, /** @type {number} */ n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
132
+
133
+ /**
134
+ * The walk: every step with whether the tree says it is done, what it is
135
+ * for, what to run and where to ask. `current` is the first one not done.
136
+ * @param {ReturnType<typeof onboardState>} state
137
+ */
138
+ export function onboardSteps(state) {
139
+ const first = state.open[0] ?? null;
140
+ const id = first?.id ?? "<id>";
141
+ const ran = state.closed > 0;
142
+ const steps = [
143
+ { id: "install", title: "Install",
144
+ done: state.installed,
145
+ evidence: state.installed ? ".doctrina/ and AGENTS.md are here" : "",
146
+ say: "Doctrina keeps what the system should do in `.doctrina/` and tells every agent how to work in `AGENTS.md`; nothing else in the repository is touched.",
147
+ run: ["doctrina init"],
148
+ ask: "" },
149
+ { id: "describe", title: "Describe the project",
150
+ done: state.installed && state.specs.length > 0 && !state.intakePending,
151
+ evidence: state.specs.length > 0 ? `${state.specs.length} capability spec${state.specs.length === 1 ? "" : "s"} (${state.specs.slice(0, 4).join(", ")}${state.specs.length > 4 ? ", …" : ""})` : "",
152
+ say: "The specs are the current truth: one per capability, each requirement a testable sentence, each criterion citing its proof. Everything after this step changes them through a change.",
153
+ run: state.intakePending
154
+ ? ["doctrina intake # the stored description is pending: execute the playbook it prints"]
155
+ : [state.hasCode ? "doctrina intake --from-code # describe what the code does today" : 'doctrina intake --text "<what the project is, in a paragraph>"'],
156
+ ask: state.hasCode ? "what the code cannot tell: who uses it, and what must never break." : "for the project in a paragraph: who it is for and what it must do." },
157
+ { id: "first-task", title: "Pick a first task and open it",
158
+ done: state.open.length > 0 || ran,
159
+ evidence: first ? `${first.id} is open` : (ran ? "a change was opened and closed" : ""),
160
+ say: "A request that changes behaviour becomes a change before code is edited; the CLI scaffolds it and prints the playbook. Small and real beats a toy: the cycle is learned on work that matters.",
161
+ run: ['doctrina work "<the task>"'],
162
+ ask: "to choose one of the candidates, or to name a small task of their own — something done in under an hour." },
163
+ { id: "plan", title: "Write the delta and the tasks",
164
+ done: ran || state.open.some((ch) => ch.planned),
165
+ evidence: first?.planned ? `${first.id} has ${first.tasks.total} task${first.tasks.total === 1 ? "" : "s"} written` : "",
166
+ say: "The delta is the sentence the spec gains or loses; the tasks are how the code gets there. Nothing is implemented yet — this is the moment the human reads and corrects.",
167
+ run: [`doctrina work --resume ${id} # the playbook`, `doctrina change check ${id} # would the close pass?`],
168
+ ask: "whether the requirement you wrote is what they meant — show it to them before any code.",
169
+ askAfter: true },
170
+ { id: "implement", title: "Implement and prove",
171
+ done: ran || state.open.some((ch) => ch.ticked),
172
+ evidence: first?.ticked ? `every task of ${first.id} is ticked` : "",
173
+ say: "Each task is implemented and ticked, and each new criterion cites the test that proves it: `coverage` reads that citation, not the claim.",
174
+ run: [`doctrina change tick ${id} <n>`, "doctrina verify"],
175
+ ask: "" },
176
+ { id: "close", title: "Close",
177
+ done: ran,
178
+ evidence: ran ? `${state.closed} change${state.closed === 1 ? "" : "s"} closed — the last at ${state.lastClosed}` : "",
179
+ say: "One command merges the delta into the spec, runs every gate and archives the change. If it refuses, the change is not done — it names what is missing.",
180
+ run: [`doctrina close ${id}`],
181
+ ask: "" },
182
+ ];
183
+ const at = steps.findIndex((s) => !s.done);
184
+ return { steps, current: at < 0 ? null : steps[at].id };
185
+ }
186
+
187
+ /** What a project that already ran the cycle shows a newcomer. */
188
+ export function tourLines(state) {
189
+ const cap = state.specs[0] ?? "<capability>";
190
+ return [
191
+ "doctrina prime # the state of the project in one read — every session starts here",
192
+ `doctrina why ${cap}${" ".repeat(Math.max(1, 17 - cap.length))}# why a capability exists: intent, proof, decisions, history`,
193
+ ...(state.lastClosed ? [`${state.lastClosed}proposal.md # a finished change, start to end`] : []),
194
+ 'doctrina work "<request>" # the next request — it says by itself when no change is needed',
195
+ "doctrina hooks install # once per clone: the commit hook and the merge drivers",
196
+ ];
197
+ }
@@ -0,0 +1,177 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk, write } from "./fs-ops.js";
5
+ import { configPath } from "./config.js";
6
+
7
+ // A PRESET: THE ORGANISATION'S HOUSE, IN A FOLDER (change 0253).
8
+ //
9
+ // Every project of one company starts from the same things — its language,
10
+ // the rules no repository may break, the decisions that bind all of them,
11
+ // its templates, the checks its pipeline runs — and each `doctrina init`
12
+ // started from the CLI's defaults, so the house style was re-typed, or
13
+ // forgotten, per repository. A preset is a folder (a repository the team
14
+ // clones, a submodule, a path on a shared drive — the CLI makes no network
15
+ // call) that `init --preset` and `upgrade --preset` lay over a project:
16
+ //
17
+ // preset.json { "name", "description", "config": { … } }
18
+ // templates/** → .doctrina/templates/** (per-file overrides, ADR 0019)
19
+ // decisions/*.md → .doctrina/decisions/
20
+ // skills/*.md → .doctrina/skills/
21
+ // verify.json → .doctrina/verify.json (only when the project has none)
22
+ //
23
+ // ADDITIVE, like every scaffold update: a file the project already has is
24
+ // kept, a config key it already declares is kept, and the plan says so.
25
+
26
+ export const PRESET_FILE = "preset.json";
27
+ const NAME = /^[a-z0-9][a-z0-9-]*$/;
28
+ const CONFIG_KEYS = ["language", "context_budget", "rules", "surface"];
29
+ const FOLDERS = [
30
+ { from: "templates", to: ".doctrina/templates", only: null },
31
+ { from: "decisions", to: ".doctrina/decisions", only: /\.md$/ },
32
+ { from: "skills", to: ".doctrina/skills", only: /\.md$/ },
33
+ ];
34
+
35
+ /**
36
+ * Read a preset folder. Throws an Error whose message says what is wrong
37
+ * with it — the caller prints it and exits 2.
38
+ * @param {string} dir
39
+ * @returns {{ dir: string, name: string, description: string, config: Record<string, any> }}
40
+ */
41
+ export function readPreset(dir) {
42
+ const root = path.resolve(dir);
43
+ if (!isDir(root)) throw new Error(`preset ${dir} is not a folder`);
44
+ const file = path.join(root, PRESET_FILE);
45
+ if (!isFile(file)) throw new Error(`${dir} has no ${PRESET_FILE} — a preset is a folder with one: { "name": "acme", "config": { … } }`);
46
+ let data;
47
+ try {
48
+ data = JSON.parse(read(file));
49
+ } catch (err) {
50
+ throw new Error(`${path.join(dir, PRESET_FILE)} is not valid JSON: ${/** @type {Error} */ (err).message}`);
51
+ }
52
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(`${PRESET_FILE} must be an object`);
53
+ if (typeof data.name !== "string" || !NAME.test(data.name)) throw new Error(`${PRESET_FILE} needs a "name" of lowercase letters, digits and hyphens`);
54
+ const config = data.config ?? {};
55
+ if (typeof config !== "object" || Array.isArray(config)) throw new Error(`${PRESET_FILE}: "config" must be an object`);
56
+ const unknown = Object.keys(config).filter((k) => !CONFIG_KEYS.includes(k));
57
+ if (unknown.length > 0) throw new Error(`${PRESET_FILE}: config key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => `"${k}"`).join(", ")} unknown — a preset sets ${CONFIG_KEYS.join(", ")}`);
58
+ return { dir: root, name: data.name, description: typeof data.description === "string" ? data.description : "", config };
59
+ }
60
+
61
+ const posix = (/** @type {string} */ p) => p.split(path.sep).join("/");
62
+ const adrNumber = (/** @type {string} */ name) => name.match(/^(\d{4})-/)?.[1] ?? null;
63
+
64
+ /**
65
+ * What laying the preset over the project would do. Reads only.
66
+ * @returns {{
67
+ * files: { rel: string, from: string, action: "copy" | "same" | "kept", note: string }[],
68
+ * config: { key: string, value: any, action: "set" | "kept" | "same" }[],
69
+ * verify: string[],
70
+ * }}
71
+ */
72
+ export function planPreset(projectRoot, preset) {
73
+ /** @type {{ rel: string, from: string, action: "copy" | "same" | "kept", note: string }[]} */
74
+ const files = [];
75
+ const taken = new Map();
76
+ const decisions = path.join(projectRoot, ".doctrina", "decisions");
77
+ if (isDir(decisions)) {
78
+ for (const f of readdirSync(decisions)) {
79
+ const n = adrNumber(f);
80
+ if (n) taken.set(n, f);
81
+ }
82
+ }
83
+ const add = (/** @type {string} */ from, /** @type {string} */ rel) => {
84
+ const dest = path.join(projectRoot, rel);
85
+ if (isFile(dest)) {
86
+ files.push({ rel, from, action: read(dest) === read(from) ? "same" : "kept", note: "the project has its own" });
87
+ return;
88
+ }
89
+ // An ADR's number is its identity: a preset decision whose number the
90
+ // project already gave to another one is not renumbered behind its back.
91
+ const n = rel.startsWith(".doctrina/decisions/") ? adrNumber(path.basename(rel)) : null;
92
+ if (n && taken.has(n)) {
93
+ files.push({ rel, from, action: "kept", note: `ADR number ${n} is ${taken.get(n)} here — copy it under a free number by hand` });
94
+ return;
95
+ }
96
+ files.push({ rel, from, action: "copy", note: "" });
97
+ };
98
+ for (const folder of FOLDERS) {
99
+ const base = path.join(preset.dir, folder.from);
100
+ for (const from of walk(base)) {
101
+ if (folder.only && !folder.only.test(from)) continue;
102
+ add(from, `${folder.to}/${posix(path.relative(base, from))}`);
103
+ }
104
+ }
105
+ /** @type {string[]} */
106
+ let verify = [];
107
+ const presetVerify = path.join(preset.dir, "verify.json");
108
+ if (isFile(presetVerify)) {
109
+ add(presetVerify, ".doctrina/verify.json");
110
+ try {
111
+ verify = (JSON.parse(read(presetVerify)).checks ?? []).map((/** @type {{ run?: string }} */ ch) => String(ch.run ?? "")).filter(Boolean);
112
+ } catch { /* an unreadable verify.json is reported by `doctrina verify` once copied */ }
113
+ }
114
+
115
+ /** @type {Record<string, any>} */
116
+ let current = {};
117
+ try {
118
+ current = JSON.parse(read(configPath(projectRoot)));
119
+ } catch { /* no config.json yet, or an unreadable one: nothing is declared */ }
120
+ const config = Object.entries(preset.config).map(([key, value]) => ({
121
+ key, value,
122
+ action: /** @type {"set" | "kept" | "same"} */ (!(key in current) ? "set"
123
+ : JSON.stringify(current[key]) === JSON.stringify(value) ? "same" : "kept"),
124
+ }));
125
+ return { files, config, verify };
126
+ }
127
+
128
+ /** How many things the plan would write. */
129
+ export function presetPending(plan) {
130
+ return plan.files.filter((f) => f.action === "copy").length + plan.config.filter((c) => c.action === "set").length;
131
+ }
132
+
133
+ /** Write what the plan calls for. The caller re-derives the index. */
134
+ export function applyPreset(projectRoot, preset, plan) {
135
+ for (const f of plan.files) {
136
+ if (f.action === "copy") write(path.join(projectRoot, f.rel), read(f.from));
137
+ }
138
+ const file = configPath(projectRoot);
139
+ /** @type {Record<string, any>} */
140
+ let current = {};
141
+ try {
142
+ current = JSON.parse(read(file));
143
+ } catch { /* written whole below */ }
144
+ /** @type {Record<string, any>} */
145
+ const next = { ...current };
146
+ for (const c of plan.config) if (c.action === "set") next[c.key] = c.value;
147
+ next.preset = preset.name;
148
+ write(file, JSON.stringify(next, null, 2) + "\n", { force: true });
149
+ }
150
+
151
+ /**
152
+ * The plan as lines a person reads: what is copied, what is kept and why,
153
+ * and — said every time — the commands a preset's verify.json would run.
154
+ * @param {boolean} written
155
+ * @returns {string[]}
156
+ */
157
+ export function describePreset(preset, plan, written) {
158
+ const verb = written ? "copied " : "would copy";
159
+ const lines = [`preset ${preset.name}${preset.description ? ` — ${preset.description}` : ""}`];
160
+ for (const f of plan.files) {
161
+ if (f.action === "copy") lines.push(` ${verb} ${f.rel}`);
162
+ else if (f.action === "kept") lines.push(` kept ${f.rel} — ${f.note}`);
163
+ }
164
+ for (const c of plan.config) {
165
+ const shown = Array.isArray(c.value) ? `${c.value.length} rule${c.value.length === 1 ? "" : "s"}` : JSON.stringify(c.value);
166
+ if (c.action === "set") lines.push(` ${written ? "set " : "would set "} config ${c.key} = ${shown}`);
167
+ else if (c.action === "kept") lines.push(` kept config ${c.key} — the project declares another value`);
168
+ }
169
+ const copiesVerify = plan.files.some((f) => f.rel === ".doctrina/verify.json" && f.action === "copy");
170
+ if (copiesVerify && plan.verify.length > 0) {
171
+ lines.push(` note the preset's verify.json declares what \`doctrina verify\` RUNS on this machine: ${plan.verify.map((r) => `\`${r}\``).join(", ")} — read it before running the gate`);
172
+ }
173
+ const same = plan.files.filter((f) => f.action === "same").length;
174
+ if (same > 0) lines.push(` same ${same} file${same === 1 ? "" : "s"} already as the preset has ${same === 1 ? "it" : "them"}`);
175
+ if (presetPending(plan) === 0) lines.push(" nothing to add — the project already carries the preset");
176
+ return lines;
177
+ }