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
package/src/lib/fs-ops.js CHANGED
@@ -42,17 +42,68 @@ export function withEol(text, eol) {
42
42
  return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
43
43
  }
44
44
 
45
+ // ...and so is the line ending (seventh audit, change 0250).
46
+ //
47
+ // A Windows checkout with `core.autocrlf` stores every artifact with CRLF,
48
+ // and the grammar was read with patterns written for LF. `.*$` does not
49
+ // cross a `\r`, so a header stamp matched nothing and reported success; a
50
+ // placeholder bullet `-\r` read as an empty requirement, and a fresh spec
51
+ // carried five EARS warnings an LF checkout never saw; a line diff showed
52
+ // the whole file as changed. Each was fixed where it was found, one caller
53
+ // at a time (`eolOf`/`withEol`), and the next caller forgot.
54
+ //
55
+ // So the rule is the door's: text is READ with LF, whatever the file holds,
56
+ // and WRITTEN BACK with the ending the file already has. No parser
57
+ // downstream has to know which checkout it is running in, and a rewrite
58
+ // never mixes endings or turns a file's every line into a diff.
45
59
  export function read(p) {
46
60
  const text = readFileSync(p, "utf8");
47
- return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
61
+ const body = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
62
+ return body.includes("\r\n") ? body.replace(/\r\n/g, "\n") : body;
48
63
  }
49
64
 
50
- export function write(p, content, { force = false } = {}) {
51
- if (existsSync(p) && !force) {
65
+ /** Does the file at `p` end its lines with CRLF today? False when absent or unreadable. */
66
+ function storedAsCrlf(p) {
67
+ try {
68
+ return readFileSync(p, "utf8").includes("\r\n");
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export function write(p, content, { force = false, atomic = false } = {}) {
75
+ const there = existsSync(p);
76
+ if (there && !force) {
52
77
  throw new Error(`refusing to overwrite existing file: ${p} (pass --force to allow)`);
53
78
  }
54
79
  mkdirSync(path.dirname(p), { recursive: true });
55
- writeFileSync(p, content);
80
+ // A rewrite keeps the ending the file has; a new file is written as given.
81
+ const text = there && typeof content === "string" && storedAsCrlf(p)
82
+ ? content.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n")
83
+ : content;
84
+ if (!atomic) {
85
+ writeFileSync(p, text);
86
+ return;
87
+ }
88
+ // Whole or not at all: written beside the target and moved over it, so a
89
+ // command reading the file at that instant never sees half of it. Where the
90
+ // move is refused (a reader holding the file open on Windows) the plain
91
+ // write still lands.
92
+ const tmp = `${p}.${process.pid}.tmp`;
93
+ try {
94
+ writeFileSync(tmp, text);
95
+ renameSync(tmp, p);
96
+ } catch {
97
+ writeFileSync(p, text);
98
+ try {
99
+ unlinkSync(tmp);
100
+ } catch { /* never created, or already moved */ }
101
+ }
102
+ }
103
+
104
+ /** Append `line` to the text file at `p`, with the ending the file already uses. */
105
+ export function appendLine(p, line) {
106
+ writeFileSync(p, `${line}${storedAsCrlf(p) ? "\r\n" : "\n"}`, { flag: "a" });
56
107
  }
57
108
 
58
109
  export function exists(p) {
@@ -133,17 +184,34 @@ export function pruneEmptyDirs(dir, stopAt) {
133
184
  return removed;
134
185
  }
135
186
 
136
- // Recursive file walk. Returns absolute paths of every regular file.
137
- export function walk(root) {
187
+ // Recursive file walk. Returns absolute paths of every regular file, sorted.
188
+ //
189
+ // The directory listing already says what each entry is, so it is asked
190
+ // once (change 0249): the walk used to `stat` every entry twice — is it a
191
+ // directory? is it a file? — which was a third of `validate`'s time on this
192
+ // repository. A symbolic link is still followed with a `stat`, as before.
193
+ // `depth` bounds how far down it goes: 1 is the files directly in `root`.
194
+ /**
195
+ * @param {string} root
196
+ * @param {{ depth?: number }} [opts]
197
+ * @returns {string[]}
198
+ */
199
+ export function walk(root, { depth = Infinity } = {}) {
200
+ /** @type {string[]} */
138
201
  const out = [];
139
202
  if (!exists(root)) return out;
140
- const stack = [root];
203
+ /** @type {[string, number][]} */
204
+ const stack = [[root, 1]];
141
205
  while (stack.length) {
142
- const dir = stack.pop();
143
- for (const entry of readdirSync(dir)) {
144
- const full = path.join(dir, entry);
145
- if (isDir(full)) stack.push(full);
146
- else if (isFile(full)) out.push(full);
206
+ const [dir, level] = /** @type {[string, number]} */ (stack.pop());
207
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
208
+ const full = path.join(dir, entry.name);
209
+ const link = entry.isSymbolicLink();
210
+ if (link ? isDir(full) : entry.isDirectory()) {
211
+ if (level < depth) stack.push([full, level + 1]);
212
+ } else if (link ? isFile(full) : entry.isFile()) {
213
+ out.push(full);
214
+ }
147
215
  }
148
216
  }
149
217
  return out.sort();
package/src/lib/gates.js CHANGED
@@ -67,7 +67,7 @@ export const GATES = {
67
67
  blockers(projectRoot, changeDir) {
68
68
  const out = [];
69
69
  // Same counter as every other surface (change 0067).
70
- const countUnchecked = (text, section = null) =>
70
+ const countUnchecked = (text, section = /** @type {string | null} */ (null)) =>
71
71
  checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
72
72
 
73
73
  const tasksPath = path.join(changeDir, "tasks.md");
@@ -224,6 +224,12 @@ export const SEQUENCES = {
224
224
  // the CLI's source, which is how a pt-BR project sat red under `clarify`
225
225
  // with no clue why (change 0047). Advisory: a default is not a fault.
226
226
  { id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
227
+ // Which `doctrina` the terminal runs, and how the repository merges
228
+ // (changes 0242, 0243). Readouts of the machine and the clone, not of the
229
+ // artifacts: the hooks call `doctrina` by name, so a second install ahead
230
+ // on the PATH is the one they run.
231
+ { id: "cli", label: "cli", level: "advisory", argv: null, rerun: "doctrina --version" },
232
+ { id: "merges", label: "merges", level: "advisory", argv: null, rerun: "doctrina hooks install" },
227
233
  // Also a readout, and also advisory. It appears only when the operator
228
234
  // has switched the usage log on: an instrument that is off has nothing
229
235
  // to say, and a row saying so every run would train the reader to skip
package/src/lib/git.js CHANGED
@@ -69,6 +69,24 @@ export function git(cwd, args, { env } = {}) {
69
69
  return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
70
70
  }
71
71
 
72
+ /**
73
+ * Where git keeps `name` for the repository `cwd` is in — `hooks`,
74
+ * `info/exclude` — as an absolute path, or null outside a repository. Asked
75
+ * of git, never built from `.git/`: in a worktree and in a submodule `.git`
76
+ * is a FILE, and `core.hooksPath` moves the hooks anywhere (change 0250).
77
+ */
78
+ export function gitPathOf(cwd, name) {
79
+ const r = git(cwd, ["rev-parse", "--git-path", name]);
80
+ if (r.state !== GIT_STATE.OK || r.stdout.trim() === "") return null;
81
+ return path.resolve(cwd, r.stdout.trim());
82
+ }
83
+
84
+ /** `cwd` relative to the top of its repository, with forward slashes ("" at the top), or null outside one. */
85
+ export function repoPrefix(cwd) {
86
+ const r = git(cwd, ["rev-parse", "--show-prefix"]);
87
+ return r.state === GIT_STATE.OK ? r.stdout.trim().replace(/\/+$/, "") : null;
88
+ }
89
+
72
90
  // Is this a git repository at all?
73
91
  export function isRepo(cwd) {
74
92
  return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
@@ -30,6 +30,12 @@ export function load(projectRoot) {
30
30
  try {
31
31
  return JSON.parse(raw);
32
32
  } catch (err) {
33
+ // Conflict markers are the one way this file usually stops parsing
34
+ // (change 0242): two branches closed a change each, and git merged the
35
+ // derived index as text. Said as what it is, with the two ways out.
36
+ if (/^(<{7}|={7}|>{7})( |$)/m.test(raw)) {
37
+ throw new Error(".doctrina/index.json carries git's merge conflict markers — it is derived, so `doctrina index rebuild` writes it again from the tree (and `doctrina hooks install` makes git merge it by itself)");
38
+ }
33
39
  throw new Error(`.doctrina/index.json is not valid JSON: ${err.message}`);
34
40
  }
35
41
  }
@@ -42,7 +48,10 @@ export function save(projectRoot, index) {
42
48
  index.framework_version = newestVersion(index.framework_version, cliVersion());
43
49
  const p = indexPath(projectRoot);
44
50
  const text = JSON.stringify(index, null, 2) + "\n";
45
- write(p, text, { force: true });
51
+ // Atomic (change 0250): every command reads this file, and one that read
52
+ // it while another was half-way through writing it stopped with
53
+ // "Unexpected end of JSON input".
54
+ write(p, text, { force: true, atomic: true });
46
55
  }
47
56
 
48
57
  export function blank(projectName, date) {
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ import { ARTIFACT_CATEGORIES } from "./index-json.js";
3
+ import { newestVersion } from "./version.js";
4
+
5
+ // THE INDEX MERGES AS WHAT IT IS: A LIST OF ARTIFACTS (change 0242).
6
+ //
7
+ // `index.json` is one file every change rewrites — a version here, a closed
8
+ // change appended there, the date on top. Two branches that each close a
9
+ // change therefore meet on the same lines, and git, which merges text, stops
10
+ // with conflict markers in a file nobody wrote by hand.
11
+ //
12
+ // Merged as data there is nothing to resolve: an entry one branch added is
13
+ // added, an entry one branch removed is removed, and where both changed the
14
+ // same field the newer value wins — a version only grows, a date only moves
15
+ // forward. What this cannot know (a header edited on both sides) the tree
16
+ // still decides: `doctrina index rebuild` derives the index from the files,
17
+ // and the commit hook runs it.
18
+
19
+ const key = (/** @type {any} */ entry) => String(entry?.path ?? entry?.id ?? "");
20
+ const same = (/** @type {any} */ a, /** @type {any} */ b) => JSON.stringify(a) === JSON.stringify(b);
21
+
22
+ /** `1.10.0` over `1.9.0`; anything that is not three numbers compares as text. */
23
+ function laterVersion(a, b) {
24
+ const parts = (/** @type {string} */ v) => String(v).split(".").map((n) => Number(n));
25
+ const [x, y] = [parts(a), parts(b)];
26
+ if (x.some(Number.isNaN) || y.some(Number.isNaN)) return String(a) >= String(b) ? a : b;
27
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
28
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0) ? a : b;
29
+ }
30
+ return a;
31
+ }
32
+
33
+ /** Both sides changed `field` to different values: the one that moved furthest. */
34
+ function settle(field, ours, theirs) {
35
+ if (field === "framework_version") return newestVersion(ours, theirs);
36
+ if (field === "version") return laterVersion(ours, theirs);
37
+ if (/(^|_)(updated|date)$/.test(field)) return String(ours) >= String(theirs) ? ours : theirs;
38
+ return ours;
39
+ }
40
+
41
+ /** Three-way merge of one object's fields. */
42
+ function mergeFields(base, ours, theirs) {
43
+ /** @type {Record<string, any>} */
44
+ const out = {};
45
+ for (const field of new Set([...Object.keys(ours ?? {}), ...Object.keys(theirs ?? {})])) {
46
+ const [b, o, t] = [base?.[field], ours?.[field], theirs?.[field]];
47
+ let value;
48
+ if (same(o, t) || same(t, b)) value = o;
49
+ else if (same(o, b)) value = t;
50
+ else if (o === undefined || t === undefined) value = o ?? t;
51
+ else value = settle(field, o, t);
52
+ if (value !== undefined) out[field] = value;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Three-way merge of one artifact category, in the order a rebuild writes. */
58
+ function mergeList(base, ours, theirs) {
59
+ const by = (/** @type {any[]} */ list) => new Map((list ?? []).map((e) => [key(e), e]));
60
+ const [b, o, t] = [by(base), by(ours), by(theirs)];
61
+ const out = [];
62
+ for (const k of new Set([...o.keys(), ...t.keys()])) {
63
+ const [eb, eo, et] = [b.get(k), o.get(k), t.get(k)];
64
+ if (eo && et) out.push(mergeFields(eb, eo, et));
65
+ // Present on one side only: added there, or removed on the other. An
66
+ // entry the base never had was added; one it had was removed — unless
67
+ // the side that kept it also changed it, which a removal must not erase.
68
+ else if (!eb) out.push(eo ?? et);
69
+ else if (!same(eo ?? et, eb)) out.push(eo ?? et);
70
+ }
71
+ return out.sort((x, y) => (key(x) < key(y) ? -1 : key(x) > key(y) ? 1 : 0));
72
+ }
73
+
74
+ /**
75
+ * Merge two indexes that descend from `base`. Pure: returns the merged index.
76
+ * @param {any} base the common ancestor (`{}` when there is none)
77
+ * @param {any} ours
78
+ * @param {any} theirs
79
+ */
80
+ export function mergeIndex(base, ours, theirs) {
81
+ const out = mergeFields({ ...base, artifacts: undefined }, { ...ours, artifacts: undefined }, { ...theirs, artifacts: undefined });
82
+ const [ab, ao, at] = [base?.artifacts ?? {}, ours?.artifacts ?? {}, theirs?.artifacts ?? {}];
83
+ /** @type {Record<string, any>} */
84
+ const artifacts = {};
85
+ for (const cat of new Set([...Object.keys(ao), ...Object.keys(at)])) {
86
+ const list = ARTIFACT_CATEGORIES.includes(cat) || Array.isArray(ao[cat] ?? at[cat]);
87
+ artifacts[cat] = list ? mergeList(ab[cat], ao[cat], at[cat]) : mergeFields(ab[cat], ao[cat] ?? {}, at[cat] ?? {});
88
+ }
89
+ return { ...out, artifacts };
90
+ }
@@ -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
+ }
@@ -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
 
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
  }