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.
- 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-ingest/SKILL.md +104 -0
- package/.agents/skills/brain-ingest/references/cli-inputs.md +106 -0
- package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
- package/.agents/skills/brain-ingest/references/extraction.md +103 -0
- package/.agents/skills/brain-ingest/references/notion.md +61 -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 +64 -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 +336 -0
- package/dist/lib/log.js +44 -0
- package/dist/lib/note-new.js +119 -0
- package/dist/lib/notes.js +95 -0
- package/dist/lib/paths.js +49 -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 +714 -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 +1059 -0
- package/spec/schemas/proposal.schema.json +559 -0
- package/templates/README.md +44 -0
- package/templates/brain/AGENTS.md +54 -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,36 @@
|
|
|
1
|
+
import { assertNoProblems, isRecord, readJsonInput, unknownKeys } from "../lib/input.js";
|
|
2
|
+
import { assertStagedValid, checkNoteInput, stageNotes } from "../lib/note-new.js";
|
|
3
|
+
import { Workspace } from "../lib/workspace.js";
|
|
4
|
+
import { lintWarnings } from "../lib/warnings.js";
|
|
5
|
+
/** `brain note new --input <file|-> [--dry-run]` (spec/cli.md §5.5). */
|
|
6
|
+
export const noteNewCommand = {
|
|
7
|
+
name: "note new",
|
|
8
|
+
usage: "brain note new --input <file|-> [--dry-run] [--json]",
|
|
9
|
+
summary: "Write new draft notes from JSON input. All or nothing; never changes an existing note.",
|
|
10
|
+
options: { input: "string", "dry-run": "boolean" },
|
|
11
|
+
maxPositionals: 0,
|
|
12
|
+
needsBrain: true,
|
|
13
|
+
async run({ args, brain, now }) {
|
|
14
|
+
const input = await readJsonInput(args.options.input, "note new");
|
|
15
|
+
if (!isRecord(input))
|
|
16
|
+
assertNoProblems([{ pointer: "", message: "must be an object with a notes list" }]);
|
|
17
|
+
const record = input;
|
|
18
|
+
const problems = unknownKeys(record, ["notes"], "");
|
|
19
|
+
if (!Array.isArray(record.notes))
|
|
20
|
+
problems.push({ pointer: "/notes", message: "must be a list" });
|
|
21
|
+
else
|
|
22
|
+
record.notes.forEach((item, index) => problems.push(...checkNoteInput(item, `/notes/${index}`)));
|
|
23
|
+
assertNoProblems(problems);
|
|
24
|
+
const dryRun = args.options["dry-run"] === true;
|
|
25
|
+
const workspace = await Workspace.open(brain);
|
|
26
|
+
const staged = stageNotes(workspace, record.notes, (index) => `/notes/${index}`, { now });
|
|
27
|
+
assertStagedValid(workspace, staged.pointers, "/notes");
|
|
28
|
+
const warnings = lintWarnings(workspace.warnings());
|
|
29
|
+
if (!dryRun) {
|
|
30
|
+
await workspace.commit(staged.created.map((note) => ({ op: "note-new", id: note.id, title: note.title })), now);
|
|
31
|
+
}
|
|
32
|
+
const human = staged.created.map((note) => `${dryRun ? "would create" : "created"} ${note.path}\n`).join("")
|
|
33
|
+
+ staged.skipped.map((skip) => `skipped notes[${skip.input_index}]: same content as ${skip.existing}\n`).join("");
|
|
34
|
+
return { data: { created: staged.created, skipped: staged.skipped, dry_run: dryRun }, warnings, human: human || "Nothing to write.\n" };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BrainError } from "../lib/errors.js";
|
|
2
|
+
import { assertNoProblems, isRecord, readJsonInput, unknownKeys } from "../lib/input.js";
|
|
3
|
+
import { listProposals, PROPOSAL_STATUSES, stageProposals } from "../lib/proposals.js";
|
|
4
|
+
import { Workspace } from "../lib/workspace.js";
|
|
5
|
+
import { lintWarnings } from "../lib/warnings.js";
|
|
6
|
+
/** `brain proposal new --input <file|-> [--dry-run]` (spec/cli.md §5.6). */
|
|
7
|
+
export const proposalNewCommand = {
|
|
8
|
+
name: "proposal new",
|
|
9
|
+
usage: "brain proposal new --input <file|-> [--dry-run] [--json]",
|
|
10
|
+
summary: "Write pending proposals for changes to existing knowledge. All or nothing; skips duplicates of pending proposals.",
|
|
11
|
+
options: { input: "string", "dry-run": "boolean" },
|
|
12
|
+
maxPositionals: 0,
|
|
13
|
+
needsBrain: true,
|
|
14
|
+
async run({ args, brain, now }) {
|
|
15
|
+
const input = await readJsonInput(args.options.input, "proposal new");
|
|
16
|
+
if (!isRecord(input))
|
|
17
|
+
assertNoProblems([{ pointer: "", message: "must be an object with a proposals list" }]);
|
|
18
|
+
const record = input;
|
|
19
|
+
assertNoProblems(unknownKeys(record, ["proposals"], ""));
|
|
20
|
+
const dryRun = args.options["dry-run"] === true;
|
|
21
|
+
const workspace = await Workspace.open(brain);
|
|
22
|
+
const { created, skipped } = stageProposals(workspace, record.proposals, now);
|
|
23
|
+
const warnings = lintWarnings(workspace.warnings());
|
|
24
|
+
if (!dryRun) {
|
|
25
|
+
await workspace.commit(created.map((proposal) => ({
|
|
26
|
+
op: "proposal-new",
|
|
27
|
+
id: proposal.id,
|
|
28
|
+
title: proposal.title,
|
|
29
|
+
pairs: proposal.target === undefined ? [["kind", proposal.kind]] : [["kind", proposal.kind], ["target", proposal.target]],
|
|
30
|
+
})), now);
|
|
31
|
+
}
|
|
32
|
+
const human = created.map((proposal) => `${dryRun ? "would create" : "created"} ${proposal.path}\n`).join("")
|
|
33
|
+
+ skipped.map((skip) => `skipped proposals[${skip.input_index}]: same as pending ${skip.existing}\n`).join("");
|
|
34
|
+
return { data: { created, skipped, dry_run: dryRun }, warnings, human: human || "Nothing to write.\n" };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** `brain proposal list [--status <s>[,<s>…]] [--target <id>]` (spec/cli.md §5.6). */
|
|
38
|
+
export const proposalListCommand = {
|
|
39
|
+
name: "proposal list",
|
|
40
|
+
usage: "brain proposal list [--status <s>[,<s>…]] [--target <id>] [--json]",
|
|
41
|
+
summary: "List proposals, filtered by status and target, sorted by created then id.",
|
|
42
|
+
options: { status: "string", target: "string" },
|
|
43
|
+
maxPositionals: 0,
|
|
44
|
+
needsBrain: true,
|
|
45
|
+
async run({ args, brain }) {
|
|
46
|
+
const statuses = typeof args.options.status === "string" ? args.options.status.split(",").map((status) => status.trim()).filter((status) => status !== "") : [];
|
|
47
|
+
const unknown = statuses.filter((status) => !PROPOSAL_STATUSES.includes(status));
|
|
48
|
+
if (unknown.length > 0)
|
|
49
|
+
throw new BrainError("USAGE", `Unknown status ${unknown.join(", ")}; use ${PROPOSAL_STATUSES.join(", ")}`);
|
|
50
|
+
const workspace = await Workspace.open(brain);
|
|
51
|
+
const proposals = listProposals(workspace, statuses, args.options.target);
|
|
52
|
+
const human = proposals.length === 0
|
|
53
|
+
? "No proposals.\n"
|
|
54
|
+
: proposals.map((proposal) => `${String(proposal.id)} ${String(proposal.status)} ${String(proposal.kind)} ${String(proposal.title)}\n`).join("");
|
|
55
|
+
return { data: { proposals }, human };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BrainError } from "../lib/errors.js";
|
|
2
|
+
import { scanNotes } from "../lib/notes.js";
|
|
3
|
+
import { queryContext, queryTypes, queryWords, rankCandidates } from "../lib/query.js";
|
|
4
|
+
/** `brain query --candidates <question>` (spec/cli.md §5.13). */
|
|
5
|
+
export const queryCommand = {
|
|
6
|
+
name: "query",
|
|
7
|
+
usage: "brain query --candidates <question> [--limit <n>] [--types <t>[,<t>…]] [--repo <path>] [--json]",
|
|
8
|
+
summary: "Return ranked note candidates for the brain-query skill.",
|
|
9
|
+
options: { candidates: "string", limit: "string", types: "string", repo: "string" },
|
|
10
|
+
maxPositionals: 0,
|
|
11
|
+
needsBrain: true,
|
|
12
|
+
async run({ args, brain }) {
|
|
13
|
+
const question = args.options.candidates;
|
|
14
|
+
if (question === undefined) {
|
|
15
|
+
throw new BrainError("USAGE", "brain query requires --candidates; use the brain-query skill to answer questions");
|
|
16
|
+
}
|
|
17
|
+
const limit = parseLimit(args.options.limit);
|
|
18
|
+
const tokens = queryWords(question);
|
|
19
|
+
const [context, scanned] = await Promise.all([
|
|
20
|
+
queryContext(args.options.repo),
|
|
21
|
+
scanNotes(brain.root),
|
|
22
|
+
]);
|
|
23
|
+
const candidates = rankCandidates(scanned.notes, tokens, queryTypes(args.options.types), context).slice(0, limit);
|
|
24
|
+
return {
|
|
25
|
+
data: { query: question, tokens, context, candidates },
|
|
26
|
+
warnings: scanned.problems.map((problem) => ({ code: problem.code, message: `${problem.path}: ${problem.message}`, path: problem.path })),
|
|
27
|
+
human: `${candidates.length} candidate${candidates.length === 1 ? "" : "s"}.\n`,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
function parseLimit(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return 10;
|
|
34
|
+
if (!/^[1-9]\d*$/.test(value) || Number(value) > 50) {
|
|
35
|
+
throw new BrainError("USAGE", "--limit must be an integer from 1 to 50");
|
|
36
|
+
}
|
|
37
|
+
return Number(value);
|
|
38
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BrainError } from "../lib/errors.js";
|
|
4
|
+
import { buildIndex } from "../lib/index-builder.js";
|
|
5
|
+
import { lintBrain } from "../lib/lint.js";
|
|
6
|
+
import { scanNotes } from "../lib/notes.js";
|
|
7
|
+
import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
8
|
+
const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory"];
|
|
9
|
+
const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
|
|
10
|
+
/** `brain status` (spec/cli.md §5.2). */
|
|
11
|
+
export const statusCommand = {
|
|
12
|
+
name: "status",
|
|
13
|
+
usage: "brain status [--json]",
|
|
14
|
+
summary: "Show a read-only health summary for this brain.",
|
|
15
|
+
options: {},
|
|
16
|
+
maxPositionals: 0,
|
|
17
|
+
needsBrain: true,
|
|
18
|
+
async run({ brain }) {
|
|
19
|
+
const schemas = loadSchemas();
|
|
20
|
+
if (!schemas.brain(brain.config)) {
|
|
21
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", `brain.json fails schema: ${describeErrors(schemas.brain.errors).join("; ")}`);
|
|
22
|
+
}
|
|
23
|
+
const { notes, problems } = await scanNotes(brain.root);
|
|
24
|
+
const noteCounts = Object.fromEntries(NOTE_TYPES.map((type) => [type, 0]));
|
|
25
|
+
const proposalCounts = Object.fromEntries(PROPOSAL_STATUSES.map((status) => [status, 0]));
|
|
26
|
+
for (const note of notes) {
|
|
27
|
+
if (note.type === "proposal") {
|
|
28
|
+
const status = note.attributes.status;
|
|
29
|
+
if (typeof status === "string" && status in proposalCounts)
|
|
30
|
+
proposalCounts[status] += 1;
|
|
31
|
+
}
|
|
32
|
+
else if (note.type in noteCounts) {
|
|
33
|
+
noteCounts[note.type] += 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const index = buildIndex(notes).markdown;
|
|
37
|
+
let currentIndex = null;
|
|
38
|
+
try {
|
|
39
|
+
currentIndex = await readFile(join(brain.root, "index.md"), "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A missing index is stale, as it would be regenerated by `brain index`.
|
|
43
|
+
}
|
|
44
|
+
const issues = lintBrain({
|
|
45
|
+
root: brain.root,
|
|
46
|
+
notes,
|
|
47
|
+
problems,
|
|
48
|
+
brainConfig: brain.config,
|
|
49
|
+
indexMarkdown: currentIndex,
|
|
50
|
+
});
|
|
51
|
+
const viewStale = issues.filter((issue) => issue.rule === "view-stale").map((issue) => issue.path);
|
|
52
|
+
const notion = readNotion(brain.config);
|
|
53
|
+
const lastLog = await findLastLogLine(brain.root);
|
|
54
|
+
const data = {
|
|
55
|
+
schema_version: brain.config.schema_version,
|
|
56
|
+
owner: brain.config.owner.name,
|
|
57
|
+
notes: noteCounts,
|
|
58
|
+
proposals: proposalCounts,
|
|
59
|
+
notion,
|
|
60
|
+
index_stale: currentIndex !== index,
|
|
61
|
+
views_stale: viewStale,
|
|
62
|
+
last_log: lastLog,
|
|
63
|
+
};
|
|
64
|
+
return { data, human: `Owner: ${data.owner}\nNotes: ${notes.length}\n` };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
function readNotion(config) {
|
|
68
|
+
const notion = config.notion;
|
|
69
|
+
const databases = notion.databases;
|
|
70
|
+
const configured = [];
|
|
71
|
+
const missing = [];
|
|
72
|
+
for (const [key, value] of Object.entries(databases)) {
|
|
73
|
+
(typeof value === "string" && value !== "" ? configured : missing).push(key);
|
|
74
|
+
}
|
|
75
|
+
const tasks = notion.tasks;
|
|
76
|
+
return { configured, missing, tasks_database: typeof tasks.database === "string" && tasks.database !== "" };
|
|
77
|
+
}
|
|
78
|
+
async function findLastLogLine(root) {
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = (await readdir(join(root, "log"))).filter((entry) => /^\d{4}-\d{2}\.md$/.test(entry)).sort();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries.reverse()) {
|
|
87
|
+
try {
|
|
88
|
+
const lines = (await readFile(join(root, "log", entry), "utf8")).trimEnd().split("\n");
|
|
89
|
+
for (const line of lines.reverse())
|
|
90
|
+
if (line.startsWith("- "))
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Status is diagnostic; an unreadable old log does not make the brain unusable.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|