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.
- package/.agents/skills/README.md +3 -0
- package/.agents/skills/brain-apply/SKILL.md +80 -0
- package/.agents/skills/brain-connect-notion/SKILL.md +55 -0
- package/.agents/skills/brain-howto/SKILL.md +36 -0
- package/.agents/skills/brain-howto/references/guide.md +176 -0
- package/.agents/skills/brain-ingest/SKILL.md +105 -0
- package/.agents/skills/brain-ingest/references/cli-inputs.md +129 -0
- package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
- package/.agents/skills/brain-ingest/references/extraction.md +118 -0
- package/.agents/skills/brain-ingest/references/notion.md +65 -0
- package/.agents/skills/brain-ingest/references/sources.md +70 -0
- package/.agents/skills/brain-notion-change/SKILL.md +57 -0
- package/.agents/skills/brain-query/SKILL.md +62 -0
- package/.agents/skills/brain-sync/SKILL.md +66 -0
- package/.agents/skills/brain-sync/references/formats.md +53 -0
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/brain.js +5 -0
- package/dist/cli.js +83 -0
- package/dist/commands/apply.js +34 -0
- package/dist/commands/config.js +96 -0
- package/dist/commands/id.js +32 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/init.js +233 -0
- package/dist/commands/link.js +285 -0
- package/dist/commands/lint.js +47 -0
- package/dist/commands/log.js +48 -0
- package/dist/commands/note.js +36 -0
- package/dist/commands/proposal.js +57 -0
- package/dist/commands/query.js +38 -0
- package/dist/commands/status.js +98 -0
- package/dist/commands/sync.js +53 -0
- package/dist/commands/upgrade.js +143 -0
- package/dist/commands/views.js +111 -0
- package/dist/lib/apply.js +524 -0
- package/dist/lib/args.js +63 -0
- package/dist/lib/brain-path.js +91 -0
- package/dist/lib/brain.js +36 -0
- package/dist/lib/command.js +61 -0
- package/dist/lib/errors.js +33 -0
- package/dist/lib/frontmatter.js +171 -0
- package/dist/lib/fs-safe.js +140 -0
- package/dist/lib/ids.js +51 -0
- package/dist/lib/index-builder.js +216 -0
- package/dist/lib/input.js +45 -0
- package/dist/lib/lint.js +340 -0
- package/dist/lib/log.js +44 -0
- package/dist/lib/note-new.js +121 -0
- package/dist/lib/notes.js +95 -0
- package/dist/lib/paths.js +59 -0
- package/dist/lib/proposals.js +229 -0
- package/dist/lib/query.js +163 -0
- package/dist/lib/schemas.js +51 -0
- package/dist/lib/skills.js +60 -0
- package/dist/lib/sync.js +760 -0
- package/dist/lib/time.js +21 -0
- package/dist/lib/views.js +139 -0
- package/dist/lib/warnings.js +9 -0
- package/dist/lib/workspace.js +183 -0
- package/package.json +45 -4
- package/spec/schemas/brain.schema.json +200 -0
- package/spec/schemas/note.schema.json +1102 -0
- package/spec/schemas/proposal.schema.json +559 -0
- package/templates/README.md +44 -0
- package/templates/brain/AGENTS.md +55 -0
- package/templates/brain/CLAUDE.md +1 -0
- package/templates/brain/_proposals/.gitkeep +0 -0
- package/templates/brain/archive/goals/.gitkeep +0 -0
- package/templates/brain/archive/projects/.gitkeep +0 -0
- package/templates/brain/areas/.gitkeep +0 -0
- package/templates/brain/brain.json +35 -0
- package/templates/brain/gitignore +6 -0
- package/templates/brain/goals/.gitkeep +0 -0
- package/templates/brain/index.md +3 -0
- package/templates/brain/knowledge/concepts/.gitkeep +0 -0
- package/templates/brain/knowledge/decisions/.gitkeep +0 -0
- package/templates/brain/knowledge/meetings/.gitkeep +0 -0
- package/templates/brain/knowledge/orgs/.gitkeep +0 -0
- package/templates/brain/knowledge/people/.gitkeep +0 -0
- package/templates/brain/knowledge/questions/.gitkeep +0 -0
- package/templates/brain/memories/.gitkeep +0 -0
- package/templates/brain/projects/.gitkeep +0 -0
- package/templates/brain/sources/notion/.gitkeep +0 -0
package/dist/lib/time.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BrainError } from "./errors.js";
|
|
2
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
3
|
+
/** The command clock: `--now` when given (ISO 8601 UTC), else the system clock. */
|
|
4
|
+
export function resolveNow(now) {
|
|
5
|
+
if (now === undefined)
|
|
6
|
+
return new Date();
|
|
7
|
+
const parsed = new Date(now);
|
|
8
|
+
// Reject impossible dates such as 2026-02-30, which Date would roll over into March.
|
|
9
|
+
if (!TIMESTAMP.test(now) || Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 19) !== now.slice(0, 19)) {
|
|
10
|
+
throw new BrainError("USAGE", `--now must be an ISO 8601 UTC timestamp like 2026-12-03T10:22:00Z, got "${now}"`);
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
/** `2026-12-03T10:22:00Z` (seconds precision, as the schema's timestamps). */
|
|
15
|
+
export function formatTimestamp(date) {
|
|
16
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
17
|
+
}
|
|
18
|
+
/** `2026-12-03` in UTC. */
|
|
19
|
+
export function formatDate(date) {
|
|
20
|
+
return date.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { compareCodePoints } from "./notes.js";
|
|
3
|
+
const CURRENT_ASSESSMENTS = new Set(["accepted", "provisional", "contested"]);
|
|
4
|
+
/** Render one project view from canonical notes only (spec/schema.md §16). */
|
|
5
|
+
export function deriveView(project, kind, notes) {
|
|
6
|
+
const canonical = notes.filter((note) => note.type !== "view");
|
|
7
|
+
const byId = new Map(canonical.map((note) => [note.id, note]));
|
|
8
|
+
const generated = new Set([project.id]);
|
|
9
|
+
const link = (note) => noteLink(projectViewPath(project, kind), note);
|
|
10
|
+
const title = (note) => text(note.attributes.title);
|
|
11
|
+
const add = (note) => {
|
|
12
|
+
generated.add(note.id);
|
|
13
|
+
return link(note);
|
|
14
|
+
};
|
|
15
|
+
const sections = kind === "brief"
|
|
16
|
+
? briefSections(project, canonical, byId, title, add)
|
|
17
|
+
: decisionLogSections(project, canonical, byId, title, add, link);
|
|
18
|
+
const generatedBlock = ["<!-- @generated:start -->", ...sections.flatMap((section, index) => index === 0 ? section : ["", ...section]), "<!-- @generated:end -->"].join("\n");
|
|
19
|
+
return { kind, generatedBlock, generatedFrom: [...generated].sort(compareCodePoints) };
|
|
20
|
+
}
|
|
21
|
+
/** The standard view filename beside a project's `project.md`. */
|
|
22
|
+
export function projectViewPath(project, kind) {
|
|
23
|
+
return `${posix.dirname(project.path)}/${kind === "brief" ? "brief.md" : "decision-log.md"}`;
|
|
24
|
+
}
|
|
25
|
+
/** Exactly one standalone start/end marker, in order, is required before replacement. */
|
|
26
|
+
export function generatedBlockRange(body) {
|
|
27
|
+
const start = markerPositions(body, "<!-- @generated:start -->");
|
|
28
|
+
const end = markerPositions(body, "<!-- @generated:end -->");
|
|
29
|
+
if (start.length !== 1 || end.length !== 1 || start[0] >= end[0])
|
|
30
|
+
return undefined;
|
|
31
|
+
return { start: start[0], end: end[0] + "<!-- @generated:end -->".length };
|
|
32
|
+
}
|
|
33
|
+
function briefSections(project, notes, byId, title, add) {
|
|
34
|
+
const status = ["## Status"];
|
|
35
|
+
const notionFields = record(project.attributes.notion_fields);
|
|
36
|
+
const statusValue = scalar(notionFields.status);
|
|
37
|
+
const priority = scalar(notionFields.priority);
|
|
38
|
+
const asOf = scalar(notionFields.as_of);
|
|
39
|
+
if (statusValue !== undefined || priority !== undefined) {
|
|
40
|
+
const parts = [statusValue === undefined ? undefined : `Status: ${statusValue}`, priority === undefined ? undefined : `Priority: ${priority}`]
|
|
41
|
+
.filter((value) => value !== undefined);
|
|
42
|
+
const suffix = asOf === undefined ? "" : ` (as of ${asOf.slice(0, 10)})`;
|
|
43
|
+
status.push(`- ${parts.join(" · ")}${suffix} ([project](project.md))`);
|
|
44
|
+
}
|
|
45
|
+
for (const [key, value] of Object.entries(record(project.attributes.facts)).sort(([left], [right]) => compareCodePoints(left, right))) {
|
|
46
|
+
const person = typeof value === "string" ? byId.get(value) : undefined;
|
|
47
|
+
const rendered = person?.type === "person" ? add(person) : String(value);
|
|
48
|
+
status.push(`- ${formatFactKey(key)}: ${rendered} ([project](project.md))`);
|
|
49
|
+
}
|
|
50
|
+
if (status.length === 1)
|
|
51
|
+
status.push("- None");
|
|
52
|
+
const goal = typeof project.attributes.goal === "string" ? byId.get(project.attributes.goal) : undefined;
|
|
53
|
+
const goalSection = ["## Goal", goal?.type === "goal" ? `- ${add(goal)}` : "- None"];
|
|
54
|
+
const people = list(project.attributes.people)
|
|
55
|
+
.map((id) => typeof id === "string" ? byId.get(id) : undefined)
|
|
56
|
+
.filter((note) => note?.type === "person")
|
|
57
|
+
.sort((left, right) => compareCodePoints(title(left), title(right)) || compareCodePoints(left.id, right.id));
|
|
58
|
+
const peopleSection = ["## People", ...(people.length === 0 ? ["- None"] : people.map((person) => {
|
|
59
|
+
const role = scalar(record(person.attributes.facts).role);
|
|
60
|
+
return `- ${add(person)}${role === undefined ? "" : ` — ${role}`}`;
|
|
61
|
+
}))];
|
|
62
|
+
const decisions = notes
|
|
63
|
+
.filter((note) => note.type === "decision" && belongsToProject(note, project) && CURRENT_ASSESSMENTS.has(String(note.attributes.assessment)))
|
|
64
|
+
.sort(sortDescendingBy("date"))
|
|
65
|
+
.slice(0, 5);
|
|
66
|
+
const decisionsSection = ["## Recent decisions", ...(decisions.length === 0 ? ["- None"] : decisions.map((decision) => `- ${text(decision.attributes.date)} — ${add(decision)} (${text(decision.attributes.assessment)})`))];
|
|
67
|
+
const questions = notes
|
|
68
|
+
.filter((note) => note.type === "question" && belongsToProject(note, project))
|
|
69
|
+
.filter((note) => {
|
|
70
|
+
const status = record(note.attributes.notion_fields).status;
|
|
71
|
+
return status === undefined || status === "Open";
|
|
72
|
+
})
|
|
73
|
+
.sort(sortDescendingBy("created"));
|
|
74
|
+
const questionsSection = ["## Open questions", ...(questions.length === 0 ? ["- None"] : questions.map((question) => `- ${add(question)} (${text(question.attributes.kind)})`))];
|
|
75
|
+
const meetings = notes
|
|
76
|
+
.filter((note) => note.type === "meeting" && belongsToProject(note, project))
|
|
77
|
+
.sort(sortDescendingBy("date"))
|
|
78
|
+
.slice(0, 5);
|
|
79
|
+
const meetingsSection = ["## Recent meetings", ...(meetings.length === 0 ? ["- None"] : meetings.map((meeting) => `- ${text(meeting.attributes.date)} — ${add(meeting)}`))];
|
|
80
|
+
return [status, goalSection, peopleSection, decisionsSection, questionsSection, meetingsSection];
|
|
81
|
+
}
|
|
82
|
+
function decisionLogSections(project, notes, byId, _title, add, link) {
|
|
83
|
+
const decisions = notes.filter((note) => note.type === "decision" && belongsToProject(note, project));
|
|
84
|
+
const current = decisions.filter((note) => CURRENT_ASSESSMENTS.has(String(note.attributes.assessment))).sort(sortDescendingBy("date"));
|
|
85
|
+
const superseded = decisions.filter((note) => note.attributes.assessment === "superseded").sort(sortDescendingBy("valid_until"));
|
|
86
|
+
const currentSection = ["## Current", ...(current.length === 0 ? ["- None"] : current.map((decision) => {
|
|
87
|
+
const older = relationTarget(decision, "supersedes", byId);
|
|
88
|
+
return `- ${text(decision.attributes.date)} — ${add(decision)} — ${text(decision.attributes.assessment)}${older === undefined ? "" : ` — supersedes ${link(older)}`}`;
|
|
89
|
+
}))];
|
|
90
|
+
const supersededSection = ["## Superseded", ...(superseded.length === 0 ? ["- None"] : superseded.map((decision) => {
|
|
91
|
+
const replacement = relationTarget(decision, "superseded_by", byId);
|
|
92
|
+
const from = scalar(decision.attributes.valid_from) ?? text(decision.attributes.date);
|
|
93
|
+
const until = text(decision.attributes.valid_until);
|
|
94
|
+
return `- ${from} → ${until} — ${add(decision)} — superseded by ${replacement === undefined ? "(unknown)" : link(replacement)}`;
|
|
95
|
+
}))];
|
|
96
|
+
return [currentSection, supersededSection];
|
|
97
|
+
}
|
|
98
|
+
function relationTarget(note, type, byId) {
|
|
99
|
+
for (const relation of list(note.attributes.relations)) {
|
|
100
|
+
const recordValue = record(relation);
|
|
101
|
+
if (recordValue.type === type && typeof recordValue.target === "string")
|
|
102
|
+
return byId.get(recordValue.target);
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function belongsToProject(note, project) {
|
|
107
|
+
return list(note.attributes.projects).includes(project.id);
|
|
108
|
+
}
|
|
109
|
+
function sortDescendingBy(field) {
|
|
110
|
+
return (left, right) => compareCodePoints(text(right.attributes[field]), text(left.attributes[field])) || compareCodePoints(left.id, right.id);
|
|
111
|
+
}
|
|
112
|
+
function noteLink(fromPath, note) {
|
|
113
|
+
const title = text(note.attributes.title).replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
114
|
+
return `[${title}](${posix.relative(posix.dirname(fromPath), note.path)})`;
|
|
115
|
+
}
|
|
116
|
+
function markerPositions(body, marker) {
|
|
117
|
+
const expression = new RegExp(`(?:^|\\n)${escapeRegExp(marker)}(?=\\n|$)`, "g");
|
|
118
|
+
return [...body.matchAll(expression)].map((match) => match.index + (match[0].startsWith("\n") ? 1 : 0));
|
|
119
|
+
}
|
|
120
|
+
function escapeRegExp(value) {
|
|
121
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
122
|
+
}
|
|
123
|
+
function formatFactKey(key) {
|
|
124
|
+
return key.replaceAll("_", " ").replace(/^./, (first) => first.toUpperCase());
|
|
125
|
+
}
|
|
126
|
+
function text(value) {
|
|
127
|
+
return value === undefined || value === null ? "" : String(value);
|
|
128
|
+
}
|
|
129
|
+
function scalar(value) {
|
|
130
|
+
return typeof value === "string" || typeof value === "number" ? String(value) : undefined;
|
|
131
|
+
}
|
|
132
|
+
function list(value) {
|
|
133
|
+
return Array.isArray(value) ? value : [];
|
|
134
|
+
}
|
|
135
|
+
function record(value) {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
137
|
+
? value
|
|
138
|
+
: {};
|
|
139
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Convert staged lint issues to the warning envelope used by writing commands. */
|
|
2
|
+
export function lintWarnings(issues) {
|
|
3
|
+
return issues.map((issue) => ({
|
|
4
|
+
code: issue.rule.toUpperCase().replaceAll("-", "_"),
|
|
5
|
+
message: `${issue.path}:${issue.line} ${issue.message}`,
|
|
6
|
+
path: issue.path,
|
|
7
|
+
...(issue.id === null ? {} : { id: issue.id }),
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { rmdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
import { BrainError } from "./errors.js";
|
|
5
|
+
import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.js";
|
|
6
|
+
import { atomicCreateFile, atomicWriteFile, ensureDirectory } from "./fs-safe.js";
|
|
7
|
+
import { lintBrain } from "./lint.js";
|
|
8
|
+
import { appendLog } from "./log.js";
|
|
9
|
+
import { compareCodePoints, parseNote, scanNotes } from "./notes.js";
|
|
10
|
+
/**
|
|
11
|
+
* The brain as one command sees it: notes read from disk plus the writes the command has
|
|
12
|
+
* staged. Writes are validated in memory (spec/cli.md §3.6.1) and land together on `commit`,
|
|
13
|
+
* which restores every file it had touched if a write fails (§3.6.3).
|
|
14
|
+
*/
|
|
15
|
+
export class Workspace {
|
|
16
|
+
root;
|
|
17
|
+
config;
|
|
18
|
+
problems;
|
|
19
|
+
notesByPath;
|
|
20
|
+
staged = new Map();
|
|
21
|
+
baseline;
|
|
22
|
+
constructor(brain, notes, problems) {
|
|
23
|
+
this.root = brain.root;
|
|
24
|
+
this.config = brain.config;
|
|
25
|
+
this.problems = problems;
|
|
26
|
+
this.notesByPath = new Map(notes.map((note) => [note.path, note]));
|
|
27
|
+
}
|
|
28
|
+
static async open(brain) {
|
|
29
|
+
const { notes, problems } = await scanNotes(brain.root);
|
|
30
|
+
return new Workspace(brain, notes, problems);
|
|
31
|
+
}
|
|
32
|
+
/** Every note, staged writes included, sorted by path. */
|
|
33
|
+
get notes() {
|
|
34
|
+
return [...this.notesByPath.values()].sort((a, b) => compareCodePoints(a.path, b.path));
|
|
35
|
+
}
|
|
36
|
+
/** The note with this id (the first by path if duplicated). */
|
|
37
|
+
get(id) {
|
|
38
|
+
return this.notes.find((note) => note.id === id);
|
|
39
|
+
}
|
|
40
|
+
ids() {
|
|
41
|
+
return new Set(this.notes.map((note) => note.id));
|
|
42
|
+
}
|
|
43
|
+
/** Whether a file or folder exists at this brain-relative path, on disk or staged. */
|
|
44
|
+
exists(path) {
|
|
45
|
+
if (this.staged.has(path))
|
|
46
|
+
return true;
|
|
47
|
+
for (const staged of this.staged.keys())
|
|
48
|
+
if (staged.startsWith(`${path}/`))
|
|
49
|
+
return true;
|
|
50
|
+
return existsSync(join(this.root, path));
|
|
51
|
+
}
|
|
52
|
+
/** A parsed, editable copy of a note; pass it to `save` after changing it. */
|
|
53
|
+
edit(note) {
|
|
54
|
+
return parseFrontmatter(note.raw);
|
|
55
|
+
}
|
|
56
|
+
save(path, document) {
|
|
57
|
+
this.write(path, serializeFrontmatter(document));
|
|
58
|
+
}
|
|
59
|
+
/** Stage a file. Its content must parse as a note. */
|
|
60
|
+
write(path, content) {
|
|
61
|
+
const parsed = parseNote(path, content);
|
|
62
|
+
if ("code" in parsed)
|
|
63
|
+
throw new Error(`Refusing to stage ${path}: ${parsed.message}`);
|
|
64
|
+
const previous = this.staged.get(path);
|
|
65
|
+
const original = previous !== undefined ? previous.original : this.notesByPath.get(path)?.raw ?? null;
|
|
66
|
+
if (original === content)
|
|
67
|
+
this.staged.delete(path);
|
|
68
|
+
else
|
|
69
|
+
this.staged.set(path, { content, original });
|
|
70
|
+
this.notesByPath.set(path, parsed);
|
|
71
|
+
}
|
|
72
|
+
checkpoint() {
|
|
73
|
+
return { staged: new Map(this.staged), notes: new Map(this.notesByPath) };
|
|
74
|
+
}
|
|
75
|
+
restore(checkpoint) {
|
|
76
|
+
this.staged = new Map(checkpoint.staged);
|
|
77
|
+
this.notesByPath = new Map(checkpoint.notes);
|
|
78
|
+
}
|
|
79
|
+
/** Paths whose content changed since the checkpoint, sorted. */
|
|
80
|
+
changedSince(checkpoint) {
|
|
81
|
+
return [...this.notesByPath.entries()]
|
|
82
|
+
.filter(([path, note]) => checkpoint.notes.get(path)?.raw !== note.raw)
|
|
83
|
+
.map(([path]) => path)
|
|
84
|
+
.sort(compareCodePoints);
|
|
85
|
+
}
|
|
86
|
+
/** Paths with staged writes, sorted. */
|
|
87
|
+
get changedPaths() {
|
|
88
|
+
return [...this.staged.keys()].sort(compareCodePoints);
|
|
89
|
+
}
|
|
90
|
+
isNew(path) {
|
|
91
|
+
return this.staged.get(path)?.original === null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Lint errors that the staged writes introduce, anywhere in the brain (or only in `paths`):
|
|
95
|
+
* errors that were there before the command are not counted, so an old problem never blocks
|
|
96
|
+
* an unrelated change.
|
|
97
|
+
*/
|
|
98
|
+
newErrors(paths) {
|
|
99
|
+
this.baseline ??= new Set(this.issues(this.initialNotes(), new Set()).filter((issue) => issue.severity === "error").map(issueKey));
|
|
100
|
+
const wanted = paths === undefined ? undefined : new Set(paths);
|
|
101
|
+
return this.issues(this.notes, new Set(this.staged.keys()))
|
|
102
|
+
.filter((issue) => issue.severity === "error")
|
|
103
|
+
.filter((issue) => (wanted === undefined || wanted.has(issue.path)) && !this.baseline.has(issueKey(issue)));
|
|
104
|
+
}
|
|
105
|
+
/** Lint warnings for staged files, reported by commands that write those files. */
|
|
106
|
+
warnings(paths = this.staged.keys()) {
|
|
107
|
+
const wanted = new Set(paths);
|
|
108
|
+
return this.issues(this.notes, new Set(this.staged.keys()))
|
|
109
|
+
.filter((issue) => issue.severity === "warning" && wanted.has(issue.path));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write every staged file, then append `log` lines (spec/cli.md §3.9). If anything fails,
|
|
113
|
+
* restore the files and remove the folders this commit created. Refusal BrainErrors such as
|
|
114
|
+
* WRITE_CONFLICT pass through after rollback; unexpected failures become INTERNAL.
|
|
115
|
+
*/
|
|
116
|
+
async commit(log = [], now = new Date()) {
|
|
117
|
+
const done = [];
|
|
118
|
+
const folders = [];
|
|
119
|
+
try {
|
|
120
|
+
for (const path of this.changedPaths) {
|
|
121
|
+
const staged = this.staged.get(path);
|
|
122
|
+
folders.push(...missingFolders(this.root, posix.dirname(path)));
|
|
123
|
+
await ensureDirectory(this.root, posix.dirname(path));
|
|
124
|
+
if (staged.original === null)
|
|
125
|
+
await atomicCreateFile(this.root, path, staged.content);
|
|
126
|
+
else
|
|
127
|
+
await atomicWriteFile(this.root, path, staged.content);
|
|
128
|
+
done.push([path, staged]);
|
|
129
|
+
}
|
|
130
|
+
if (log.length > 0)
|
|
131
|
+
await appendLog(this.root, now, log);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
for (const [path, staged] of done.reverse()) {
|
|
135
|
+
if (staged.original === null)
|
|
136
|
+
await unlink(join(this.root, path)).catch(() => undefined);
|
|
137
|
+
else
|
|
138
|
+
await atomicWriteFile(this.root, path, staged.original).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
for (const folder of folders.reverse())
|
|
141
|
+
await rmdir(join(this.root, folder)).catch(() => undefined);
|
|
142
|
+
if (error instanceof BrainError && error.code !== "INTERNAL")
|
|
143
|
+
throw error;
|
|
144
|
+
throw new BrainError("INTERNAL", `Write failed; restored the files already written: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
}
|
|
146
|
+
const created = done.filter(([, staged]) => staged.original === null).map(([path]) => path);
|
|
147
|
+
const changed = done.filter(([, staged]) => staged.original !== null).map(([path]) => path);
|
|
148
|
+
this.staged.clear();
|
|
149
|
+
return { created, changed };
|
|
150
|
+
}
|
|
151
|
+
initialNotes() {
|
|
152
|
+
const notes = [];
|
|
153
|
+
for (const note of this.notesByPath.values()) {
|
|
154
|
+
const staged = this.staged.get(note.path);
|
|
155
|
+
if (staged === undefined)
|
|
156
|
+
notes.push(note);
|
|
157
|
+
else if (staged.original !== null)
|
|
158
|
+
notes.push(parseNote(note.path, staged.original));
|
|
159
|
+
}
|
|
160
|
+
return notes.sort((a, b) => compareCodePoints(a.path, b.path));
|
|
161
|
+
}
|
|
162
|
+
issues(notes, pendingPaths) {
|
|
163
|
+
return lintBrain({
|
|
164
|
+
root: this.root,
|
|
165
|
+
notes,
|
|
166
|
+
problems: this.problems,
|
|
167
|
+
brainConfig: this.config,
|
|
168
|
+
indexMarkdown: undefined,
|
|
169
|
+
pendingPaths,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Folders on the way to `folder` that do not exist yet, outermost first. */
|
|
174
|
+
function missingFolders(root, folder) {
|
|
175
|
+
const missing = [];
|
|
176
|
+
for (let current = folder; current !== "." && current !== "" && !existsSync(join(root, current)); current = posix.dirname(current)) {
|
|
177
|
+
missing.unshift(current);
|
|
178
|
+
}
|
|
179
|
+
return missing;
|
|
180
|
+
}
|
|
181
|
+
function issueKey(issue) {
|
|
182
|
+
return `${issue.path}\u0000${issue.rule}\u0000${issue.message}`;
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultimate-brain",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI for an AI-assisted Markdown and Git second brain",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"second-brain",
|
|
8
|
+
"knowledge-management",
|
|
9
|
+
"markdown",
|
|
10
|
+
"notion",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"codex",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"ultimate-brain": "bin/brain.js",
|
|
18
|
+
"brain": "bin/brain.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"dist",
|
|
23
|
+
"spec/schemas",
|
|
24
|
+
"templates/brain",
|
|
25
|
+
".agents/skills",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"test:package": "node scripts/test-package.mjs",
|
|
33
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.6"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"ajv": "^8.17.1",
|
|
41
|
+
"yaml": "^2.9.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"typescript": "^5.9.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://ultimate-brain.dev/schemas/v1/brain.schema.json",
|
|
4
|
+
"title": "brain.json (v1)",
|
|
5
|
+
"description": "Per-brain configuration. See spec/schema.md §12 and decisions 0014, 0018.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"owner",
|
|
10
|
+
"budgets",
|
|
11
|
+
"notion"
|
|
12
|
+
],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"schema_version": {
|
|
16
|
+
"const": 1
|
|
17
|
+
},
|
|
18
|
+
"owner": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": [
|
|
21
|
+
"name"
|
|
22
|
+
],
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"properties": {
|
|
25
|
+
"name": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"budgets": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"properties": {
|
|
35
|
+
"ingest_max_notes": {
|
|
36
|
+
"type": "integer",
|
|
37
|
+
"minimum": 1,
|
|
38
|
+
"maximum": 20,
|
|
39
|
+
"default": 8
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"notion": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"required": [
|
|
46
|
+
"databases",
|
|
47
|
+
"tasks"
|
|
48
|
+
],
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"properties": {
|
|
51
|
+
"databases": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"properties": {
|
|
55
|
+
"areas": {
|
|
56
|
+
"$ref": "#/$defs/dbId"
|
|
57
|
+
},
|
|
58
|
+
"goals": {
|
|
59
|
+
"$ref": "#/$defs/dbId"
|
|
60
|
+
},
|
|
61
|
+
"key_results": {
|
|
62
|
+
"$ref": "#/$defs/dbId"
|
|
63
|
+
},
|
|
64
|
+
"projects": {
|
|
65
|
+
"$ref": "#/$defs/dbId"
|
|
66
|
+
},
|
|
67
|
+
"meetings": {
|
|
68
|
+
"$ref": "#/$defs/dbId"
|
|
69
|
+
},
|
|
70
|
+
"people": {
|
|
71
|
+
"$ref": "#/$defs/dbId"
|
|
72
|
+
},
|
|
73
|
+
"orgs": {
|
|
74
|
+
"$ref": "#/$defs/dbId"
|
|
75
|
+
},
|
|
76
|
+
"decisions": {
|
|
77
|
+
"$ref": "#/$defs/dbId"
|
|
78
|
+
},
|
|
79
|
+
"questions": {
|
|
80
|
+
"$ref": "#/$defs/dbId"
|
|
81
|
+
},
|
|
82
|
+
"review": {
|
|
83
|
+
"$ref": "#/$defs/dbId"
|
|
84
|
+
},
|
|
85
|
+
"capture_inbox": {
|
|
86
|
+
"$ref": "#/$defs/dbId"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"tasks": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"required": [
|
|
93
|
+
"database",
|
|
94
|
+
"properties",
|
|
95
|
+
"new_task_status"
|
|
96
|
+
],
|
|
97
|
+
"additionalProperties": false,
|
|
98
|
+
"properties": {
|
|
99
|
+
"database": {
|
|
100
|
+
"$ref": "#/$defs/dbId"
|
|
101
|
+
},
|
|
102
|
+
"properties": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"required": [
|
|
105
|
+
"title"
|
|
106
|
+
],
|
|
107
|
+
"additionalProperties": false,
|
|
108
|
+
"properties": {
|
|
109
|
+
"title": {
|
|
110
|
+
"type": "string"
|
|
111
|
+
},
|
|
112
|
+
"status": {
|
|
113
|
+
"type": "string"
|
|
114
|
+
},
|
|
115
|
+
"due": {
|
|
116
|
+
"type": "string"
|
|
117
|
+
},
|
|
118
|
+
"project": {
|
|
119
|
+
"type": "string"
|
|
120
|
+
},
|
|
121
|
+
"area": {
|
|
122
|
+
"type": "string"
|
|
123
|
+
},
|
|
124
|
+
"owner": {
|
|
125
|
+
"type": "string"
|
|
126
|
+
},
|
|
127
|
+
"meeting": {
|
|
128
|
+
"type": "string"
|
|
129
|
+
},
|
|
130
|
+
"meeting_url": {
|
|
131
|
+
"type": "string"
|
|
132
|
+
},
|
|
133
|
+
"brain_id": {
|
|
134
|
+
"type": "string"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"new_task_status": {
|
|
139
|
+
"type": "string"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"allOf": [
|
|
143
|
+
{
|
|
144
|
+
"if": {
|
|
145
|
+
"properties": {
|
|
146
|
+
"database": {
|
|
147
|
+
"minLength": 1
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"required": [
|
|
151
|
+
"database"
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
"then": {
|
|
155
|
+
"properties": {
|
|
156
|
+
"properties": {
|
|
157
|
+
"properties": {
|
|
158
|
+
"title": {
|
|
159
|
+
"minLength": 1
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"anyOf": [
|
|
163
|
+
{
|
|
164
|
+
"properties": {
|
|
165
|
+
"meeting": {
|
|
166
|
+
"minLength": 1
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"required": [
|
|
170
|
+
"meeting"
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"properties": {
|
|
175
|
+
"meeting_url": {
|
|
176
|
+
"minLength": 1
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"required": [
|
|
180
|
+
"meeting_url"
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"$defs": {
|
|
194
|
+
"dbId": {
|
|
195
|
+
"description": "Notion database id; empty string until `brain connect-notion` fills it.",
|
|
196
|
+
"type": "string",
|
|
197
|
+
"pattern": "^$|^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|