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
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import { readdirSync } from "node:fs";
4
- import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
4
+ import { exists, isDir, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, walk, write } from "./fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "./templates.js";
6
6
 
7
7
  // Agent adapters: the thin per-agent files that point at AGENTS.md.
@@ -128,6 +128,88 @@ export function renderAdapterFile(file, tokens) {
128
128
 
129
129
  export { exists };
130
130
 
131
+ // Files an adapter USED to write, and the file that replaced each (change
132
+ // 0238). Cursor's documentation no longer describes `.cursor/commands/`;
133
+ // Cursor 2.4 converts slash commands to skills (`/migrate-to-skills`), with
134
+ // `disable-model-invocation: true` to keep them explicit — which is what
135
+ // the cursor adapter writes now, under `.cursor/skills/`.
136
+ export const RETIRED_ADAPTER_FILES = Object.freeze({
137
+ cursor: ["context", "next", "status", "why", "work"].map((x) => ({
138
+ retired: `.cursor/commands/doctrina-${x}.md`,
139
+ replacedBy: `.cursor/skills/doctrina-${x}/SKILL.md`,
140
+ })),
141
+ });
142
+
143
+ /**
144
+ * Moving an installed adapter off its retired files: the replacement is
145
+ * written where it is missing; a retired file still exactly as the CLI
146
+ * wrote it is removed; one edited since is kept and named, never deleted.
147
+ * @param {string} projectRoot
148
+ * @param {Record<string, string>} tokens as `adapter add` renders with
149
+ * @param {string} [root] where the files live: the shadow folder of a shadowed repository
150
+ */
151
+ // The core loop an adapter installs as slash commands or skills.
152
+ const CORE_LOOP_FILE = /(^|\/)doctrina-[a-z-]+(\.md|\/SKILL\.md)$/;
153
+
154
+ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
155
+ /** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[], added: Array<{ relativePath: string, content: string }> }} */
156
+ const plan = { writes: [], removes: [], kept: [], added: [] };
157
+ const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
158
+ for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
159
+ const spec = adapterFiles(projectRoot, name);
160
+ if (!spec) continue;
161
+ for (const { retired, replacedBy } of list) {
162
+ if (!isFile(path.join(root, retired))) continue;
163
+ const file = spec.files.find((f) => f.relativePath === replacedBy);
164
+ if (!file) continue;
165
+ const rendered = renderAdapterFile(file, tokens);
166
+ if (!isFile(path.join(root, replacedBy))) plan.writes.push({ relativePath: replacedBy, content: rendered });
167
+ const body = rendered.replace(/^---\n[\s\S]*?\n---\n+/, "");
168
+ (norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
169
+ }
170
+ }
171
+ // A family the adapter installed gains the member a newer CLI ships
172
+ // (change 0256). `/doctrina-onboard` reached only the projects that ran
173
+ // `adapter add` again: the upgrade compared nothing but retired files. An
174
+ // adapter is read as installed by its own core-loop files, so one the
175
+ // project never added is never written. Not in a shadow, where every
176
+ // file is also a link the workspace owns.
177
+ if (root === projectRoot) {
178
+ for (const name of listAdapterNames(projectRoot)) {
179
+ const family = (adapterFiles(projectRoot, name)?.files ?? [])
180
+ .filter((f) => CORE_LOOP_FILE.test(f.relativePath));
181
+ if (!family.some((f) => isFile(path.join(root, f.relativePath)))) continue;
182
+ for (const f of family) {
183
+ if (isFile(path.join(root, f.relativePath)) || plan.writes.some((w) => w.relativePath === f.relativePath)) continue;
184
+ plan.added.push({ relativePath: f.relativePath, content: renderAdapterFile(f, tokens) });
185
+ }
186
+ }
187
+ }
188
+ return plan;
189
+ }
190
+
191
+ /**
192
+ * Apply a migration plan under `root` — the project, or the shadow folder
193
+ * a shadowed repository's files live in. Returns the removed files with
194
+ * what they held, for the caller that re-links a shadow.
195
+ * @returns {Array<{ rel: string, content: string }>}
196
+ */
197
+ export function applyAdapterMigration(root, plan) {
198
+ for (const w of [...plan.writes, ...(plan.added ?? [])]) {
199
+ mkdirp(path.dirname(path.join(root, w.relativePath)));
200
+ write(path.join(root, w.relativePath), w.content, { force: true });
201
+ }
202
+ const gone = [];
203
+ for (const rel of plan.removes) {
204
+ const at = path.join(root, rel);
205
+ if (!isFile(at)) continue;
206
+ gone.push({ rel, content: read(at) });
207
+ remove(at);
208
+ pruneEmptyDirs(path.dirname(at), root);
209
+ }
210
+ return gone;
211
+ }
212
+
131
213
  // ---------------------------------------------------------------------------
132
214
  // Configuration files an adapter SHARES with the user (change 0211).
133
215
  //
@@ -0,0 +1,400 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { accessSync, constants, statSync } from "node:fs";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { doctrinaHome } from "./workspace-locate.js";
6
+
7
+ // THE AGENT THE CONSOLE TALKS TO (change 0257, ADR 0032).
8
+ //
9
+ // The CLI never calls a model (ADR 0005) and still does not. What a human
10
+ // types into the console as a request goes to the AI agent they already
11
+ // use, through that agent's own non-interactive mode: the same binary, the
12
+ // same account, the same rules and hooks the IDE session reads. Doctrina
13
+ // starts it with the prompt, relays what it prints, and remembers the
14
+ // session id so the next prompt continues the conversation.
15
+ //
16
+ // It is the same agent, not the same window: there is no public door into a
17
+ // chat pane that is already open in an IDE, so the console's conversation
18
+ // is its own session of that agent, in the same repository.
19
+ //
20
+ // Each agent is described by what its documentation states: the binary, how
21
+ // a prompt is passed, the output it streams, how a session resumes. Nothing
22
+ // here widens what the agent may do. No flag that skips its approvals is
23
+ // ever passed; the person adds flags of their own in DOCTRINA_AGENT_ARGS.
24
+
25
+ /**
26
+ * @typedef {{ type: "session", id: string }
27
+ * | { type: "text", text: string }
28
+ * | { type: "tool", name: string, detail: string }
29
+ * | { type: "result", ok: boolean, turns: number | null, ms: number | null, denials: string[], error: string }
30
+ * | { type: "model", name: string }
31
+ * | { type: "raw", text: string }} AgentEvent
32
+ */
33
+
34
+ /**
35
+ * @typedef {{
36
+ * id: string, label: string, bins: string[], exercised: boolean,
37
+ * stdin: boolean,
38
+ * about: string,
39
+ * model: string[] | null,
40
+ * models: string[],
41
+ * listing: string,
42
+ * custom?: boolean,
43
+ * args: (prompt: string, opts: { session: string | null, extra: string[] }) => string[],
44
+ * parse: (line: string) => AgentEvent[],
45
+ * }} AgentSpec
46
+ *
47
+ * `about` says which product the binary belongs to; `model` is how a model
48
+ * is named on its command line (`{model}` is replaced), or null when it
49
+ * takes none; `models` are names it is known to accept — examples for the
50
+ * list `/model` completes from, never a limit on what may be typed;
51
+ * `listing` is how the agent itself shows the models an account has.
52
+ */
53
+
54
+ const clip = (/** @type {unknown} */ v, n = 160) => {
55
+ const s = String(v ?? "").replace(/\s+/g, " ").trim();
56
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
57
+ };
58
+
59
+ /** One line that says what a tool call does, from its input. */
60
+ function toolDetail(input) {
61
+ if (input === null || typeof input !== "object") return clip(input);
62
+ const o = /** @type {Record<string, unknown>} */ (input);
63
+ for (const key of ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"]) {
64
+ if (typeof o[key] === "string" && o[key] !== "") return clip(o[key]);
65
+ }
66
+ const first = Object.values(o).find((v) => typeof v === "string" && v !== "");
67
+ return first ? clip(first) : "";
68
+ }
69
+
70
+ function json(line) {
71
+ const text = line.trim();
72
+ if (!text.startsWith("{")) return null;
73
+ try {
74
+ const parsed = JSON.parse(text);
75
+ return parsed && typeof parsed === "object" ? parsed : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ const rawOf = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("raw"), text: line.replace(/\s+$/, "") }]);
82
+
83
+ /** Claude Code's and Cursor's `stream-json`: one event per line. */
84
+ function parseStreamJson(line) {
85
+ const e = json(line);
86
+ if (!e) return rawOf(line);
87
+ /** @type {AgentEvent[]} */
88
+ const out = [];
89
+ if (e.type === "system" && e.subtype === "init" && typeof e.session_id === "string") {
90
+ out.push({ type: "session", id: e.session_id });
91
+ // The model the agent says it is running: shown, never assumed.
92
+ if (typeof e.model === "string" && e.model !== "") out.push({ type: "model", name: e.model });
93
+ } else if (e.type === "assistant") {
94
+ for (const part of Array.isArray(e.message?.content) ? e.message.content : []) {
95
+ if (part?.type === "text" && typeof part.text === "string" && part.text.trim() !== "") out.push({ type: "text", text: part.text });
96
+ else if (part?.type === "tool_use") out.push({ type: "tool", name: String(part.name ?? "tool"), detail: toolDetail(part.input) });
97
+ }
98
+ } else if (e.type === "tool_call" && e.subtype === "started") {
99
+ // Cursor names the tool by the key it nests the call under.
100
+ const call = e.tool_call && typeof e.tool_call === "object" ? e.tool_call : {};
101
+ const key = Object.keys(call)[0] ?? "tool";
102
+ const body = call[key] ?? {};
103
+ const name = key === "function" ? String(body.name ?? "tool") : key.replace(/ToolCall$/, "").replace(/^./, (ch) => ch.toUpperCase());
104
+ out.push({ type: "tool", name, detail: toolDetail(key === "function" ? body.arguments : body.args) });
105
+ } else if (e.type === "result") {
106
+ if (typeof e.session_id === "string") out.push({ type: "session", id: e.session_id });
107
+ const denials = (Array.isArray(e.permission_denials) ? e.permission_denials : [])
108
+ .map((/** @type {any} */ d) => `${d?.tool_name ?? "tool"}(${toolDetail(d?.tool_input)})`);
109
+ const ok = e.is_error !== true && (e.subtype === undefined || e.subtype === "success");
110
+ out.push({
111
+ type: "result", ok,
112
+ turns: typeof e.num_turns === "number" ? e.num_turns : null,
113
+ ms: typeof e.duration_ms === "number" ? e.duration_ms : null,
114
+ denials: [...new Set(denials)],
115
+ error: ok ? "" : clip(e.result ?? e.subtype ?? "the agent reported an error", 300),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** `codex exec --json`: JSON Lines of thread, turn and item events. */
122
+ function parseCodex(line) {
123
+ const e = json(line);
124
+ if (!e) return rawOf(line);
125
+ /** @type {AgentEvent[]} */
126
+ const out = [];
127
+ if (e.type === "thread.started" && typeof e.thread_id === "string") out.push({ type: "session", id: e.thread_id });
128
+ else if (e.type === "item.completed" && e.item && typeof e.item === "object") {
129
+ const kind = String(e.item.type ?? "");
130
+ if (/agent_?message/i.test(kind) && typeof e.item.text === "string") out.push({ type: "text", text: e.item.text });
131
+ else if (/command/i.test(kind)) out.push({ type: "tool", name: "Shell", detail: clip(e.item.command) });
132
+ else if (/file/i.test(kind)) out.push({ type: "tool", name: "Edit", detail: toolDetail(e.item) });
133
+ } else if (e.type === "turn.completed") out.push({ type: "result", ok: true, turns: null, ms: null, denials: [], error: "" });
134
+ else if (e.type === "turn.failed" || e.type === "error") {
135
+ out.push({ type: "result", ok: false, turns: null, ms: null, denials: [], error: clip(e.error?.message ?? e.message ?? "the agent reported an error", 300) });
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** Plain text: every line is something the agent said. */
141
+ const parseText = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("text"), text: line.replace(/\s+$/, "") }]);
142
+
143
+ /** @type {AgentSpec[]} */
144
+ export const AGENTS = [
145
+ { id: "claude", label: "Claude Code", bins: ["claude"], exercised: true, stdin: true,
146
+ about: "also what the Claude extensions for VS Code and JetBrains run",
147
+ model: ["--model", "{model}"], models: ["opus", "sonnet", "haiku"], listing: "`/model` inside `claude`",
148
+ // The prompt goes in on stdin: no length limit, and nothing a person
149
+ // typed is ever an argument. Edits are accepted and `doctrina` may run —
150
+ // the cycle needs both; every other command still asks, which in print
151
+ // mode means it is refused and reported.
152
+ args: (_prompt, { session, extra }) => ["-p", "--output-format", "stream-json", "--verbose",
153
+ "--permission-mode", "acceptEdits", "--allowedTools", "Bash(doctrina:*)",
154
+ ...(session ? ["--resume", session] : []), ...extra],
155
+ parse: parseStreamJson },
156
+ { id: "cursor", label: "Cursor", bins: ["cursor-agent", "agent"], exercised: false, stdin: false,
157
+ about: "the agent of the Cursor IDE",
158
+ model: ["--model", "{model}"], models: [], listing: "`agent models`",
159
+ args: (prompt, { session, extra }) => ["-p", "--output-format", "stream-json",
160
+ ...(session ? ["--resume", session] : []), ...extra, prompt],
161
+ parse: parseStreamJson },
162
+ { id: "copilot", label: "GitHub Copilot", bins: ["copilot"], exercised: false, stdin: false,
163
+ about: "the agent of GitHub Copilot, as in VS Code",
164
+ model: ["--model={model}"], models: [], listing: "`/model` inside `copilot`",
165
+ // Its programmatic mode documents no session to resume: each request
166
+ // starts a conversation. It may write files and run `doctrina`.
167
+ args: (prompt, { extra }) => ["-p", prompt, "-s", "--allow-tool=write, shell(doctrina:*)", ...extra],
168
+ parse: parseText },
169
+ { id: "codex", label: "Codex", bins: ["codex"], exercised: false, stdin: false,
170
+ about: "also what the Codex extension for VS Code runs",
171
+ model: ["--model", "{model}"], models: [], listing: "`codex debug models`",
172
+ args: (prompt, { session, extra }) => ["exec", ...(session ? ["resume", session] : []),
173
+ "--json", "--sandbox", "workspace-write", ...extra, prompt],
174
+ parse: parseCodex },
175
+ { id: "gemini", label: "Gemini CLI", bins: ["gemini"], exercised: false, stdin: true,
176
+ about: "also behind Gemini Code Assist in VS Code",
177
+ model: ["--model", "{model}"], models: ["auto", "pro", "flash", "flash-lite"], listing: "`/model` inside `gemini`",
178
+ // Plain text out carries no session id, so each request starts a conversation.
179
+ args: (_prompt, { extra }) => ["--approval-mode", "auto_edit", ...extra],
180
+ parse: parseText },
181
+ { id: "opencode", label: "opencode", bins: ["opencode"], exercised: false, stdin: false,
182
+ about: "",
183
+ // Only `opencode run "<prompt>"` is on the page Doctrina was built from:
184
+ // a model goes in DOCTRINA_AGENT_ARGS, as that CLI names it.
185
+ model: null, models: [], listing: "",
186
+ args: (prompt, { extra }) => ["run", ...extra, prompt],
187
+ parse: parseText },
188
+ ];
189
+
190
+ // AN AGENT OF THE PERSON'S OWN (change 0258).
191
+ //
192
+ // The catalog above is the agents Doctrina knows how to start. A person who
193
+ // uses another — or a wrapper of their own — declares it once, in
194
+ // `$DOCTRINA_HOME/agents.json`, as the command line they would type:
195
+ //
196
+ // { "agents": [ { "id": "mine", "label": "My agent", "command": "mycli",
197
+ // "args": ["run", "{prompt}"], "model": ["--model", "{model}"],
198
+ // "resume": ["--session", "{session}"], "models": ["small", "large"] } ] }
199
+ //
200
+ // `{prompt}` is where the request goes; with no `{prompt}` it goes in on
201
+ // stdin. Its output is read as plain text, or as `stream-json` when
202
+ // `"output"` says so. It is the person's file, never the project's.
203
+ export const CUSTOM_AGENTS_FILE = "agents.json";
204
+
205
+ const isStrings = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === "string");
206
+
207
+ /**
208
+ * The agents `$DOCTRINA_HOME/agents.json` declares, and what is wrong with it.
209
+ * @param {NodeJS.ProcessEnv} [env]
210
+ * @returns {{ specs: AgentSpec[], errors: string[] }}
211
+ */
212
+ export function customAgents(env = process.env) {
213
+ const file = path.join(doctrinaHome(env), CUSTOM_AGENTS_FILE);
214
+ /** @type {AgentSpec[]} */
215
+ const specs = [];
216
+ /** @type {string[]} */
217
+ const errors = [];
218
+ if (!isFile(file)) return { specs, errors };
219
+ let data;
220
+ try {
221
+ data = JSON.parse(read(file));
222
+ } catch (err) {
223
+ return { specs, errors: [`${file} is not valid JSON: ${/** @type {Error} */ (err).message}`] };
224
+ }
225
+ const list = Array.isArray(data?.agents) ? data.agents : null;
226
+ if (!list) return { specs, errors: [`${file}: expected { "agents": [ … ] }`] };
227
+ list.forEach((/** @type {any} */ a, /** @type {number} */ i) => {
228
+ const where = `${file}: agents[${i}]`;
229
+ if (!a || typeof a !== "object") return errors.push(`${where} is not an object`);
230
+ if (typeof a.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(a.id)) return errors.push(`${where}.id must be lowercase letters, digits and hyphens`);
231
+ if (AGENTS.some((b) => b.id === a.id) || specs.some((b) => b.id === a.id)) return errors.push(`${where}.id "${a.id}" is already an agent`);
232
+ if (typeof a.command !== "string" || a.command.trim() === "") return errors.push(`${where}.command names the binary to run`);
233
+ for (const key of ["args", "model", "resume", "models"]) {
234
+ if (a[key] !== undefined && !isStrings(a[key])) return errors.push(`${where}.${key} must be an array of strings`);
235
+ }
236
+ /** @type {string[]} */
237
+ const args = a.args ?? [];
238
+ /** @type {string[]} */
239
+ const resume = a.resume ?? [];
240
+ const inArgs = args.some((x) => x.includes("{prompt}"));
241
+ return specs.push({
242
+ id: a.id, label: typeof a.label === "string" && a.label.trim() !== "" ? a.label : a.id,
243
+ bins: [a.command], exercised: false, stdin: !inArgs, custom: true,
244
+ about: "your own, from agents.json",
245
+ model: a.model ?? null, models: a.models ?? [], listing: "",
246
+ args: (prompt, { session, extra }) => [
247
+ ...args.map((x) => x.replaceAll("{prompt}", prompt)),
248
+ ...(session ? resume.map((x) => x.replaceAll("{session}", session)) : []),
249
+ ...extra,
250
+ ],
251
+ parse: a.output === "stream-json" ? parseStreamJson : parseText,
252
+ });
253
+ });
254
+ return { specs, errors };
255
+ }
256
+
257
+ /** Every agent the console can start: the catalog, then the person's own. */
258
+ export const agentSpecs = (/** @type {NodeJS.ProcessEnv} */ env = process.env) => [...AGENTS, ...customAgents(env).specs];
259
+
260
+ export const agentById = (/** @type {string} */ id, /** @type {NodeJS.ProcessEnv} */ env = process.env) => agentSpecs(env).find((a) => a.id === id) ?? null;
261
+
262
+ /** The file a command name resolves to on PATH, or null. */
263
+ export function findOnPath(bin, env = process.env, platform = process.platform) {
264
+ const exts = platform === "win32" ? ["", ...String(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
265
+ for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter)) {
266
+ if (dir === "") continue;
267
+ for (const ext of exts) {
268
+ const file = path.join(dir, bin + ext);
269
+ try {
270
+ if (!statSync(file).isFile()) continue;
271
+ if (platform !== "win32") accessSync(file, constants.X_OK);
272
+ return file;
273
+ } catch { /* not here */ }
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /**
280
+ * Every agent the console knows, installed or not, the one it would use
281
+ * first on top. The order is the person's: `DOCTRINA_AGENT`, then the IDE
282
+ * whose terminal this is, then the adapters the project installed, then the
283
+ * catalog's; what is not installed comes last.
284
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
285
+ * @returns {{ id: string, label: string, file: string | null, bins: string[], exercised: boolean, about: string, models: string[], listing: string, takesModel: boolean, custom: boolean }[]}
286
+ */
287
+ export function agentCatalog({ env = process.env, cwd = process.cwd(), platform = process.platform } = {}) {
288
+ const pinned = String(env.DOCTRINA_AGENT ?? "").trim().toLowerCase();
289
+ const inCursor = Boolean(env.CURSOR_TRACE_ID) || /cursor/i.test(String(env.VSCODE_GIT_ASKPASS_MAIN ?? ""));
290
+ const hasCursor = isDir(path.join(cwd, ".cursor"));
291
+ const all = agentSpecs(env).map((spec) => {
292
+ // `agent` is too common a name to trust on its own: it counts as Cursor's
293
+ // only where Cursor is in use.
294
+ const bins = spec.id === "cursor" && !(inCursor || hasCursor) ? spec.bins.filter((b) => b !== "agent") : spec.bins;
295
+ const file = bins.map((b) => (/[\\/]/.test(b) ? (isFile(b) ? b : null) : findOnPath(b, env, platform))).find(Boolean) ?? null;
296
+ return { id: spec.id, label: spec.label, file, bins: spec.bins, exercised: spec.exercised, about: spec.about, models: spec.models, listing: spec.listing, takesModel: spec.model !== null, custom: spec.custom === true };
297
+ });
298
+ const rank = (/** @type {{ id: string, file: string | null }} */ a) => {
299
+ if (!a.file) return 9;
300
+ if (a.id === pinned) return 0;
301
+ if (a.id === "cursor" && inCursor) return 1;
302
+ if (a.id === "claude" && env.CLAUDECODE) return 1;
303
+ if (a.id === "cursor" && hasCursor) return 2;
304
+ if (a.id === "claude" && (isDir(path.join(cwd, ".claude")) || isFile(path.join(cwd, "CLAUDE.md")))) return 2;
305
+ if (a.id === "copilot" && isFile(path.join(cwd, ".github", "copilot-instructions.md"))) return 2;
306
+ if (a.id === "gemini" && isFile(path.join(cwd, "GEMINI.md"))) return 2;
307
+ return 3;
308
+ };
309
+ return all.map((a, i) => ({ a, i })).sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i).map((x) => x.a);
310
+ }
311
+
312
+ /**
313
+ * The agents installed on this machine, the one the console would use first.
314
+ * `DOCTRINA_AGENT=none` finds none.
315
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
316
+ * @returns {{ id: string, label: string, file: string, exercised: boolean }[]}
317
+ */
318
+ export function detectAgents(opts = {}) {
319
+ const pinned = String((opts.env ?? process.env).DOCTRINA_AGENT ?? "").trim().toLowerCase();
320
+ if (pinned === "none" || pinned === "off") return [];
321
+ return agentCatalog(opts).flatMap((a) => (a.file ? [{ id: a.id, label: a.label, file: a.file, exercised: a.exercised }] : []));
322
+ }
323
+
324
+ /** Split DOCTRINA_AGENT_ARGS the way a shell would, quotes included. */
325
+ export function splitArgs(text) {
326
+ const out = [];
327
+ let cur = "";
328
+ let quote = "";
329
+ let open = false;
330
+ for (const ch of String(text ?? "")) {
331
+ if (quote) {
332
+ if (ch === quote) quote = "";
333
+ else cur += ch;
334
+ } else if (ch === '"' || ch === "'") {
335
+ quote = ch;
336
+ open = true;
337
+ } else if (/\s/.test(ch)) {
338
+ if (cur !== "" || open) out.push(cur);
339
+ cur = "";
340
+ open = false;
341
+ } else cur += ch;
342
+ }
343
+ if (cur !== "" || open) out.push(cur);
344
+ return out;
345
+ }
346
+
347
+ /**
348
+ * How to start an agent with a prompt: the file, its arguments, and what to
349
+ * write to its stdin. `refused` says why it cannot be started safely.
350
+ * @param {{ id: string, file: string }} agent
351
+ * @param {string} prompt
352
+ * @param {{ session?: string | null, model?: string | null, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [opts]
353
+ */
354
+ export function agentInvocation(agent, prompt, { session = null, model = null, env = process.env, platform = process.platform } = {}) {
355
+ const spec = agentById(agent.id, env);
356
+ if (!spec) return { refused: `unknown agent "${agent.id}"`, file: "", args: /** @type {string[]} */ ([]), stdin: null, shell: false };
357
+ // The model the person chose, the way this agent names one; then their own flags.
358
+ const chosen = model && spec.model ? spec.model.map((x) => x.replaceAll("{model}", model)) : [];
359
+ const extra = [...chosen, ...splitArgs(env.DOCTRINA_AGENT_ARGS)];
360
+ const args = spec.args(prompt, { session, extra });
361
+ // A .cmd launcher only runs through cmd.exe, which would read a prompt as
362
+ // commands. With the prompt on stdin nothing typed reaches it.
363
+ const shim = platform === "win32" && /\.(cmd|bat)$/i.test(agent.file);
364
+ if (shim && !spec.stdin) {
365
+ return { refused: `${spec.label}'s launcher is a ${path.extname(agent.file)} script, and a prompt is not passed through cmd.exe — put its .exe first on PATH`, file: agent.file, args, stdin: null, shell: false };
366
+ }
367
+ return { refused: "", file: agent.file, args, stdin: spec.stdin ? prompt : null, shell: shim };
368
+ }
369
+
370
+ /**
371
+ * The prompt an agent receives: what was typed, where each mentioned
372
+ * artifact lives — so `@spec:auth` is a file the agent opens and not a
373
+ * guess — and what it needs to know about the other consoles: that it may
374
+ * hand one a task, or that this request came from one and its last message
375
+ * is the report.
376
+ * @param {string} text
377
+ * @param {{ kind: string, id: string, path: string }[]} mentions
378
+ * @param {{ peers?: { name: string, busy: boolean, listening: boolean }[], origin?: string | null }} [room]
379
+ */
380
+ export function agentPrompt(text, mentions, { peers = [], origin = null } = {}) {
381
+ const named = [];
382
+ for (const m of String(text).matchAll(/(?<![\w@])@([a-z]+):([A-Za-z0-9._-]+)/g)) {
383
+ const hit = mentions.find((x) => x.kind === m[1] && x.id === m[2]);
384
+ if (hit && !named.includes(hit)) named.push(hit);
385
+ }
386
+ let out = text;
387
+ if (named.length > 0) {
388
+ out += `\n\nDoctrina artifacts mentioned above:\n${named.map((m) => `- @${m.kind}:${m.id} — ${m.path}${m.kind === "skill" ? " (a project skill: follow its procedure)" : ""}`).join("\n")}`;
389
+ }
390
+ if (origin) {
391
+ out += `\n\nThis request was sent by the Doctrina console "${origin}", open in another terminal. Do the work here, in this repository; do not hand it to another console. Your last message is sent back to "${origin}" as the report: make it say what you did and what is left.`;
392
+ } else {
393
+ const open = peers.filter((p) => p.listening);
394
+ if (open.length > 0) {
395
+ out += `\n\nOther Doctrina consoles are open in other terminals: ${open.map((p) => `${p.name} (${p.busy ? "busy" : "idle"})`).join(", ")}. `
396
+ + "To hand one a task and wait for its report, run: doctrina ui --send <name> \"<the task, self-contained>\". Its agent works in its own repository; only do that when the task belongs there.";
397
+ }
398
+ }
399
+ return out;
400
+ }
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { eolOf } from "./fs-ops.js";
5
+ import { extractOps } from "./spec-ops.js";
6
+
7
+ // THE RECORD OF WHICH DELTAS A CHANGE'S `applied` STAMP COVERS (change 0247).
8
+ //
9
+ // One line at the end of proposal.md, in a comment so no reader takes it for
10
+ // content:
11
+ //
12
+ // <!-- doctrina:applied-deltas specs/auth/delta.md=3f1c9a7e,91bd02c4 -->
13
+ //
14
+ // One fingerprint per op of a delta's `ops` block, because the usual repair
15
+ // after a close that stopped is `doctrina change delta`, which adds ops to
16
+ // the delta that is already there. A delta without ops (ADDED, REMOVED) has
17
+ // one fingerprint, of its whole text. `none` when the change was applied
18
+ // with no delta at all — which is exactly the change one gets added to.
19
+
20
+ const RECORD = /<!-- doctrina:applied-deltas ([^>]*?) -->/;
21
+ const short = (/** @type {string} */ text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
22
+
23
+ /** A delta's path inside its change, with forward slashes. */
24
+ export function deltaKey(changeDir, deltaPath) {
25
+ return path.relative(changeDir, deltaPath).split(path.sep).join("/");
26
+ }
27
+
28
+ /**
29
+ * What a delta holds: one fingerprint per op, or one of its whole text when
30
+ * it carries no ops block. Line endings do not count.
31
+ * @returns {string[]}
32
+ */
33
+ export function deltaFingerprint(text) {
34
+ const ops = extractOps(text);
35
+ if (ops.length > 0) return ops.map((op) => short(JSON.stringify(op)));
36
+ return [`text:${short(String(text).replace(/\r\n/g, "\n"))}`];
37
+ }
38
+
39
+ /**
40
+ * The deltas the stamp covers, or null when the proposal carries no record
41
+ * (a change applied by a CLI that did not write one).
42
+ * @returns {Map<string, string[]> | null}
43
+ */
44
+ export function appliedDeltas(proposal) {
45
+ const m = String(proposal).match(RECORD);
46
+ if (!m) return null;
47
+ const out = new Map();
48
+ for (const pair of m[1].trim().split(/\s+/)) {
49
+ const at = pair.lastIndexOf("=");
50
+ if (at > 0) out.set(pair.slice(0, at), pair.slice(at + 1).split(",").filter(Boolean));
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Which of a delta's ops the stamp does not cover yet. `gone` is true when an
57
+ * op that WAS applied is no longer in the block as it was — changed or
58
+ * removed — which nothing can apply on top of.
59
+ * @param {string[]} recorded
60
+ * @param {string[]} current
61
+ * @returns {{ fresh: number[], gone: boolean }}
62
+ */
63
+ export function pendingOps(recorded, current) {
64
+ const left = new Map();
65
+ for (const h of recorded) left.set(h, (left.get(h) ?? 0) + 1);
66
+ const fresh = [];
67
+ current.forEach((h, i) => {
68
+ const n = left.get(h) ?? 0;
69
+ if (n > 0) left.set(h, n - 1);
70
+ else fresh.push(i);
71
+ });
72
+ return { fresh, gone: [...left.values()].some((n) => n > 0) };
73
+ }
74
+
75
+ /** The proposal with its record replaced, or appended as the last line. */
76
+ export function recordAppliedDeltas(proposal, entries) {
77
+ const line = `<!-- doctrina:applied-deltas ${entries.length ? entries.map(([k, h]) => `${k}=${h.join(",")}`).join(" ") : "none"} -->`;
78
+ const text = String(proposal);
79
+ if (RECORD.test(text)) return text.replace(RECORD, line);
80
+ const eol = eolOf(text);
81
+ return text.replace(/\s*$/, "") + eol + eol + line + eol;
82
+ }
package/src/lib/args.js CHANGED
@@ -8,12 +8,25 @@
8
8
  // -x=value or -x value -> string value
9
9
  // -- -> stop flag parsing; rest is positional
10
10
  //
11
- // Anything else becomes positional. Repeated flags overwrite the previous value.
11
+ // Anything else becomes positional. Repeated flags overwrite the previous value
12
+ // in `flags`; every value is still kept, in order, for `flagAll` — a command
13
+ // that takes a flag more than once (`change delta --op a --op b`) reads them
14
+ // all there instead of losing every one but the last (change 0228).
15
+
16
+ /** @type {WeakMap<Map<string, string|boolean>, Map<string, string[]>>} */
17
+ const EVERY_VALUE = new WeakMap();
12
18
 
13
19
  export function parseArgs(argv, opts = {}) {
14
20
  const booleanFlags = new Set(opts.boolean ?? []);
15
21
  const positional = [];
22
+ /** @type {Map<string, string|boolean>} */
16
23
  const flags = new Map();
24
+ /** @type {Map<string, string[]>} */
25
+ const every = new Map();
26
+ EVERY_VALUE.set(flags, every);
27
+ const keep = (/** @type {string} */ name, /** @type {string|boolean} */ value) => {
28
+ if (typeof value === "string") every.set(name, [...(every.get(name) ?? []), value]);
29
+ };
17
30
  let i = 0;
18
31
  let stopped = false;
19
32
 
@@ -37,12 +50,14 @@ export function parseArgs(argv, opts = {}) {
37
50
  const eq = body.indexOf("=");
38
51
  if (eq >= 0) {
39
52
  flags.set(body.slice(0, eq), body.slice(eq + 1));
53
+ keep(body.slice(0, eq), body.slice(eq + 1));
40
54
  i += 1;
41
55
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
42
56
  flags.set(body, true);
43
57
  i += 1;
44
58
  } else {
45
59
  flags.set(body, argv[i + 1] ?? true);
60
+ keep(body, argv[i + 1] ?? true);
46
61
  i += 2;
47
62
  }
48
63
  continue;
@@ -53,12 +68,14 @@ export function parseArgs(argv, opts = {}) {
53
68
  const eq = body.indexOf("=");
54
69
  if (eq >= 0) {
55
70
  flags.set(body.slice(0, eq), body.slice(eq + 1));
71
+ keep(body.slice(0, eq), body.slice(eq + 1));
56
72
  i += 1;
57
73
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
58
74
  flags.set(body, true);
59
75
  i += 1;
60
76
  } else {
61
77
  flags.set(body, argv[i + 1] ?? true);
78
+ keep(body, argv[i + 1] ?? true);
62
79
  i += 2;
63
80
  }
64
81
  continue;
@@ -91,7 +108,22 @@ export function parsePositiveInt(raw) {
91
108
  return Number.isSafeInteger(n) && n > 0 ? n : null;
92
109
  }
93
110
 
94
- export function flagString(flags, name, fallback = undefined) {
111
+ /**
112
+ * Every value a string flag was given, in the order given — `[]` when absent.
113
+ * A Map built by hand (tests, callers that never parsed argv) answers with
114
+ * its one value.
115
+ * @param {Map<string, string|boolean>} flags
116
+ * @param {string} name
117
+ * @returns {string[]}
118
+ */
119
+ export function flagAll(flags, name) {
120
+ const every = EVERY_VALUE.get(flags)?.get(name);
121
+ if (every) return every;
122
+ const one = flags.get(name);
123
+ return typeof one === "string" ? [one] : [];
124
+ }
125
+
126
+ export function flagString(flags, name, fallback = /** @type {string | undefined} */ (undefined)) {
95
127
  const v = flags.get(name);
96
128
  if (v === undefined || v === true) return fallback;
97
129
  return String(v);