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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +4 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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;
|
package/src/lib/index-json.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/intake-model.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
159
|
+
appendLine(p, line);
|
|
161
160
|
return p;
|
|
162
161
|
}
|
|
163
162
|
|
package/src/lib/lock.js
ADDED
|
@@ -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
|
+
}
|
package/src/lib/metrics-model.js
CHANGED
|
@@ -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
|
}
|