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,53 @@
1
+ import { readJsonInput } from "../lib/input.js";
2
+ import { applyPlan, buildPlan, parsePlan, parseSnapshot, record, render, renderTasks } from "../lib/sync.js";
3
+ import { Workspace } from "../lib/workspace.js";
4
+ import { BrainError } from "../lib/errors.js";
5
+ import { lintWarnings } from "../lib/warnings.js";
6
+ export const syncPlanCommand = {
7
+ name: "sync plan", usage: "brain sync plan --input <snapshot|-> [--json]", summary: "Compare a Notion snapshot with the brain without writing.", options: { input: "string" }, maxPositionals: 0, needsBrain: true,
8
+ async run({ args, brain }) { const input = args.options.input; if (input === undefined)
9
+ throw new BrainError("USAGE", "sync plan needs --input"); const workspace = await Workspace.open(brain); const plan = buildPlan(workspace, parseSnapshot(await readJsonInput(input, "sync plan"))); return { data: plan, human: `${plan.create.length} creates, ${plan.updates.length} updates, ${plan.corrections.length} corrections\n` }; },
10
+ };
11
+ export const syncApplyCommand = {
12
+ name: "sync apply", usage: "brain sync apply --input <plan|-> [--record-only] [--dry-run] [--json]", summary: "Apply a sync plan using atomic per-item writes.", options: { input: "string", "record-only": "boolean", "dry-run": "boolean" }, maxPositionals: 0, needsBrain: true,
13
+ async run({ args, brain, now }) { const input = args.options.input; if (input === undefined)
14
+ throw new BrainError("USAGE", "sync apply needs --input"); const workspace = await Workspace.open(brain); const data = applyPlan(workspace, parsePlan(await readJsonInput(input, "sync apply")), now, args.options["record-only"] === true); const warnings = lintWarnings(workspace.warnings()); const dryRun = args.options["dry-run"] === true; if (!dryRun)
15
+ await workspace.commit(data.log, now); const { log: _log, ...output } = data; const failed = Number(data.summary.failed ?? 0); return { data: { dry_run: dryRun, ...output }, warnings, exitCode: failed > 0 ? 1 : 0, human: `${failed} failed items\n` }; },
16
+ };
17
+ export const syncRenderCommand = {
18
+ name: "sync render", usage: "brain sync render (--changed | --ids <id>[,<id>…]) [--plan <plan.json>] [--json]", summary: "Render Notion payloads without writing.", options: { changed: "boolean", ids: "string", plan: "string" }, maxPositionals: 0, needsBrain: true,
19
+ async run({ args, brain, now }) {
20
+ const changed = args.options.changed === true;
21
+ const idsText = args.options.ids;
22
+ if (changed === (idsText !== undefined))
23
+ throw new BrainError("USAGE", "provide exactly one of --changed or --ids");
24
+ const workspace = await Workspace.open(brain);
25
+ const ids = idsText === undefined ? undefined : idsText.split(",").map((id) => id.trim()).filter(Boolean);
26
+ if (ids !== undefined)
27
+ for (const id of ids)
28
+ if (workspace.get(id) === undefined)
29
+ throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
30
+ const held = new Map();
31
+ const planPath = args.options.plan;
32
+ if (planPath !== undefined)
33
+ for (const item of parsePlan(await readJsonInput(planPath, "sync render")).conflicts)
34
+ if (item.code === "BOTH_CHANGED" && typeof item.brain_id === "string" && typeof item.property === "string") {
35
+ const set = held.get(item.brain_id) ?? new Set();
36
+ set.add(item.property);
37
+ held.set(item.brain_id, set);
38
+ }
39
+ const data = render(workspace, now, ids, held);
40
+ return { data: { items: data.items, errors: data.errors }, warnings: data.warnings, exitCode: data.errors.length > 0 ? 1 : 0, human: `${data.items.length} payloads\n` };
41
+ },
42
+ };
43
+ export const syncTasksCommand = {
44
+ name: "sync tasks", usage: "brain sync tasks --meeting <mtg-id> --input <file|-> [--json]", summary: "Render action-item task payloads for a meeting.", options: { meeting: "string", input: "string" }, maxPositionals: 0, needsBrain: true,
45
+ async run({ args, brain }) { const meeting = args.options.meeting; const input = args.options.input; if (meeting === undefined || input === undefined)
46
+ throw new BrainError("USAGE", "sync tasks needs --meeting and --input"); return { data: renderTasks(await Workspace.open(brain), meeting, await readJsonInput(input, "sync tasks")), human: "Task payloads rendered.\n" }; },
47
+ };
48
+ export const syncRecordCommand = {
49
+ name: "sync record", usage: "brain sync record --input <file|-> [--dry-run] [--json]", summary: "Record successful Notion publication results.", options: { input: "string", "dry-run": "boolean" }, maxPositionals: 0, needsBrain: true,
50
+ async run({ args, brain, now }) { const input = args.options.input; if (input === undefined)
51
+ throw new BrainError("USAGE", "sync record needs --input"); const workspace = await Workspace.open(brain); const data = record(workspace, await readJsonInput(input, "sync record")); const records = data.records; const dryRun = args.options["dry-run"] === true; if (!dryRun)
52
+ await workspace.commit([], now); const failed = records.filter((item) => item.result === "failed").length; return { data: { dry_run: dryRun, ...data }, exitCode: failed > 0 ? 1 : 0, human: `${records.length} records\n` }; },
53
+ };
@@ -0,0 +1,143 @@
1
+ import { lstat, readdir, readFile, unlink } from "node:fs/promises";
2
+ import { dirname, join, relative, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { atomicWriteFile, ensureDirectory } from "../lib/fs-safe.js";
5
+ import { appendLog } from "../lib/log.js";
6
+ import { assertPlainPath, listShippedSkillFiles } from "../lib/skills.js";
7
+ const TEMPLATE_ROOT = fileURLToPath(new URL("../../templates/brain/", import.meta.url));
8
+ const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url));
9
+ const START = /^<!-- ultimate-brain:rules:start\b[^\n]*-->$/gm;
10
+ const END = /^<!-- ultimate-brain:rules:end -->$/gm;
11
+ /** `brain upgrade` (spec/cli.md §5.15): bring the product-owned files of a brain up to the installed package. */
12
+ export const upgradeCommand = {
13
+ name: "upgrade",
14
+ usage: "brain upgrade [--dry-run] [--json]",
15
+ summary: "Update the brain's product-owned AGENTS.md block and skills to the installed version.",
16
+ options: { "dry-run": "boolean" },
17
+ maxPositionals: 0,
18
+ needsBrain: true,
19
+ async run({ args, brain, now }) {
20
+ const root = brain.root;
21
+ const warnings = [];
22
+ const changes = [];
23
+ let unchanged = 0;
24
+ // Every check runs before the first write, so a refusal leaves the brain untouched.
25
+ const template = await readFile(join(TEMPLATE_ROOT, "AGENTS.md"), "utf8");
26
+ await assertPlainPath(root, "AGENTS.md");
27
+ const current = await readOptional(join(root, "AGENTS.md"));
28
+ if (current === null)
29
+ changes.push({ path: "AGENTS.md", action: "created", content: template });
30
+ else {
31
+ const block = rulesBlock(template);
32
+ const own = rulesBlock(current);
33
+ if (own === undefined) {
34
+ changes.push({ path: "AGENTS.md", action: "skipped" });
35
+ warnings.push({ code: "AGENTS_BLOCK_NOT_FOUND", message: "AGENTS.md has no single ultimate-brain rules block; left unchanged." });
36
+ }
37
+ else if (own === block)
38
+ unchanged += 1;
39
+ else
40
+ changes.push({ path: "AGENTS.md", action: "updated", content: current.replace(own, () => block) });
41
+ }
42
+ if (!(await exists(join(root, "CLAUDE.md"))))
43
+ changes.push({ path: "CLAUDE.md", action: "created", content: "@AGENTS.md\n" });
44
+ else
45
+ unchanged += 1;
46
+ // Skills (decision 0029): shipped files are product-owned, inside the folders of shipped skills only.
47
+ const shipped = await listShippedSkillFiles();
48
+ const shippedPaths = new Set(shipped.map((file) => file.relative));
49
+ for (const name of new Set(shipped.map((file) => file.relative.split("/")[0])))
50
+ await assertPlainPath(root, `.agents/skills/${name}`, "folder");
51
+ for (const file of shipped) {
52
+ const path = `.agents/skills/${file.relative}`;
53
+ await assertPlainPath(root, path);
54
+ const existing = await readOptionalBuffer(join(root, path));
55
+ if (existing === null)
56
+ changes.push({ path, action: "created", content: file.content });
57
+ else if (existing.equals(file.content))
58
+ unchanged += 1;
59
+ else
60
+ changes.push({ path, action: "updated", content: file.content });
61
+ }
62
+ for (const name of new Set(shipped.map((file) => file.relative.split("/")[0]))) {
63
+ for (const file of await listFiles(join(root, ".agents/skills", name))) {
64
+ const inSkills = `${name}/${file}`;
65
+ await assertPlainPath(root, `.agents/skills/${inSkills}`);
66
+ if (!shippedPaths.has(inSkills))
67
+ changes.push({ path: `.agents/skills/${inSkills}`, action: "removed" });
68
+ }
69
+ }
70
+ changes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
71
+ const written = changes.filter((change) => change.action !== "skipped");
72
+ const version = JSON.parse(await readFile(PACKAGE_JSON, "utf8")).version;
73
+ const dryRun = args.options["dry-run"] === true;
74
+ if (!dryRun && written.length > 0) {
75
+ for (const change of written) {
76
+ if (change.action === "removed") {
77
+ await unlink(join(root, change.path));
78
+ continue;
79
+ }
80
+ await ensureDirectory(root, dirname(change.path));
81
+ await atomicWriteFile(root, change.path, change.content);
82
+ }
83
+ await appendLog(root, now, [{ op: "upgrade", id: "-", title: version, pairs: [["files", written.length]] }]);
84
+ }
85
+ return {
86
+ warnings,
87
+ data: { dry_run: dryRun, version, files: changes.map(({ path, action }) => ({ path, action })), unchanged },
88
+ human: `${written.length} files ${dryRun ? "would change" : "changed"}, ${unchanged} unchanged\n`,
89
+ };
90
+ },
91
+ };
92
+ /** The text from the rules start marker through the end marker, when exactly one well-ordered pair exists. */
93
+ function rulesBlock(text) {
94
+ const starts = [...text.matchAll(START)];
95
+ const ends = [...text.matchAll(END)];
96
+ if (starts.length !== 1 || ends.length !== 1 || ends[0].index < starts[0].index)
97
+ return undefined;
98
+ return text.slice(starts[0].index, ends[0].index + ends[0][0].length);
99
+ }
100
+ async function listFiles(folder) {
101
+ const files = [];
102
+ async function visit(current) {
103
+ let entries;
104
+ try {
105
+ entries = await readdir(current, { withFileTypes: true });
106
+ }
107
+ catch {
108
+ return;
109
+ }
110
+ for (const entry of entries) {
111
+ const path = join(current, entry.name);
112
+ if (entry.isDirectory())
113
+ await visit(path);
114
+ else
115
+ files.push(relative(folder, path).split(sep).join("/"));
116
+ }
117
+ }
118
+ await visit(folder);
119
+ return files;
120
+ }
121
+ async function exists(path) {
122
+ try {
123
+ await lstat(path);
124
+ return true;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ async function readOptional(path) {
131
+ const buffer = await readOptionalBuffer(path);
132
+ return buffer === null ? null : buffer.toString("utf8");
133
+ }
134
+ async function readOptionalBuffer(path) {
135
+ try {
136
+ if (!(await lstat(path)).isFile())
137
+ return null;
138
+ return await readFile(path);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
@@ -0,0 +1,111 @@
1
+ import { BrainError } from "../lib/errors.js";
2
+ import { serializeNewFile } from "../lib/frontmatter.js";
3
+ import { generateId } from "../lib/ids.js";
4
+ import { compareCodePoints } from "../lib/notes.js";
5
+ import { formatDate, formatTimestamp } from "../lib/time.js";
6
+ import { deriveView, generatedBlockRange, projectViewPath } from "../lib/views.js";
7
+ import { Workspace } from "../lib/workspace.js";
8
+ /** `brain views [--project <id>] [--check]` (spec/cli.md §5.9). */
9
+ export const viewsCommand = {
10
+ name: "views",
11
+ usage: "brain views [--project <id>] [--check] [--json]",
12
+ summary: "Regenerate project briefs and decision logs while preserving user blocks.",
13
+ options: { project: "string", check: "boolean" },
14
+ maxPositionals: 0,
15
+ needsBrain: true,
16
+ async run({ args, brain, now }) {
17
+ const workspace = await Workspace.open(brain);
18
+ const requested = args.options.project;
19
+ let projects = workspace.notes.filter((note) => note.type === "project");
20
+ if (requested !== undefined) {
21
+ const project = workspace.get(requested);
22
+ if (project?.type !== "project")
23
+ throw new BrainError("UNKNOWN_ID", `No project with id ${requested}`);
24
+ projects = [project];
25
+ }
26
+ projects.sort((left, right) => compareCodePoints(left.path, right.path));
27
+ const written = [];
28
+ const created = [];
29
+ const unchanged = [];
30
+ const errors = [];
31
+ const ids = workspace.ids();
32
+ const check = args.options.check === true;
33
+ for (const project of projects) {
34
+ for (const kind of ["brief", "decision_log"]) {
35
+ const path = projectViewPath(project, kind);
36
+ const existing = workspace.notes.find((note) => note.path === path);
37
+ const derived = deriveView(project, kind, workspace.notes);
38
+ if (existing === undefined && workspace.exists(path)) {
39
+ errors.push({ path, reason: "MARKERS_INVALID" });
40
+ continue;
41
+ }
42
+ if (existing === undefined) {
43
+ created.push(path);
44
+ if (!check) {
45
+ const id = generateId("vw", ids);
46
+ ids.add(id);
47
+ workspace.write(path, serializeNewFile(newViewAttributes(id, project, kind, derived.generatedFrom, now), `${derived.generatedBlock}\n\n<!-- @user:start -->\n<!-- @user:end -->\n`));
48
+ }
49
+ continue;
50
+ }
51
+ const range = existing.type === "view" && existing.attributes.project === project.id && existing.attributes.view_kind === kind
52
+ ? generatedBlockRange(existing.body)
53
+ : undefined;
54
+ if (range === undefined) {
55
+ errors.push({ path, reason: "MARKERS_INVALID" });
56
+ continue;
57
+ }
58
+ const currentFrom = Array.isArray(existing.attributes.generated_from) ? existing.attributes.generated_from : [];
59
+ const unchangedView = existing.body.slice(range.start, range.end) === derived.generatedBlock
60
+ && sameStrings(currentFrom, derived.generatedFrom);
61
+ if (unchangedView) {
62
+ unchanged.push(path);
63
+ continue;
64
+ }
65
+ written.push(path);
66
+ if (!check) {
67
+ const document = workspace.edit(existing);
68
+ document.attributes.generated_from = derived.generatedFrom;
69
+ document.attributes.generated_at = formatTimestamp(now);
70
+ document.attributes.updated = formatDate(now);
71
+ document.body = `${existing.body.slice(0, range.start)}${derived.generatedBlock}${existing.body.slice(range.end)}`;
72
+ workspace.save(path, document);
73
+ }
74
+ }
75
+ }
76
+ if (!check && workspace.changedPaths.length > 0) {
77
+ const invalid = workspace.newErrors();
78
+ if (invalid.length > 0) {
79
+ throw new BrainError("INPUT_INVALID", `Generated views would add lint errors: ${invalid.map((issue) => `${issue.path} ${issue.rule}`).join(", ")}`);
80
+ }
81
+ await workspace.commit();
82
+ }
83
+ written.sort(compareCodePoints);
84
+ created.sort(compareCodePoints);
85
+ unchanged.sort(compareCodePoints);
86
+ errors.sort((left, right) => compareCodePoints(left.path, right.path));
87
+ const exitCode = errors.length > 0 || (check && (written.length > 0 || created.length > 0)) ? 1 : 0;
88
+ return {
89
+ data: { projects: projects.length, written, created, unchanged, errors, check },
90
+ exitCode,
91
+ human: errors.length > 0 ? `${errors.map((error) => `${error.path}: ${error.reason}`).join("\n")}\n` : "Views are current.\n",
92
+ };
93
+ },
94
+ };
95
+ function newViewAttributes(id, project, kind, generatedFrom, now) {
96
+ const suffix = kind === "brief" ? "brief" : "decision log";
97
+ return {
98
+ id,
99
+ type: "view",
100
+ title: `${String(project.attributes.title)} — ${suffix}`,
101
+ view_kind: kind,
102
+ project: project.id,
103
+ generated_from: generatedFrom,
104
+ generated_at: formatTimestamp(now),
105
+ created: formatDate(now),
106
+ updated: formatDate(now),
107
+ };
108
+ }
109
+ function sameStrings(left, right) {
110
+ return left.length === right.length && left.every((value, index) => value === right[index]);
111
+ }