ultimate-brain 0.0.0-stage → 0.2.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 (83) hide show
  1. package/.agents/skills/README.md +3 -0
  2. package/.agents/skills/brain-apply/SKILL.md +80 -0
  3. package/.agents/skills/brain-connect-notion/SKILL.md +55 -0
  4. package/.agents/skills/brain-howto/SKILL.md +36 -0
  5. package/.agents/skills/brain-howto/references/guide.md +176 -0
  6. package/.agents/skills/brain-ingest/SKILL.md +105 -0
  7. package/.agents/skills/brain-ingest/references/cli-inputs.md +129 -0
  8. package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
  9. package/.agents/skills/brain-ingest/references/extraction.md +118 -0
  10. package/.agents/skills/brain-ingest/references/notion.md +65 -0
  11. package/.agents/skills/brain-ingest/references/sources.md +70 -0
  12. package/.agents/skills/brain-notion-change/SKILL.md +57 -0
  13. package/.agents/skills/brain-query/SKILL.md +62 -0
  14. package/.agents/skills/brain-sync/SKILL.md +66 -0
  15. package/.agents/skills/brain-sync/references/formats.md +53 -0
  16. package/LICENSE +21 -0
  17. package/README.md +40 -2
  18. package/bin/brain.js +5 -0
  19. package/dist/cli.js +83 -0
  20. package/dist/commands/apply.js +34 -0
  21. package/dist/commands/config.js +96 -0
  22. package/dist/commands/id.js +32 -0
  23. package/dist/commands/index.js +58 -0
  24. package/dist/commands/init.js +233 -0
  25. package/dist/commands/link.js +285 -0
  26. package/dist/commands/lint.js +47 -0
  27. package/dist/commands/log.js +48 -0
  28. package/dist/commands/note.js +36 -0
  29. package/dist/commands/proposal.js +57 -0
  30. package/dist/commands/query.js +38 -0
  31. package/dist/commands/status.js +98 -0
  32. package/dist/commands/sync.js +53 -0
  33. package/dist/commands/upgrade.js +143 -0
  34. package/dist/commands/views.js +111 -0
  35. package/dist/lib/apply.js +524 -0
  36. package/dist/lib/args.js +63 -0
  37. package/dist/lib/brain-path.js +91 -0
  38. package/dist/lib/brain.js +36 -0
  39. package/dist/lib/command.js +61 -0
  40. package/dist/lib/errors.js +33 -0
  41. package/dist/lib/frontmatter.js +171 -0
  42. package/dist/lib/fs-safe.js +140 -0
  43. package/dist/lib/ids.js +51 -0
  44. package/dist/lib/index-builder.js +216 -0
  45. package/dist/lib/input.js +45 -0
  46. package/dist/lib/lint.js +340 -0
  47. package/dist/lib/log.js +44 -0
  48. package/dist/lib/note-new.js +121 -0
  49. package/dist/lib/notes.js +95 -0
  50. package/dist/lib/paths.js +59 -0
  51. package/dist/lib/proposals.js +229 -0
  52. package/dist/lib/query.js +163 -0
  53. package/dist/lib/schemas.js +51 -0
  54. package/dist/lib/skills.js +60 -0
  55. package/dist/lib/sync.js +760 -0
  56. package/dist/lib/time.js +21 -0
  57. package/dist/lib/views.js +139 -0
  58. package/dist/lib/warnings.js +9 -0
  59. package/dist/lib/workspace.js +183 -0
  60. package/package.json +45 -4
  61. package/spec/schemas/brain.schema.json +200 -0
  62. package/spec/schemas/note.schema.json +1102 -0
  63. package/spec/schemas/proposal.schema.json +559 -0
  64. package/templates/README.md +44 -0
  65. package/templates/brain/AGENTS.md +55 -0
  66. package/templates/brain/CLAUDE.md +1 -0
  67. package/templates/brain/_proposals/.gitkeep +0 -0
  68. package/templates/brain/archive/goals/.gitkeep +0 -0
  69. package/templates/brain/archive/projects/.gitkeep +0 -0
  70. package/templates/brain/areas/.gitkeep +0 -0
  71. package/templates/brain/brain.json +35 -0
  72. package/templates/brain/gitignore +6 -0
  73. package/templates/brain/goals/.gitkeep +0 -0
  74. package/templates/brain/index.md +3 -0
  75. package/templates/brain/knowledge/concepts/.gitkeep +0 -0
  76. package/templates/brain/knowledge/decisions/.gitkeep +0 -0
  77. package/templates/brain/knowledge/meetings/.gitkeep +0 -0
  78. package/templates/brain/knowledge/orgs/.gitkeep +0 -0
  79. package/templates/brain/knowledge/people/.gitkeep +0 -0
  80. package/templates/brain/knowledge/questions/.gitkeep +0 -0
  81. package/templates/brain/memories/.gitkeep +0 -0
  82. package/templates/brain/projects/.gitkeep +0 -0
  83. package/templates/brain/sources/notion/.gitkeep +0 -0
@@ -0,0 +1,285 @@
1
+ import { lstat, readdir, readFile, realpath, stat, unlink } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { isProductRepository } from "../lib/brain-path.js";
4
+ import { BrainError } from "../lib/errors.js";
5
+ import { atomicWriteFile, ensureDirectory } from "../lib/fs-safe.js";
6
+ import { scanNotes } from "../lib/notes.js";
7
+ import { assertPlainPath, listShippedSkillFiles } from "../lib/skills.js";
8
+ const START = "<!-- ultimate-brain:start -->";
9
+ const END = "<!-- ultimate-brain:end -->";
10
+ const STACK = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ /** `brain link <repo-path>` (spec/cli.md §5.12). */
12
+ export const linkCommand = {
13
+ name: "link",
14
+ usage: "brain link <repo-path> [--project <id>] [--area <id>] [--stack <name>]… [--repo-name <name>] [--json]",
15
+ summary: "Link a consuming repository to this brain and install its consult instruction.",
16
+ options: { project: "string", area: "string", stack: "list", "repo-name": "string" },
17
+ maxPositionals: 1,
18
+ needsBrain: true,
19
+ async run({ args, brain }) {
20
+ const [requested] = args.positionals;
21
+ if (requested === undefined)
22
+ throw new BrainError("USAGE", "brain link needs a repository path");
23
+ const root = await openRepo(requested);
24
+ if (root === brain.root || await isProductRepository(root)) {
25
+ throw new BrainError("OUTSIDE_ROOT", "Refusing to write a brain or product repository as a consuming repository");
26
+ }
27
+ const { notes } = await scanNotes(brain.root);
28
+ const existingMarker = await readMarker(root);
29
+ const marker = buildMarker(existingMarker, root, args.options, notes);
30
+ const project = marker.project === undefined ? undefined : noteOf(notes, marker.project, "project");
31
+ const area = marker.area === undefined ? undefined : noteOf(notes, marker.area, "area");
32
+ const block = instructionBlock(project, area);
33
+ const agents = await readOptional(root, "AGENTS.md");
34
+ const claude = await readOptional(root, "CLAUDE.md");
35
+ const agentText = replaceOrAppend(agents, block, "AGENTS.md");
36
+ const claudeText = claude === null
37
+ ? "@AGENTS.md\n"
38
+ : hasAgentImport(claude) ? claude : replaceOrAppend(claude, block, "CLAUDE.md");
39
+ const planned = [
40
+ { path: ".brain", before: existingMarker.raw, after: `${JSON.stringify(marker, null, 2)}\n` },
41
+ { path: "AGENTS.md", before: agents, after: agentText },
42
+ { path: "CLAUDE.md", before: claude, after: claudeText },
43
+ ...await planSkill(root),
44
+ ];
45
+ await writePlan(root, planned);
46
+ const action = (file) => file.before === null ? "created" : file.after === null ? "removed" : file.before === file.after ? "unchanged" : "updated";
47
+ return {
48
+ data: {
49
+ repo_abs: root,
50
+ marker: { path: ".brain", written: planned[0].before !== planned[0].after, value: marker },
51
+ files: planned.slice(1).map((file) => ({ path: file.path, action: action(file) })),
52
+ },
53
+ human: `Linked ${root} to the brain.\n`,
54
+ };
55
+ },
56
+ };
57
+ async function openRepo(requested) {
58
+ const path = resolve(requested);
59
+ try {
60
+ if (!(await stat(path)).isDirectory())
61
+ throw new BrainError("USAGE", `Repository path is not a directory: ${path}`);
62
+ return await realpath(path);
63
+ }
64
+ catch (error) {
65
+ if (error instanceof BrainError)
66
+ throw error;
67
+ throw new BrainError("USAGE", `Repository path does not exist or cannot be read: ${path}`);
68
+ }
69
+ }
70
+ async function readMarker(root) {
71
+ const raw = await readOptional(root, ".brain");
72
+ if (raw === null)
73
+ return { raw, value: {} };
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ }
78
+ catch (error) {
79
+ throw new BrainError("INPUT_INVALID", `.brain is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ if (!isMarker(parsed))
82
+ throw new BrainError("INPUT_INVALID", ".brain must be an object with only repo, project, area and stacks");
83
+ return { raw, value: parsed };
84
+ }
85
+ function buildMarker(previous, root, options, notes) {
86
+ const marker = { ...previous.value };
87
+ marker.repo = options["repo-name"] ?? basename(root);
88
+ const requestedProject = options.project;
89
+ const requestedArea = options.area;
90
+ if (requestedProject !== undefined) {
91
+ const project = noteOf(notes, requestedProject, "project");
92
+ marker.project = project.id;
93
+ marker.area = requestedArea === undefined ? areaOf(project) : noteOf(notes, requestedArea, "area").id;
94
+ }
95
+ else if (requestedArea !== undefined) {
96
+ marker.area = noteOf(notes, requestedArea, "area").id;
97
+ }
98
+ if (options.stack !== undefined) {
99
+ const rawStacks = options.stack;
100
+ for (const stack of rawStacks) {
101
+ if (!STACK.test(stack))
102
+ throw new BrainError("USAGE", `--stack must be lowercase kebab-case: ${stack}`);
103
+ }
104
+ marker.stacks = [...new Set(rawStacks)].sort();
105
+ }
106
+ if (marker.project !== undefined)
107
+ noteOf(notes, marker.project, "project");
108
+ if (marker.area !== undefined)
109
+ noteOf(notes, marker.area, "area");
110
+ if (marker.stacks !== undefined)
111
+ marker.stacks = canonicalStacks(marker.stacks);
112
+ return orderedMarker(marker);
113
+ }
114
+ function areaOf(project) {
115
+ if (typeof project.attributes.area !== "string") {
116
+ throw new BrainError("UNKNOWN_ID", `Project ${project.id} has no area`);
117
+ }
118
+ return project.attributes.area;
119
+ }
120
+ function noteOf(notes, id, type) {
121
+ const note = notes.find((candidate) => candidate.id === id && candidate.type === type);
122
+ if (note === undefined)
123
+ throw new BrainError("UNKNOWN_ID", `No ${type} with id ${id}`);
124
+ return note;
125
+ }
126
+ function canonicalStacks(stacks) {
127
+ if (!stacks.every((stack) => typeof stack === "string" && STACK.test(stack))) {
128
+ throw new BrainError("INPUT_INVALID", ".brain stacks must be lowercase kebab-case strings");
129
+ }
130
+ return [...new Set(stacks)].sort();
131
+ }
132
+ function orderedMarker(marker) {
133
+ const ordered = {};
134
+ if (marker.repo !== undefined)
135
+ ordered.repo = marker.repo;
136
+ if (marker.project !== undefined)
137
+ ordered.project = marker.project;
138
+ if (marker.area !== undefined)
139
+ ordered.area = marker.area;
140
+ if (marker.stacks !== undefined)
141
+ ordered.stacks = marker.stacks;
142
+ return ordered;
143
+ }
144
+ function instructionBlock(project, area) {
145
+ const lines = [
146
+ START,
147
+ "## Ultimate Brain",
148
+ "",
149
+ "Managed by `brain link`; edits inside this block are overwritten.",
150
+ "",
151
+ `This repo is linked to the user's second brain${project === undefined && area === undefined ? "." : ":"}`,
152
+ ];
153
+ if (project !== undefined)
154
+ lines.push(`- Project: ${String(project.attributes.title)} (\`${project.id}\`)`);
155
+ if (area !== undefined)
156
+ lines.push(`- Area: ${String(area.attributes.title)} (\`${area.id}\`)`);
157
+ lines.push("", "The brain holds decisions, key facts, people and meeting history that this repo does not.", "", "Consult it before you:", "- make or revisit a decision about scope, architecture, vendors or the client;", "- answer why something was decided, who someone is, or a date, budget or owner;", "- rely on a fact about the client or project that this repo does not state.", "", "How: use the `brain-query` skill, or run `brain query --candidates \"<question>\" --json` and read the notes it returns. Cite the note paths. Say when a fact is `contested` or `superseded`.", "", "The brain is read-only from this repo. Never edit its files from here.", END);
158
+ return lines.join("\n");
159
+ }
160
+ function replaceOrAppend(existing, block, file) {
161
+ if (existing === null || existing === "")
162
+ return `${block}\n`;
163
+ const range = blockRange(existing);
164
+ if (range !== undefined)
165
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
166
+ if (existing.includes(START) || existing.includes(END)) {
167
+ throw new BrainError("INPUT_INVALID", `${file} has malformed Ultimate Brain markers`);
168
+ }
169
+ return `${existing}${existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n"}${block}\n`;
170
+ }
171
+ function blockRange(content) {
172
+ const start = markerPositions(content, START);
173
+ const end = markerPositions(content, END);
174
+ if (start.length === 0 && end.length === 0)
175
+ return undefined;
176
+ if (start.length !== 1 || end.length !== 1 || start[0] >= end[0]) {
177
+ throw new BrainError("INPUT_INVALID", "Ultimate Brain markers must appear exactly once and in order");
178
+ }
179
+ return { start: start[0], end: end[0] + END.length };
180
+ }
181
+ function markerPositions(content, marker) {
182
+ const expression = new RegExp(`(?:^|\\n)${escapeRegExp(marker)}(?=\\n|$)`, "g");
183
+ return [...content.matchAll(expression)].map((match) => match.index + (match[0].startsWith("\n") ? 1 : 0));
184
+ }
185
+ function hasAgentImport(content) {
186
+ return content.split(/\r?\n/).some((line) => line === "@AGENTS.md");
187
+ }
188
+ async function readOptional(root, path) {
189
+ try {
190
+ const target = `${root}/${path}`;
191
+ if ((await lstat(target)).isSymbolicLink())
192
+ throw new BrainError("OUTSIDE_ROOT", `Refusing to replace a symbolic link: ${target}`);
193
+ return await readFile(target, "utf8");
194
+ }
195
+ catch (error) {
196
+ if (error instanceof BrainError)
197
+ throw error;
198
+ if (isNodeError(error) && error.code === "ENOENT")
199
+ return null;
200
+ throw error;
201
+ }
202
+ }
203
+ /** Product-owned `.agents/skills/brain-query/` (decision 0029): replace changed files, remove stale ones. */
204
+ async function planSkill(root) {
205
+ const shipped = await listShippedSkillFiles("brain-query");
206
+ const planned = [];
207
+ const keep = new Set();
208
+ await assertPlainPath(root, ".agents/skills/brain-query", "folder");
209
+ for (const file of shipped) {
210
+ const path = `.agents/skills/${file.relative}`;
211
+ await assertPlainPath(root, path);
212
+ keep.add(path);
213
+ planned.push({ path, before: await readOptional(root, path), after: file.content.toString("utf8") });
214
+ }
215
+ const folder = ".agents/skills/brain-query";
216
+ for (const path of await listFiles(root, folder)) {
217
+ await assertPlainPath(root, path);
218
+ if (!keep.has(path))
219
+ planned.push({ path, before: await readOptional(root, path), after: null });
220
+ }
221
+ return planned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
222
+ }
223
+ async function listFiles(root, folder) {
224
+ let entries;
225
+ try {
226
+ entries = await readdir(join(root, folder), { withFileTypes: true });
227
+ }
228
+ catch (error) {
229
+ if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
230
+ return [];
231
+ throw error;
232
+ }
233
+ const paths = [];
234
+ for (const entry of entries) {
235
+ const path = `${folder}/${entry.name}`;
236
+ if (entry.isDirectory())
237
+ paths.push(...await listFiles(root, path));
238
+ else
239
+ paths.push(path);
240
+ }
241
+ return paths;
242
+ }
243
+ async function writePlan(root, files) {
244
+ const changed = files.filter((file) => file.before !== file.after);
245
+ const done = [];
246
+ try {
247
+ for (const file of changed) {
248
+ if (file.after === null)
249
+ await unlink(`${root}/${file.path}`);
250
+ else {
251
+ await ensureDirectory(root, file.path.split("/").slice(0, -1).join("/") || ".");
252
+ await atomicWriteFile(root, file.path, file.after);
253
+ }
254
+ done.push(file);
255
+ }
256
+ }
257
+ catch (error) {
258
+ for (const file of done.reverse()) {
259
+ if (file.before === null)
260
+ await unlink(`${root}/${file.path}`).catch(() => undefined);
261
+ else
262
+ await atomicWriteFile(root, file.path, file.before).catch(() => undefined);
263
+ }
264
+ throw error;
265
+ }
266
+ }
267
+ function isMarker(value) {
268
+ if (!isRecord(value))
269
+ return false;
270
+ if (!Object.keys(value).every((key) => ["repo", "project", "area", "stacks"].includes(key)))
271
+ return false;
272
+ return (value.repo === undefined || typeof value.repo === "string")
273
+ && (value.project === undefined || typeof value.project === "string")
274
+ && (value.area === undefined || typeof value.area === "string")
275
+ && (value.stacks === undefined || Array.isArray(value.stacks));
276
+ }
277
+ function isRecord(value) {
278
+ return typeof value === "object" && value !== null && !Array.isArray(value);
279
+ }
280
+ function escapeRegExp(value) {
281
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
282
+ }
283
+ function isNodeError(error) {
284
+ return error instanceof Error && "code" in error;
285
+ }
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { posix } from "node:path";
4
+ import { BrainError } from "../lib/errors.js";
5
+ import { lintBrain } from "../lib/lint.js";
6
+ import { scanNotes } from "../lib/notes.js";
7
+ /** `brain lint [<path>…] [--strict]` (spec/cli.md §5.10). */
8
+ export const lintCommand = {
9
+ name: "lint",
10
+ usage: "brain lint [<path>…] [--strict] [--json]",
11
+ summary: "Validate notes, links and brain.json against the schema. Exits 1 on errors (or warnings with --strict).",
12
+ options: { strict: "boolean" },
13
+ needsBrain: true,
14
+ async run({ args, brain }) {
15
+ const root = brain.root;
16
+ const { notes, problems } = await scanNotes(root);
17
+ let indexMarkdown;
18
+ try {
19
+ indexMarkdown = await readFile(join(root, "index.md"), "utf8");
20
+ }
21
+ catch {
22
+ indexMarkdown = null;
23
+ }
24
+ const all = lintBrain({ root, notes, problems, brainConfig: brain.config, indexMarkdown });
25
+ // Path filters: note files, brain-relative folders (prefix match), brain.json, index.md. brain.json is always checked.
26
+ const known = new Set([...notes.map((note) => note.path), ...problems.map((problem) => problem.path), "brain.json", "index.md"]);
27
+ const filters = args.positionals.map((path) => posix.normalize(path).replace(/^\.\//, "").replace(/\/$/, ""));
28
+ for (const filter of filters) {
29
+ if (!known.has(filter) && ![...known].some((path) => path.startsWith(`${filter}/`))) {
30
+ throw new BrainError("USAGE", `No note file or folder "${filter}" in the brain`);
31
+ }
32
+ }
33
+ const matches = (path) => path === "brain.json" || filters.some((filter) => path === filter || path.startsWith(`${filter}/`));
34
+ const issues = filters.length === 0 ? all : all.filter((issue) => matches(issue.path));
35
+ const errors = issues.filter((issue) => issue.severity === "error").length;
36
+ const warnings = issues.length - errors;
37
+ const failed = errors > 0 || (args.options.strict === true && warnings > 0);
38
+ const human = issues.length === 0
39
+ ? "No issues.\n"
40
+ : `${issues.map((issue) => `${issue.path}:${issue.line} ${issue.severity} ${issue.rule}: ${issue.message}`).join("\n")}\n${errors} errors, ${warnings} warnings\n`;
41
+ return {
42
+ data: { errors, warnings, files: notes.length + problems.length, issues },
43
+ exitCode: failed ? 1 : 0,
44
+ human,
45
+ };
46
+ },
47
+ };
@@ -0,0 +1,48 @@
1
+ import { BrainError } from "../lib/errors.js";
2
+ import { appendLog, formatLogLine } from "../lib/log.js";
3
+ import { scanNotes } from "../lib/notes.js";
4
+ const OP = /^[a-z][a-z0-9-]*$/;
5
+ const KEY = /^[a-z][a-z0-9_]*$/;
6
+ /** `brain log <op> [<id>] [--title <title>] [<key>=<value>…]` (spec/cli.md §5.11). */
7
+ export const logCommand = {
8
+ name: "log",
9
+ usage: "brain log <op> [<id>|-] [--title <title>] [<key>=<value>…] [--json]",
10
+ summary: "Append one operation line to log/<YYYY-MM>.md.",
11
+ options: { title: "string" },
12
+ needsBrain: true,
13
+ async run({ args, brain, now }) {
14
+ const [op, ...rest] = args.positionals;
15
+ if (op === undefined || !OP.test(op)) {
16
+ throw new BrainError("USAGE", "brain log needs an <op> matching [a-z][a-z0-9-]*");
17
+ }
18
+ let id = "-";
19
+ const pairs = [];
20
+ for (const [index, value] of rest.entries()) {
21
+ const equals = value.indexOf("=");
22
+ if (equals !== -1) {
23
+ const key = value.slice(0, equals);
24
+ if (!KEY.test(key))
25
+ throw new BrainError("USAGE", `Invalid key "${key}": keys match [a-z][a-z0-9_]*`);
26
+ pairs.push([key, value.slice(equals + 1)]);
27
+ }
28
+ else if (index === 0) {
29
+ id = value;
30
+ }
31
+ else {
32
+ throw new BrainError("USAGE", `Expected <key>=<value>, got "${value}"`);
33
+ }
34
+ }
35
+ let title = args.options.title;
36
+ if (id !== "-") {
37
+ const { notes } = await scanNotes(brain.root);
38
+ const note = notes.find((candidate) => candidate.id === id);
39
+ if (note === undefined)
40
+ throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
41
+ title ??= typeof note.attributes.title === "string" ? note.attributes.title : "";
42
+ }
43
+ const entry = { op, id, title: title ?? "", pairs };
44
+ const path = await appendLog(brain.root, now, [entry]);
45
+ const line = formatLogLine(now, entry);
46
+ return { data: { path, line }, human: `${line}\n` };
47
+ },
48
+ };
@@ -0,0 +1,36 @@
1
+ import { assertNoProblems, isRecord, readJsonInput, unknownKeys } from "../lib/input.js";
2
+ import { assertStagedValid, checkNoteInput, stageNotes } from "../lib/note-new.js";
3
+ import { Workspace } from "../lib/workspace.js";
4
+ import { lintWarnings } from "../lib/warnings.js";
5
+ /** `brain note new --input <file|-> [--dry-run]` (spec/cli.md §5.5). */
6
+ export const noteNewCommand = {
7
+ name: "note new",
8
+ usage: "brain note new --input <file|-> [--dry-run] [--json]",
9
+ summary: "Write new draft notes from JSON input. All or nothing; never changes an existing note.",
10
+ options: { input: "string", "dry-run": "boolean" },
11
+ maxPositionals: 0,
12
+ needsBrain: true,
13
+ async run({ args, brain, now }) {
14
+ const input = await readJsonInput(args.options.input, "note new");
15
+ if (!isRecord(input))
16
+ assertNoProblems([{ pointer: "", message: "must be an object with a notes list" }]);
17
+ const record = input;
18
+ const problems = unknownKeys(record, ["notes"], "");
19
+ if (!Array.isArray(record.notes))
20
+ problems.push({ pointer: "/notes", message: "must be a list" });
21
+ else
22
+ record.notes.forEach((item, index) => problems.push(...checkNoteInput(item, `/notes/${index}`)));
23
+ assertNoProblems(problems);
24
+ const dryRun = args.options["dry-run"] === true;
25
+ const workspace = await Workspace.open(brain);
26
+ const staged = stageNotes(workspace, record.notes, (index) => `/notes/${index}`, { now });
27
+ assertStagedValid(workspace, staged.pointers, "/notes");
28
+ const warnings = lintWarnings(workspace.warnings());
29
+ if (!dryRun) {
30
+ await workspace.commit(staged.created.map((note) => ({ op: "note-new", id: note.id, title: note.title })), now);
31
+ }
32
+ const human = staged.created.map((note) => `${dryRun ? "would create" : "created"} ${note.path}\n`).join("")
33
+ + staged.skipped.map((skip) => `skipped notes[${skip.input_index}]: same content as ${skip.existing}\n`).join("");
34
+ return { data: { created: staged.created, skipped: staged.skipped, dry_run: dryRun }, warnings, human: human || "Nothing to write.\n" };
35
+ },
36
+ };
@@ -0,0 +1,57 @@
1
+ import { BrainError } from "../lib/errors.js";
2
+ import { assertNoProblems, isRecord, readJsonInput, unknownKeys } from "../lib/input.js";
3
+ import { listProposals, PROPOSAL_STATUSES, stageProposals } from "../lib/proposals.js";
4
+ import { Workspace } from "../lib/workspace.js";
5
+ import { lintWarnings } from "../lib/warnings.js";
6
+ /** `brain proposal new --input <file|-> [--dry-run]` (spec/cli.md §5.6). */
7
+ export const proposalNewCommand = {
8
+ name: "proposal new",
9
+ usage: "brain proposal new --input <file|-> [--dry-run] [--json]",
10
+ summary: "Write pending proposals for changes to existing knowledge. All or nothing; skips duplicates of pending proposals.",
11
+ options: { input: "string", "dry-run": "boolean" },
12
+ maxPositionals: 0,
13
+ needsBrain: true,
14
+ async run({ args, brain, now }) {
15
+ const input = await readJsonInput(args.options.input, "proposal new");
16
+ if (!isRecord(input))
17
+ assertNoProblems([{ pointer: "", message: "must be an object with a proposals list" }]);
18
+ const record = input;
19
+ assertNoProblems(unknownKeys(record, ["proposals"], ""));
20
+ const dryRun = args.options["dry-run"] === true;
21
+ const workspace = await Workspace.open(brain);
22
+ const { created, skipped } = stageProposals(workspace, record.proposals, now);
23
+ const warnings = lintWarnings(workspace.warnings());
24
+ if (!dryRun) {
25
+ await workspace.commit(created.map((proposal) => ({
26
+ op: "proposal-new",
27
+ id: proposal.id,
28
+ title: proposal.title,
29
+ pairs: proposal.target === undefined ? [["kind", proposal.kind]] : [["kind", proposal.kind], ["target", proposal.target]],
30
+ })), now);
31
+ }
32
+ const human = created.map((proposal) => `${dryRun ? "would create" : "created"} ${proposal.path}\n`).join("")
33
+ + skipped.map((skip) => `skipped proposals[${skip.input_index}]: same as pending ${skip.existing}\n`).join("");
34
+ return { data: { created, skipped, dry_run: dryRun }, warnings, human: human || "Nothing to write.\n" };
35
+ },
36
+ };
37
+ /** `brain proposal list [--status <s>[,<s>…]] [--target <id>]` (spec/cli.md §5.6). */
38
+ export const proposalListCommand = {
39
+ name: "proposal list",
40
+ usage: "brain proposal list [--status <s>[,<s>…]] [--target <id>] [--json]",
41
+ summary: "List proposals, filtered by status and target, sorted by created then id.",
42
+ options: { status: "string", target: "string" },
43
+ maxPositionals: 0,
44
+ needsBrain: true,
45
+ async run({ args, brain }) {
46
+ const statuses = typeof args.options.status === "string" ? args.options.status.split(",").map((status) => status.trim()).filter((status) => status !== "") : [];
47
+ const unknown = statuses.filter((status) => !PROPOSAL_STATUSES.includes(status));
48
+ if (unknown.length > 0)
49
+ throw new BrainError("USAGE", `Unknown status ${unknown.join(", ")}; use ${PROPOSAL_STATUSES.join(", ")}`);
50
+ const workspace = await Workspace.open(brain);
51
+ const proposals = listProposals(workspace, statuses, args.options.target);
52
+ const human = proposals.length === 0
53
+ ? "No proposals.\n"
54
+ : proposals.map((proposal) => `${String(proposal.id)} ${String(proposal.status)} ${String(proposal.kind)} ${String(proposal.title)}\n`).join("");
55
+ return { data: { proposals }, human };
56
+ },
57
+ };
@@ -0,0 +1,38 @@
1
+ import { BrainError } from "../lib/errors.js";
2
+ import { scanNotes } from "../lib/notes.js";
3
+ import { queryContext, queryTypes, queryWords, rankCandidates } from "../lib/query.js";
4
+ /** `brain query --candidates <question>` (spec/cli.md §5.13). */
5
+ export const queryCommand = {
6
+ name: "query",
7
+ usage: "brain query --candidates <question> [--limit <n>] [--types <t>[,<t>…]] [--repo <path>] [--json]",
8
+ summary: "Return ranked note candidates for the brain-query skill.",
9
+ options: { candidates: "string", limit: "string", types: "string", repo: "string" },
10
+ maxPositionals: 0,
11
+ needsBrain: true,
12
+ async run({ args, brain }) {
13
+ const question = args.options.candidates;
14
+ if (question === undefined) {
15
+ throw new BrainError("USAGE", "brain query requires --candidates; use the brain-query skill to answer questions");
16
+ }
17
+ const limit = parseLimit(args.options.limit);
18
+ const tokens = queryWords(question);
19
+ const [context, scanned] = await Promise.all([
20
+ queryContext(args.options.repo),
21
+ scanNotes(brain.root),
22
+ ]);
23
+ const candidates = rankCandidates(scanned.notes, tokens, queryTypes(args.options.types), context).slice(0, limit);
24
+ return {
25
+ data: { query: question, tokens, context, candidates },
26
+ warnings: scanned.problems.map((problem) => ({ code: problem.code, message: `${problem.path}: ${problem.message}`, path: problem.path })),
27
+ human: `${candidates.length} candidate${candidates.length === 1 ? "" : "s"}.\n`,
28
+ };
29
+ },
30
+ };
31
+ function parseLimit(value) {
32
+ if (value === undefined)
33
+ return 10;
34
+ if (!/^[1-9]\d*$/.test(value) || Number(value) > 50) {
35
+ throw new BrainError("USAGE", "--limit must be an integer from 1 to 50");
36
+ }
37
+ return Number(value);
38
+ }
@@ -0,0 +1,98 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { BrainError } from "../lib/errors.js";
4
+ import { buildIndex } from "../lib/index-builder.js";
5
+ import { lintBrain } from "../lib/lint.js";
6
+ import { scanNotes } from "../lib/notes.js";
7
+ import { describeErrors, loadSchemas } from "../lib/schemas.js";
8
+ const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory"];
9
+ const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
10
+ /** `brain status` (spec/cli.md §5.2). */
11
+ export const statusCommand = {
12
+ name: "status",
13
+ usage: "brain status [--json]",
14
+ summary: "Show a read-only health summary for this brain.",
15
+ options: {},
16
+ maxPositionals: 0,
17
+ needsBrain: true,
18
+ async run({ brain }) {
19
+ const schemas = loadSchemas();
20
+ if (!schemas.brain(brain.config)) {
21
+ throw new BrainError("BRAIN_CONFIG_INVALID", `brain.json fails schema: ${describeErrors(schemas.brain.errors).join("; ")}`);
22
+ }
23
+ const { notes, problems } = await scanNotes(brain.root);
24
+ const noteCounts = Object.fromEntries(NOTE_TYPES.map((type) => [type, 0]));
25
+ const proposalCounts = Object.fromEntries(PROPOSAL_STATUSES.map((status) => [status, 0]));
26
+ for (const note of notes) {
27
+ if (note.type === "proposal") {
28
+ const status = note.attributes.status;
29
+ if (typeof status === "string" && status in proposalCounts)
30
+ proposalCounts[status] += 1;
31
+ }
32
+ else if (note.type in noteCounts) {
33
+ noteCounts[note.type] += 1;
34
+ }
35
+ }
36
+ const index = buildIndex(notes).markdown;
37
+ let currentIndex = null;
38
+ try {
39
+ currentIndex = await readFile(join(brain.root, "index.md"), "utf8");
40
+ }
41
+ catch {
42
+ // A missing index is stale, as it would be regenerated by `brain index`.
43
+ }
44
+ const issues = lintBrain({
45
+ root: brain.root,
46
+ notes,
47
+ problems,
48
+ brainConfig: brain.config,
49
+ indexMarkdown: currentIndex,
50
+ });
51
+ const viewStale = issues.filter((issue) => issue.rule === "view-stale").map((issue) => issue.path);
52
+ const notion = readNotion(brain.config);
53
+ const lastLog = await findLastLogLine(brain.root);
54
+ const data = {
55
+ schema_version: brain.config.schema_version,
56
+ owner: brain.config.owner.name,
57
+ notes: noteCounts,
58
+ proposals: proposalCounts,
59
+ notion,
60
+ index_stale: currentIndex !== index,
61
+ views_stale: viewStale,
62
+ last_log: lastLog,
63
+ };
64
+ return { data, human: `Owner: ${data.owner}\nNotes: ${notes.length}\n` };
65
+ },
66
+ };
67
+ function readNotion(config) {
68
+ const notion = config.notion;
69
+ const databases = notion.databases;
70
+ const configured = [];
71
+ const missing = [];
72
+ for (const [key, value] of Object.entries(databases)) {
73
+ (typeof value === "string" && value !== "" ? configured : missing).push(key);
74
+ }
75
+ const tasks = notion.tasks;
76
+ return { configured, missing, tasks_database: typeof tasks.database === "string" && tasks.database !== "" };
77
+ }
78
+ async function findLastLogLine(root) {
79
+ let entries;
80
+ try {
81
+ entries = (await readdir(join(root, "log"))).filter((entry) => /^\d{4}-\d{2}\.md$/.test(entry)).sort();
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ for (const entry of entries.reverse()) {
87
+ try {
88
+ const lines = (await readFile(join(root, "log", entry), "utf8")).trimEnd().split("\n");
89
+ for (const line of lines.reverse())
90
+ if (line.startsWith("- "))
91
+ return line;
92
+ }
93
+ catch {
94
+ // Status is diagnostic; an unreadable old log does not make the brain unusable.
95
+ }
96
+ }
97
+ return null;
98
+ }