ultimate-brain 0.0.0-stage → 0.1.1

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 (80) 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-ingest/SKILL.md +104 -0
  5. package/.agents/skills/brain-ingest/references/cli-inputs.md +106 -0
  6. package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
  7. package/.agents/skills/brain-ingest/references/extraction.md +103 -0
  8. package/.agents/skills/brain-ingest/references/notion.md +61 -0
  9. package/.agents/skills/brain-notion-change/SKILL.md +57 -0
  10. package/.agents/skills/brain-query/SKILL.md +62 -0
  11. package/.agents/skills/brain-sync/SKILL.md +64 -0
  12. package/.agents/skills/brain-sync/references/formats.md +53 -0
  13. package/LICENSE +21 -0
  14. package/README.md +40 -2
  15. package/bin/brain.js +5 -0
  16. package/dist/cli.js +83 -0
  17. package/dist/commands/apply.js +34 -0
  18. package/dist/commands/config.js +96 -0
  19. package/dist/commands/id.js +32 -0
  20. package/dist/commands/index.js +58 -0
  21. package/dist/commands/init.js +233 -0
  22. package/dist/commands/link.js +285 -0
  23. package/dist/commands/lint.js +47 -0
  24. package/dist/commands/log.js +48 -0
  25. package/dist/commands/note.js +36 -0
  26. package/dist/commands/proposal.js +57 -0
  27. package/dist/commands/query.js +38 -0
  28. package/dist/commands/status.js +98 -0
  29. package/dist/commands/sync.js +53 -0
  30. package/dist/commands/upgrade.js +143 -0
  31. package/dist/commands/views.js +111 -0
  32. package/dist/lib/apply.js +524 -0
  33. package/dist/lib/args.js +63 -0
  34. package/dist/lib/brain-path.js +91 -0
  35. package/dist/lib/brain.js +36 -0
  36. package/dist/lib/command.js +61 -0
  37. package/dist/lib/errors.js +33 -0
  38. package/dist/lib/frontmatter.js +171 -0
  39. package/dist/lib/fs-safe.js +140 -0
  40. package/dist/lib/ids.js +51 -0
  41. package/dist/lib/index-builder.js +216 -0
  42. package/dist/lib/input.js +45 -0
  43. package/dist/lib/lint.js +336 -0
  44. package/dist/lib/log.js +44 -0
  45. package/dist/lib/note-new.js +119 -0
  46. package/dist/lib/notes.js +95 -0
  47. package/dist/lib/paths.js +49 -0
  48. package/dist/lib/proposals.js +229 -0
  49. package/dist/lib/query.js +163 -0
  50. package/dist/lib/schemas.js +51 -0
  51. package/dist/lib/skills.js +60 -0
  52. package/dist/lib/sync.js +714 -0
  53. package/dist/lib/time.js +21 -0
  54. package/dist/lib/views.js +139 -0
  55. package/dist/lib/warnings.js +9 -0
  56. package/dist/lib/workspace.js +183 -0
  57. package/package.json +45 -4
  58. package/spec/schemas/brain.schema.json +200 -0
  59. package/spec/schemas/note.schema.json +1059 -0
  60. package/spec/schemas/proposal.schema.json +559 -0
  61. package/templates/README.md +44 -0
  62. package/templates/brain/AGENTS.md +54 -0
  63. package/templates/brain/CLAUDE.md +1 -0
  64. package/templates/brain/_proposals/.gitkeep +0 -0
  65. package/templates/brain/archive/goals/.gitkeep +0 -0
  66. package/templates/brain/archive/projects/.gitkeep +0 -0
  67. package/templates/brain/areas/.gitkeep +0 -0
  68. package/templates/brain/brain.json +35 -0
  69. package/templates/brain/gitignore +6 -0
  70. package/templates/brain/goals/.gitkeep +0 -0
  71. package/templates/brain/index.md +3 -0
  72. package/templates/brain/knowledge/concepts/.gitkeep +0 -0
  73. package/templates/brain/knowledge/decisions/.gitkeep +0 -0
  74. package/templates/brain/knowledge/meetings/.gitkeep +0 -0
  75. package/templates/brain/knowledge/orgs/.gitkeep +0 -0
  76. package/templates/brain/knowledge/people/.gitkeep +0 -0
  77. package/templates/brain/knowledge/questions/.gitkeep +0 -0
  78. package/templates/brain/memories/.gitkeep +0 -0
  79. package/templates/brain/projects/.gitkeep +0 -0
  80. package/templates/brain/sources/notion/.gitkeep +0 -0
@@ -0,0 +1,58 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../lib/fs-safe.js";
4
+ import { buildIndex } from "../lib/index-builder.js";
5
+ import { scanNotes } from "../lib/notes.js";
6
+ /** `brain index [--check]` (spec/cli.md §5.8, spec/schema.md §13). */
7
+ export const indexCommand = {
8
+ name: "index",
9
+ usage: "brain index [--check] [--json]",
10
+ summary: "Regenerate index.md from note frontmatter. --check writes nothing and exits 1 if index.md is out of date.",
11
+ options: { check: "boolean" },
12
+ maxPositionals: 0,
13
+ needsBrain: true,
14
+ async run({ args, brain }) {
15
+ const root = brain.root;
16
+ const check = args.options.check === true;
17
+ const { notes, problems } = await scanNotes(root);
18
+ const built = buildIndex(notes);
19
+ const warnings = [
20
+ ...problems.map((problem) => ({ code: problem.code, message: `${problem.path}: ${problem.message}`, path: problem.path })),
21
+ ...built.duplicates.map((duplicate) => ({
22
+ code: "DUPLICATE_ID",
23
+ message: `${duplicate.id} in ${duplicate.path} is also in ${duplicate.kept}; kept ${duplicate.kept}`,
24
+ path: duplicate.path,
25
+ id: duplicate.id,
26
+ })),
27
+ ];
28
+ let current;
29
+ try {
30
+ current = await readFile(join(root, "index.md"), "utf8");
31
+ }
32
+ catch {
33
+ current = null;
34
+ }
35
+ const differs = current !== built.markdown;
36
+ const written = !check && differs;
37
+ if (written)
38
+ await atomicWriteFile(root, "index.md", built.markdown);
39
+ // `stale`: index.md is out of date after this command (only possible with --check).
40
+ const stale = check && differs;
41
+ const human = check
42
+ ? differs ? "index.md is out of date (run brain index)\n" : "index.md is current\n"
43
+ : written ? `index.md updated (${built.entries.length} notes)\n` : "index.md already current\n";
44
+ return {
45
+ data: {
46
+ written,
47
+ stale,
48
+ path: "index.md",
49
+ counts: built.counts,
50
+ notes: built.entries,
51
+ backlinks: built.backlinks,
52
+ },
53
+ warnings,
54
+ exitCode: check && stale ? 1 : 0,
55
+ human,
56
+ };
57
+ },
58
+ };
@@ -0,0 +1,233 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { lstat, mkdir, readdir, readFile, realpath } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { BrainError } from "../lib/errors.js";
7
+ import { atomicWriteFile, ensureDirectory } from "../lib/fs-safe.js";
8
+ import { buildIndex } from "../lib/index-builder.js";
9
+ import { isProductRepository } from "../lib/brain-path.js";
10
+ import { appendLog } from "../lib/log.js";
11
+ import { scanNotes } from "../lib/notes.js";
12
+ import { listShippedSkillFiles } from "../lib/skills.js";
13
+ const exec = promisify(execFile);
14
+ const TEMPLATE_ROOT = fileURLToPath(new URL("../../templates/brain/", import.meta.url));
15
+ /** `brain init <path>` (spec/cli.md §5.1). */
16
+ export const initCommand = {
17
+ name: "init",
18
+ usage: "brain init <path> [--owner <name>] [--force] [--no-git] [--set-default] [--json]",
19
+ summary: "Create a new Ultimate Brain repository from the versioned template.",
20
+ options: { owner: "string", force: "boolean", "no-git": "boolean", "set-default": "boolean" },
21
+ maxPositionals: 1,
22
+ needsBrain: false,
23
+ async run({ args, now, environment }) {
24
+ const [requested] = args.positionals;
25
+ if (requested === undefined)
26
+ throw new BrainError("USAGE", "brain init needs a target path");
27
+ const target = resolve(requested);
28
+ const present = await inspectTarget(target);
29
+ if (present.kind === "file")
30
+ throw new BrainError("TARGET_NOT_EMPTY", `Brain target is not a directory: ${target}`);
31
+ if (!present.exists)
32
+ await mkdir(target, { recursive: true });
33
+ const root = await realpath(target);
34
+ if (await isProductRepository(root)) {
35
+ throw new BrainError("BRAIN_IS_PRODUCT_REPO", `Refusing to initialize the Ultimate Brain product repository: ${root}`);
36
+ }
37
+ if (await exists(join(root, "brain.json"))) {
38
+ throw new BrainError("ALREADY_A_BRAIN", `Target already contains brain.json: ${root}`);
39
+ }
40
+ if (present.entries.length > 0 && args.options.force !== true) {
41
+ throw new BrainError("TARGET_NOT_EMPTY", `Brain target is not empty: ${root}`);
42
+ }
43
+ const owner = args.options.owner ?? await defaultOwner();
44
+ if (owner.trim() === "")
45
+ throw new BrainError("USAGE", "--owner must not be empty");
46
+ const created = [];
47
+ const skipped = [];
48
+ const template = await listTemplateFiles();
49
+ for (const file of template) {
50
+ if (await exists(join(root, file.relative))) {
51
+ skipped.push(file.relative);
52
+ continue;
53
+ }
54
+ let content = await readFile(file.source, "utf8");
55
+ if (file.relative === "brain.json")
56
+ content = initialConfig(content, owner);
57
+ await ensureDirectory(root, dirname(file.relative));
58
+ await atomicWriteFile(root, file.relative, content);
59
+ created.push(file.relative);
60
+ }
61
+ // Product-owned Codex skills (decision 0029); like template files, existing ones are never overwritten.
62
+ for (const skill of await listShippedSkillFiles()) {
63
+ const path = `.agents/skills/${skill.relative}`;
64
+ if (await exists(join(root, path))) {
65
+ skipped.push(path);
66
+ continue;
67
+ }
68
+ await ensureDirectory(root, dirname(path));
69
+ await atomicWriteFile(root, path, skill.content);
70
+ created.push(path);
71
+ }
72
+ // The template includes an empty index, but build it rather than trusting that snapshot.
73
+ // With --force an existing index is left untouched along with every other existing template file.
74
+ if (!skipped.includes("index.md")) {
75
+ const { notes } = await scanNotes(root);
76
+ const index = buildIndex(notes).markdown;
77
+ await atomicWriteFile(root, "index.md", index);
78
+ }
79
+ const logPath = `log/${now.toISOString().slice(0, 7)}.md`;
80
+ const hadLog = await exists(join(root, logPath));
81
+ await appendLog(root, now, [{ op: "init", id: "-", title: owner, pairs: [["schema", 1]] }]);
82
+ if (!hadLog)
83
+ created.push(logPath);
84
+ const warnings = [];
85
+ const git = await initializeGit(root, args.options["no-git"] === true, warnings);
86
+ let defaultConfig;
87
+ if (args.options["set-default"] === true) {
88
+ defaultConfig = await writeDefaultConfig(root, environment.homeDirectory);
89
+ }
90
+ else {
91
+ defaultConfig = { written: false, path_abs: null, previous: null };
92
+ }
93
+ created.sort(comparePath);
94
+ skipped.sort(comparePath);
95
+ return {
96
+ resolvedBrain: root,
97
+ warnings,
98
+ data: {
99
+ path_abs: root,
100
+ created,
101
+ skipped,
102
+ git,
103
+ default_config: defaultConfig,
104
+ },
105
+ human: `Initialized Ultimate Brain at ${root}\n`,
106
+ };
107
+ },
108
+ };
109
+ async function inspectTarget(target) {
110
+ try {
111
+ const metadata = await lstat(target);
112
+ if (!metadata.isDirectory())
113
+ return { exists: true, kind: "file", entries: [] };
114
+ return { exists: true, kind: "directory", entries: await readdir(target) };
115
+ }
116
+ catch (error) {
117
+ if (isNodeError(error) && error.code === "ENOENT")
118
+ return { exists: false, kind: "directory", entries: [] };
119
+ throw error;
120
+ }
121
+ }
122
+ async function listTemplateFiles() {
123
+ const root = await realpath(TEMPLATE_ROOT);
124
+ const files = [];
125
+ async function visit(folder) {
126
+ for (const entry of await readdir(folder, { withFileTypes: true })) {
127
+ const source = join(folder, entry.name);
128
+ if (entry.isSymbolicLink())
129
+ throw new BrainError("OUTSIDE_ROOT", `Template contains a symbolic link: ${source}`);
130
+ if (entry.isDirectory())
131
+ await visit(source);
132
+ else if (entry.isFile()) {
133
+ const actual = await realpath(source);
134
+ assertInside(root, actual);
135
+ const templatePath = relative(root, actual).split(sep).join("/");
136
+ files.push({ source: actual, relative: templatePath === "gitignore" ? ".gitignore" : templatePath });
137
+ }
138
+ }
139
+ }
140
+ await visit(root);
141
+ return files.sort((left, right) => comparePath(left.relative, right.relative));
142
+ }
143
+ function initialConfig(template, owner) {
144
+ let config;
145
+ try {
146
+ config = JSON.parse(template);
147
+ }
148
+ catch (error) {
149
+ throw new BrainError("INTERNAL", `Brain template has invalid brain.json: ${error instanceof Error ? error.message : String(error)}`);
150
+ }
151
+ if (!isRecord(config) || !isRecord(config.owner))
152
+ throw new BrainError("INTERNAL", "Brain template has no owner configuration");
153
+ config.schema_version = 1;
154
+ config.owner.name = owner;
155
+ return `${JSON.stringify(config, null, 2)}\n`;
156
+ }
157
+ async function defaultOwner() {
158
+ try {
159
+ const { stdout } = await exec("git", ["config", "--get", "user.name"]);
160
+ const name = stdout.trim();
161
+ return name === "" ? "Owner" : name;
162
+ }
163
+ catch {
164
+ return "Owner";
165
+ }
166
+ }
167
+ async function initializeGit(root, noGit, warnings) {
168
+ if (noGit)
169
+ return { initialized: false, committed: false };
170
+ try {
171
+ await exec("git", ["init"], { cwd: root });
172
+ await exec("git", ["add", "--all"], { cwd: root });
173
+ }
174
+ catch {
175
+ warnings.push({ code: "GIT_COMMIT_SKIPPED", message: "Git initialization or staging failed; brain files were kept." });
176
+ return { initialized: false, committed: false };
177
+ }
178
+ try {
179
+ await exec("git", ["commit", "-m", "init: ultimate brain (schema v1)"], { cwd: root });
180
+ return { initialized: true, committed: true };
181
+ }
182
+ catch {
183
+ warnings.push({ code: "GIT_COMMIT_SKIPPED", message: "Git commit was skipped; configure a Git identity and commit the brain files." });
184
+ return { initialized: true, committed: false };
185
+ }
186
+ }
187
+ async function writeDefaultConfig(brain, homeDirectory) {
188
+ // `homeDirectory` is injectable for isolated CLI tests; production uses the current user's home.
189
+ const home = homeDirectory ?? process.env.HOME;
190
+ if (home === undefined || home === "")
191
+ throw new BrainError("INTERNAL", "Cannot determine home directory for --set-default");
192
+ const absoluteHome = resolve(home);
193
+ const root = await realpath(absoluteHome);
194
+ const folder = join(root, ".config", "ultimate-brain");
195
+ await ensureDirectory(root, ".config/ultimate-brain");
196
+ const configPath = join(folder, "config.json");
197
+ let previous = null;
198
+ try {
199
+ previous = JSON.parse(await readFile(configPath, "utf8"));
200
+ }
201
+ catch (error) {
202
+ if (!isNodeError(error) || error.code !== "ENOENT")
203
+ previous = "<unreadable>";
204
+ }
205
+ await atomicWriteFile(root, configPath, `${JSON.stringify({ brain }, null, 2)}\n`);
206
+ return { written: true, path_abs: configPath, previous };
207
+ }
208
+ async function exists(path) {
209
+ try {
210
+ await lstat(path);
211
+ return true;
212
+ }
213
+ catch (error) {
214
+ if (isNodeError(error) && error.code === "ENOENT")
215
+ return false;
216
+ throw error;
217
+ }
218
+ }
219
+ function assertInside(root, target) {
220
+ const path = relative(root, target);
221
+ if (path === ".." || path.startsWith(`..${sep}`)) {
222
+ throw new BrainError("OUTSIDE_ROOT", `Template file resolves outside its root: ${target}`);
223
+ }
224
+ }
225
+ function comparePath(left, right) {
226
+ return left < right ? -1 : left > right ? 1 : 0;
227
+ }
228
+ function isRecord(value) {
229
+ return typeof value === "object" && value !== null && !Array.isArray(value);
230
+ }
231
+ function isNodeError(error) {
232
+ return error instanceof Error && "code" in error;
233
+ }
@@ -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
+ };