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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Sync input formats
|
|
2
|
+
|
|
3
|
+
A copy of the inputs in spec/cli.md §5.14, so the skill works from inside a brain where the product spec is not readable. If they ever disagree, the spec wins; report the difference.
|
|
4
|
+
|
|
5
|
+
## Snapshot (input to `brain sync plan`)
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"as_of": "2026-12-05T09:00:00Z",
|
|
10
|
+
"rows": [
|
|
11
|
+
{
|
|
12
|
+
"database": "people",
|
|
13
|
+
"page_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
|
|
14
|
+
"last_edited": "2026-12-05T08:40:00Z",
|
|
15
|
+
"properties": {
|
|
16
|
+
"Name": "Maria López", "Summary": "Product lead at Acme.", "Role": "Product lead",
|
|
17
|
+
"Email": "maria@acme.example", "Lifecycle": "Reviewed",
|
|
18
|
+
"Brain ID": "per-3h8w2n6c", "Org": ["9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `database`: the `brain.json` key: `areas`, `goals`, `key_results`, `projects`, `people`, `orgs`, `decisions`, `questions` or `review`.
|
|
26
|
+
- `properties`: the Notion property names exactly, with normalized values:
|
|
27
|
+
|
|
28
|
+
| Notion type | Normalized value |
|
|
29
|
+
|---|---|
|
|
30
|
+
| title, text, select, status, url, email | string; empty → `null` |
|
|
31
|
+
| number | number or `null` |
|
|
32
|
+
| checkbox | boolean |
|
|
33
|
+
| date | `{"start": "<date or timestamp>", "end": "<…>" or null}` or `null` |
|
|
34
|
+
| relation | array of page ids, sorted |
|
|
35
|
+
| formula, rollup | their scalar result |
|
|
36
|
+
- A property you did not read is left out, and is then not compared.
|
|
37
|
+
|
|
38
|
+
## Plan (input to `brain sync apply` and `brain sync render --plan`)
|
|
39
|
+
|
|
40
|
+
The `data` object of the `sync plan` reply, unchanged. Not the whole reply.
|
|
41
|
+
|
|
42
|
+
## Record (input to `brain sync record`)
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{ "records": [
|
|
46
|
+
{ "id": "per-3h8w2n6c", "notion": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
|
|
47
|
+
"synced_at": "2026-12-05T09:01:10Z", "published": { "Name": "Maria López", "Brain ID": "per-3h8w2n6c" } }
|
|
48
|
+
] }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- One record per page actually written. `synced_at` is required.
|
|
52
|
+
- `published`: the render item's `published`, unchanged.
|
|
53
|
+
- `tasks` (meetings only): the new task page ids.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodrigo Cano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ultimate Brain
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An AI-assisted second brain for solo knowledge workers. Knowledge lives in Markdown + Git, is maintained by coding agents (Claude Code, Codex), and is reviewed and navigated in Notion.
|
|
4
|
+
|
|
5
|
+
This repository is the **product**: CLI, agent skills, schema, templates and the Notion space design. Each user's brain lives in its own private repository.
|
|
6
|
+
|
|
7
|
+
**Status:** v1, in use by its author. Expect changes.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Needs Node 22.6 or later.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g ultimate-brain
|
|
15
|
+
brain init ~/brain --set-default # a new brain repository
|
|
16
|
+
brain --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The agent skills come with it: `brain init` copies them into the brain for Codex, and Claude Code gets them as the `ultimate-brain` plugin.
|
|
20
|
+
|
|
21
|
+
## Update
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g ultimate-brain@latest
|
|
25
|
+
cd ~/brain && brain upgrade && git commit -am "upgrade: ultimate brain"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`brain upgrade` refreshes the brain's product-owned rules and skills. It never touches your notes.
|
|
29
|
+
|
|
30
|
+
## Start here
|
|
31
|
+
|
|
32
|
+
| Document | What it is |
|
|
33
|
+
|---|---|
|
|
34
|
+
| [docs/vision.md](docs/vision.md) | Product intent, v1 scope and success criterion |
|
|
35
|
+
| [synthesis/synthesis.md](synthesis/synthesis.md) | The v1 design: schema, meeting-ingest workflow, Notion bridge |
|
|
36
|
+
| [docs/notion-space-design.md](docs/notion-space-design.md) | Notion databases, views, and the Notion AI prompts to build them |
|
|
37
|
+
| [decisions/](decisions/) | Every accepted architecture decision |
|
|
38
|
+
| [build/TASKS.md](build/TASKS.md) | Build plan split between Claude Code and Codex |
|
|
39
|
+
| [reviews/](reviews/) | Research: reviews of five open-source second-brain repos |
|
|
40
|
+
|
|
41
|
+
Agent rules live in [AGENTS.md](AGENTS.md); `CLAUDE.md` imports it.
|
package/bin/brain.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { applyCommand } from "./commands/apply.js";
|
|
5
|
+
import { indexCommand } from "./commands/index.js";
|
|
6
|
+
import { initCommand } from "./commands/init.js";
|
|
7
|
+
import { statusCommand } from "./commands/status.js";
|
|
8
|
+
import { configGetCommand, configSetCommand } from "./commands/config.js";
|
|
9
|
+
import { idNewCommand } from "./commands/id.js";
|
|
10
|
+
import { lintCommand } from "./commands/lint.js";
|
|
11
|
+
import { logCommand } from "./commands/log.js";
|
|
12
|
+
import { noteNewCommand } from "./commands/note.js";
|
|
13
|
+
import { linkCommand } from "./commands/link.js";
|
|
14
|
+
import { upgradeCommand } from "./commands/upgrade.js";
|
|
15
|
+
import { proposalListCommand, proposalNewCommand } from "./commands/proposal.js";
|
|
16
|
+
import { queryCommand } from "./commands/query.js";
|
|
17
|
+
import { syncApplyCommand, syncPlanCommand, syncRecordCommand, syncRenderCommand, syncTasksCommand } from "./commands/sync.js";
|
|
18
|
+
import { viewsCommand } from "./commands/views.js";
|
|
19
|
+
import { runCommand } from "./lib/command.js";
|
|
20
|
+
import { exitCodeFor } from "./lib/errors.js";
|
|
21
|
+
/** Registered commands, in help order. */
|
|
22
|
+
export const COMMANDS = [
|
|
23
|
+
initCommand, statusCommand, configGetCommand, configSetCommand, idNewCommand, indexCommand, lintCommand, logCommand, noteNewCommand, proposalNewCommand, proposalListCommand, applyCommand,
|
|
24
|
+
syncPlanCommand, syncApplyCommand, syncRenderCommand, syncTasksCommand, syncRecordCommand, viewsCommand, linkCommand, queryCommand, upgradeCommand,
|
|
25
|
+
];
|
|
26
|
+
export const HELP_TEXT = `Usage: brain <command> [options]
|
|
27
|
+
|
|
28
|
+
Operate an Ultimate Brain repository. Contract: spec/cli.md.
|
|
29
|
+
|
|
30
|
+
Global options:
|
|
31
|
+
--brain <path> Brain directory (else BRAIN_DIR, else ~/.config/ultimate-brain/config.json)
|
|
32
|
+
--json Print one JSON document on stdout
|
|
33
|
+
--now <time> Use this ISO 8601 UTC time as "now"
|
|
34
|
+
-h, --help Show help
|
|
35
|
+
|
|
36
|
+
Commands:
|
|
37
|
+
${COMMANDS.map((command) => ` ${command.name.padEnd(16)}${command.summary.split(". ")[0].replace(/\.$/, "")}`).join("\n")}
|
|
38
|
+
`;
|
|
39
|
+
const defaultIo = {
|
|
40
|
+
stdout: process.stdout,
|
|
41
|
+
stderr: process.stderr,
|
|
42
|
+
};
|
|
43
|
+
export async function runCli(args, options = {}) {
|
|
44
|
+
const io = options.io ?? defaultIo;
|
|
45
|
+
const [first] = args;
|
|
46
|
+
if (first === undefined || first === "help" || first === "--help" || first === "-h") {
|
|
47
|
+
io.stdout.write(HELP_TEXT);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
const handlers = new Map();
|
|
51
|
+
for (const command of COMMANDS) {
|
|
52
|
+
handlers.set(command.name, (rest, commandIo) => runCommand(command, rest, commandIo, options));
|
|
53
|
+
}
|
|
54
|
+
for (const [name, handler] of options.commands ?? [])
|
|
55
|
+
handlers.set(name, handler);
|
|
56
|
+
// Longest command path first, so "note new" wins over "note".
|
|
57
|
+
for (const words of [2, 1]) {
|
|
58
|
+
const name = args.slice(0, words).join(" ");
|
|
59
|
+
const handler = args.length >= words ? handlers.get(name) : undefined;
|
|
60
|
+
if (handler !== undefined)
|
|
61
|
+
return handler(args.slice(words), io);
|
|
62
|
+
}
|
|
63
|
+
const message = `Unknown command: ${first}`;
|
|
64
|
+
if (args.includes("--json")) {
|
|
65
|
+
io.stdout.write(`${JSON.stringify({ ok: false, command: first, brain: null, error: { code: "USAGE", message } })}\n`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
io.stderr.write(`${message}\nRun "brain --help" for usage.\n`);
|
|
69
|
+
}
|
|
70
|
+
return exitCodeFor("USAGE");
|
|
71
|
+
}
|
|
72
|
+
const entryPoint = process.argv[1];
|
|
73
|
+
if (entryPoint !== undefined && isEntryPoint(entryPoint)) {
|
|
74
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
75
|
+
}
|
|
76
|
+
function isEntryPoint(entryPoint) {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(entryPoint) === fileURLToPath(import.meta.url);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { applyAll, parseApplyInput } from "../lib/apply.js";
|
|
2
|
+
import { readJsonInput } from "../lib/input.js";
|
|
3
|
+
import { Workspace } from "../lib/workspace.js";
|
|
4
|
+
import { lintWarnings } from "../lib/warnings.js";
|
|
5
|
+
/** `brain apply [--input <file|->] [--record-only] [--dry-run]` (spec/cli.md §5.7). */
|
|
6
|
+
export const applyCommand = {
|
|
7
|
+
name: "apply",
|
|
8
|
+
usage: "brain apply [--input <file|->] [--record-only] [--dry-run] [--json]",
|
|
9
|
+
summary: "Apply decided proposals and Notion corrections. Atomic per item; exits 1 if any item failed.",
|
|
10
|
+
options: { input: "string", "record-only": "boolean", "dry-run": "boolean" },
|
|
11
|
+
maxPositionals: 0,
|
|
12
|
+
needsBrain: true,
|
|
13
|
+
async run({ args, brain, now }) {
|
|
14
|
+
const path = args.options.input;
|
|
15
|
+
const input = path === undefined ? { corrections: [], decisions: null } : parseApplyInput(await readJsonInput(path, "apply"));
|
|
16
|
+
const dryRun = args.options["dry-run"] === true;
|
|
17
|
+
const workspace = await Workspace.open(brain);
|
|
18
|
+
const result = applyAll(workspace, input, { now, recordOnly: args.options["record-only"] === true });
|
|
19
|
+
const warnings = lintWarnings(workspace.warnings());
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
await workspace.commit(result.log, now);
|
|
22
|
+
}
|
|
23
|
+
const lines = [
|
|
24
|
+
...result.corrections.map((item) => `${item.result} correction ${item.target} ${item.field}${item.reason === undefined ? "" : ` (${item.reason})`}`),
|
|
25
|
+
...result.decisions.map((item) => `${item.result} ${item.proposal} ${item.action}${item.reason === undefined ? "" : ` (${item.reason})`}`),
|
|
26
|
+
];
|
|
27
|
+
return {
|
|
28
|
+
data: { dry_run: dryRun, corrections: result.corrections, decisions: result.decisions, summary: result.summary },
|
|
29
|
+
warnings,
|
|
30
|
+
exitCode: result.summary.failed > 0 ? 1 : 0,
|
|
31
|
+
human: lines.length === 0 ? "Nothing to apply.\n" : `${lines.join("\n")}\n`,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { BrainError } from "../lib/errors.js";
|
|
2
|
+
import { atomicWriteFile } from "../lib/fs-safe.js";
|
|
3
|
+
import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
4
|
+
/** `brain config get [<dot.path>]` (spec/cli.md §5.3). */
|
|
5
|
+
export const configGetCommand = {
|
|
6
|
+
name: "config get",
|
|
7
|
+
usage: "brain config get [<dot.path>] [--json]",
|
|
8
|
+
summary: "Read brain.json, or one value selected by a dot path.",
|
|
9
|
+
options: {},
|
|
10
|
+
maxPositionals: 1,
|
|
11
|
+
needsBrain: true,
|
|
12
|
+
async run({ args, brain }) {
|
|
13
|
+
const [path] = args.positionals;
|
|
14
|
+
const value = path === undefined ? brain.config : readPath(brain.config, path);
|
|
15
|
+
return { data: path === undefined ? value : { path, value }, human: `${JSON.stringify(value, null, 2)}\n` };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
/** `brain config set <dot.path> <json-value>` (spec/cli.md §5.3). */
|
|
19
|
+
export const configSetCommand = {
|
|
20
|
+
name: "config set",
|
|
21
|
+
usage: "brain config set <dot.path> <json-value> [--json]",
|
|
22
|
+
summary: "Set one brain.json value after validating the complete configuration.",
|
|
23
|
+
options: {},
|
|
24
|
+
maxPositionals: 2,
|
|
25
|
+
needsBrain: true,
|
|
26
|
+
async run({ args, brain }) {
|
|
27
|
+
const [path, rawValue] = args.positionals;
|
|
28
|
+
if (path === undefined || rawValue === undefined) {
|
|
29
|
+
throw new BrainError("USAGE", "brain config set needs <dot.path> and <json-value>");
|
|
30
|
+
}
|
|
31
|
+
const segments = parsePath(path);
|
|
32
|
+
if (segments.length === 1 && segments[0] === "schema_version") {
|
|
33
|
+
throw new BrainError("INPUT_INVALID", "schema_version cannot be changed");
|
|
34
|
+
}
|
|
35
|
+
let value;
|
|
36
|
+
try {
|
|
37
|
+
value = JSON.parse(rawValue);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new BrainError("INPUT_INVALID", `Config value is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
const next = structuredClone(brain.config);
|
|
43
|
+
const previous = setPath(next, segments, value);
|
|
44
|
+
const schemas = loadSchemas();
|
|
45
|
+
if (!schemas.brain(next)) {
|
|
46
|
+
throw new BrainError("INPUT_INVALID", `brain.json would be invalid: ${describeErrors(schemas.brain.errors).join("; ")}`);
|
|
47
|
+
}
|
|
48
|
+
await atomicWriteFile(brain.root, "brain.json", `${JSON.stringify(next, null, 2)}\n`);
|
|
49
|
+
return {
|
|
50
|
+
data: { path, previous: previous ?? null, value },
|
|
51
|
+
human: `${path}: ${JSON.stringify(previous)} → ${JSON.stringify(value)}\n`,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function readPath(config, path) {
|
|
56
|
+
const segments = parsePath(path);
|
|
57
|
+
let current = config;
|
|
58
|
+
for (const segment of segments) {
|
|
59
|
+
if (!isRecord(current) || !(segment in current)) {
|
|
60
|
+
throw new BrainError("USAGE", `No configuration value at ${path}`);
|
|
61
|
+
}
|
|
62
|
+
current = current[segment];
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
function setPath(config, segments, value) {
|
|
67
|
+
let current = config;
|
|
68
|
+
for (const segment of segments.slice(0, -1)) {
|
|
69
|
+
const next = current[segment];
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
const created = {};
|
|
72
|
+
current[segment] = created;
|
|
73
|
+
current = created;
|
|
74
|
+
}
|
|
75
|
+
else if (isRecord(next)) {
|
|
76
|
+
current = next;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
throw new BrainError("INPUT_INVALID", `Cannot create ${segments.join(".")}: ${segment} is not an object`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const final = segments[segments.length - 1];
|
|
83
|
+
const previous = current[final];
|
|
84
|
+
current[final] = value;
|
|
85
|
+
return previous;
|
|
86
|
+
}
|
|
87
|
+
function parsePath(path) {
|
|
88
|
+
const segments = path.split(".");
|
|
89
|
+
if (path === "" || segments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment))) {
|
|
90
|
+
throw new BrainError("USAGE", `Invalid configuration path: ${path}`);
|
|
91
|
+
}
|
|
92
|
+
return segments;
|
|
93
|
+
}
|
|
94
|
+
function isRecord(value) {
|
|
95
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BrainError } from "../lib/errors.js";
|
|
2
|
+
import { generateId, TYPE_PREFIX } from "../lib/ids.js";
|
|
3
|
+
import { Workspace } from "../lib/workspace.js";
|
|
4
|
+
/** `brain id new <type> [--count <n>]` (spec/cli.md §5.4). */
|
|
5
|
+
export const idNewCommand = {
|
|
6
|
+
name: "id new",
|
|
7
|
+
usage: "brain id new <type> [--count <n>] [--json]",
|
|
8
|
+
summary: "Generate unused, prefixed note IDs. IDs are not reserved.",
|
|
9
|
+
options: { count: "string" },
|
|
10
|
+
maxPositionals: 1,
|
|
11
|
+
needsBrain: true,
|
|
12
|
+
async run({ args, brain }) {
|
|
13
|
+
const [type] = args.positionals;
|
|
14
|
+
const prefix = type === undefined ? undefined : TYPE_PREFIX[type];
|
|
15
|
+
if (prefix === undefined)
|
|
16
|
+
throw new BrainError("USAGE", `Unknown note type: ${type ?? "(missing)"}`);
|
|
17
|
+
const rawCount = args.options.count;
|
|
18
|
+
const count = rawCount === undefined ? 1 : Number(rawCount);
|
|
19
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
20
|
+
throw new BrainError("USAGE", "--count must be an integer from 1 through 100");
|
|
21
|
+
}
|
|
22
|
+
const workspace = await Workspace.open(brain);
|
|
23
|
+
const existing = workspace.ids();
|
|
24
|
+
const ids = [];
|
|
25
|
+
for (let index = 0; index < count; index += 1) {
|
|
26
|
+
const id = generateId(prefix, existing);
|
|
27
|
+
existing.add(id);
|
|
28
|
+
ids.push(id);
|
|
29
|
+
}
|
|
30
|
+
return { data: { type, ids }, human: `${ids.join("\n")}\n` };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -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
|
+
}
|