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,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BrainError } from "./errors.js";
|
|
3
|
+
import { serializeNewFile } from "./frontmatter.js";
|
|
4
|
+
import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
5
|
+
import { assertNoProblems, escapePointer, isRecord, unknownKeys } from "./input.js";
|
|
6
|
+
import { newNotePath } from "./paths.js";
|
|
7
|
+
import { formatDate, formatTimestamp } from "./time.js";
|
|
8
|
+
const ITEM_KEYS = ["type", "id", "title", "file_date", "frontmatter", "body"];
|
|
9
|
+
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
10
|
+
const NEW_NOTE_TYPES = new Set([...KNOWLEDGE_TYPES, "source"]);
|
|
11
|
+
const FORBIDDEN_KEYS = ["id", "type", "title", "created", "updated", "lifecycle"];
|
|
12
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
/** Types whose path uses the note's own `date` (spec/cli.md §3.8). */
|
|
14
|
+
const DATED_BY_FIELD = new Set(["meeting", "decision"]);
|
|
15
|
+
function isDate(value) {
|
|
16
|
+
if (typeof value !== "string" || !DATE.test(value))
|
|
17
|
+
return false;
|
|
18
|
+
const time = Date.parse(`${value}T00:00:00Z`);
|
|
19
|
+
return !Number.isNaN(time) && new Date(time).toISOString().startsWith(value);
|
|
20
|
+
}
|
|
21
|
+
/** Shape problems of one input item; `withId: false` for a proposal's `note`, which has no id. */
|
|
22
|
+
export function checkNoteInput(value, pointer, withId = true) {
|
|
23
|
+
if (!isRecord(value))
|
|
24
|
+
return [{ pointer, message: "must be an object" }];
|
|
25
|
+
const problems = unknownKeys(value, withId ? ITEM_KEYS : ITEM_KEYS.filter((key) => key !== "id"), pointer);
|
|
26
|
+
const at = (key, message) => problems.push({ pointer: `${pointer}/${key}`, message });
|
|
27
|
+
if (typeof value.type !== "string" || !NEW_NOTE_TYPES.has(value.type)) {
|
|
28
|
+
at("type", `must be one of ${[...NEW_NOTE_TYPES].join(", ")} (views and proposals have their own commands)`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof value.title !== "string" || value.title === "")
|
|
31
|
+
at("title", "must be a non-empty string");
|
|
32
|
+
if (value.id !== undefined) {
|
|
33
|
+
const prefix = TYPE_PREFIX[String(value.type)];
|
|
34
|
+
if (typeof value.id !== "string" || prefix === undefined || !new RegExp(`^${prefix}-[0-9a-hjkmnp-tv-z]{8}$`).test(value.id)) {
|
|
35
|
+
at("id", `must be a ${prefix ?? "<prefix>"}- id from brain id new`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (value.file_date !== undefined && !isDate(value.file_date))
|
|
39
|
+
at("file_date", "must be a real date, YYYY-MM-DD");
|
|
40
|
+
if (typeof value.body !== "string")
|
|
41
|
+
at("body", "must be a string");
|
|
42
|
+
else if (value.type === "source" && value.body.includes("\r"))
|
|
43
|
+
at("body", "must use LF line endings (a source is hashed byte for byte)");
|
|
44
|
+
if (!isRecord(value.frontmatter)) {
|
|
45
|
+
at("frontmatter", "must be an object");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const forbidden = value.type === "source" ? [...FORBIDDEN_KEYS, "content_hash"] : FORBIDDEN_KEYS;
|
|
49
|
+
for (const key of Object.keys(value.frontmatter)) {
|
|
50
|
+
if (forbidden.includes(key))
|
|
51
|
+
at(`frontmatter/${escapePointer(key)}`, "is set by the CLI");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return problems;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Stage new notes in the workspace (spec/cli.md §5.5) without validating them, so a caller can
|
|
58
|
+
* stage related writes (a meeting back-reference) first. Items must have passed `checkNoteInput`.
|
|
59
|
+
* Throws ID_IN_USE when an input id already exists or repeats in the batch.
|
|
60
|
+
*/
|
|
61
|
+
export function stageNotes(workspace, items, pointerOf, options) {
|
|
62
|
+
const existing = workspace.ids();
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const clashes = [];
|
|
65
|
+
items.forEach((item, index) => {
|
|
66
|
+
if (item.id === undefined)
|
|
67
|
+
return;
|
|
68
|
+
if (existing.has(item.id) || seen.has(item.id))
|
|
69
|
+
clashes.push({ pointer: `${pointerOf(index)}/id`, message: `${item.id} is already in use` });
|
|
70
|
+
seen.add(item.id);
|
|
71
|
+
});
|
|
72
|
+
if (clashes.length > 0)
|
|
73
|
+
throw new BrainError("ID_IN_USE", `${clashes[0].pointer} ${clashes[0].message}`, clashes);
|
|
74
|
+
const today = formatDate(options.now);
|
|
75
|
+
const taken = new Set([...existing, ...seen]);
|
|
76
|
+
const result = { created: [], skipped: [], pointers: new Map() };
|
|
77
|
+
items.forEach((item, index) => {
|
|
78
|
+
const lf = item.body.replace(/\r\n?/g, "\n");
|
|
79
|
+
const body = lf === "" || lf.endsWith("\n") ? lf : `${lf}\n`;
|
|
80
|
+
const attributes = { type: item.type, title: item.title, ...item.frontmatter };
|
|
81
|
+
if (KNOWLEDGE_TYPES.has(item.type))
|
|
82
|
+
attributes.lifecycle = options.lifecycle ?? "draft";
|
|
83
|
+
if (item.type === "source") {
|
|
84
|
+
const hash = `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
85
|
+
const sourceKey = attributes.source_key;
|
|
86
|
+
const duplicateByKey = typeof sourceKey === "string" && (sourceKey.startsWith("file:") || attributes.kind === "capture");
|
|
87
|
+
const duplicate = workspace.notes.find((note) => note.type === "source" && (note.attributes.content_hash === hash || (duplicateByKey && note.attributes.source_key === sourceKey)));
|
|
88
|
+
if (duplicate !== undefined) {
|
|
89
|
+
result.skipped.push({ input_index: index, reason: "DUPLICATE_SOURCE", existing: duplicate.id });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
attributes.content_hash = hash;
|
|
93
|
+
attributes.captured ??= formatTimestamp(options.now);
|
|
94
|
+
}
|
|
95
|
+
const id = item.id ?? generateId(TYPE_PREFIX[item.type], taken);
|
|
96
|
+
taken.add(id);
|
|
97
|
+
attributes.id = id;
|
|
98
|
+
attributes.created = today;
|
|
99
|
+
attributes.updated = today;
|
|
100
|
+
const ownDate = DATED_BY_FIELD.has(item.type) && isDate(item.frontmatter.date) ? item.frontmatter.date : undefined;
|
|
101
|
+
const date = ownDate ?? item.file_date ?? today;
|
|
102
|
+
const path = newNotePath(item.type, item.title, date, (candidate) => workspace.exists(candidate), attributes.source_key);
|
|
103
|
+
workspace.write(path, serializeNewFile(attributes, body));
|
|
104
|
+
result.created.push({ id, type: item.type, title: item.title, path });
|
|
105
|
+
result.pointers.set(path, pointerOf(index));
|
|
106
|
+
});
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Throw INPUT_INVALID if the staged writes add lint errors anywhere in the brain (spec/cli.md
|
|
111
|
+
* §3.6.1). Errors in a staged file point at its input item; others name the file and point at `root`.
|
|
112
|
+
*/
|
|
113
|
+
export function assertStagedValid(workspace, pointers, root = "") {
|
|
114
|
+
const problems = workspace.newErrors().map((issue) => {
|
|
115
|
+
const pointer = pointers.get(issue.path);
|
|
116
|
+
return pointer === undefined
|
|
117
|
+
? { pointer: root, message: `${issue.path}: ${issue.rule}: ${issue.message}` }
|
|
118
|
+
: { pointer, message: `${issue.rule}: ${issue.message}` };
|
|
119
|
+
});
|
|
120
|
+
assertNoProblems(problems);
|
|
121
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
/** Folders whose `.md` files must all be notes (spec/cli.md §3.7). */
|
|
5
|
+
export const NOTE_FOLDERS = [
|
|
6
|
+
"areas",
|
|
7
|
+
"goals",
|
|
8
|
+
"projects",
|
|
9
|
+
"archive",
|
|
10
|
+
"knowledge",
|
|
11
|
+
"sources",
|
|
12
|
+
"memories",
|
|
13
|
+
"_proposals",
|
|
14
|
+
];
|
|
15
|
+
/** Read every note in the brain. Never throws on content problems; reports them instead. */
|
|
16
|
+
export async function scanNotes(root) {
|
|
17
|
+
const paths = [];
|
|
18
|
+
const problems = [];
|
|
19
|
+
for (const folder of NOTE_FOLDERS) {
|
|
20
|
+
await collectMarkdown(root, folder, paths, problems);
|
|
21
|
+
}
|
|
22
|
+
paths.sort(compareCodePoints);
|
|
23
|
+
const notes = [];
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
let source;
|
|
26
|
+
try {
|
|
27
|
+
source = await readFile(join(root, path), "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
problems.push({ path, code: "UNREADABLE", message: error instanceof Error ? error.message : String(error) });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const parsed = parseNote(path, source);
|
|
34
|
+
if ("code" in parsed)
|
|
35
|
+
problems.push(parsed);
|
|
36
|
+
else
|
|
37
|
+
notes.push(parsed);
|
|
38
|
+
}
|
|
39
|
+
problems.sort((a, b) => compareCodePoints(a.path, b.path));
|
|
40
|
+
return { notes, problems };
|
|
41
|
+
}
|
|
42
|
+
/** Parse one note file; a file that is not a readable note is reported as a problem. */
|
|
43
|
+
export function parseNote(path, source) {
|
|
44
|
+
if (source.startsWith("\uFEFF"))
|
|
45
|
+
source = source.slice(1);
|
|
46
|
+
if (!/^---\r?\n/.test(source))
|
|
47
|
+
return { path, code: "NOT_A_NOTE", message: "no frontmatter" };
|
|
48
|
+
let attributes;
|
|
49
|
+
let body;
|
|
50
|
+
try {
|
|
51
|
+
({ attributes, body } = parseFrontmatter(source));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { path, code: "UNPARSEABLE", message: error instanceof Error ? error.message : String(error) };
|
|
55
|
+
}
|
|
56
|
+
const { type } = attributes;
|
|
57
|
+
// A number or other scalar id is kept (as text) so lint reports id-format rather than "no id".
|
|
58
|
+
const id = typeof attributes.id === "number" ? String(attributes.id) : attributes.id;
|
|
59
|
+
if (typeof id !== "string" || id === "")
|
|
60
|
+
return { path, code: "NOT_A_NOTE", message: "frontmatter has no id" };
|
|
61
|
+
return { path, id, type: typeof type === "string" ? type : "", attributes, body, raw: source };
|
|
62
|
+
}
|
|
63
|
+
async function collectMarkdown(root, relative, out, problems) {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.name.startsWith("."))
|
|
73
|
+
continue;
|
|
74
|
+
const child = `${relative}/${entry.name}`;
|
|
75
|
+
if (entry.isSymbolicLink()) {
|
|
76
|
+
problems.push({ path: child, code: "SKIPPED_SYMLINK", message: "symbolic links are not followed" });
|
|
77
|
+
}
|
|
78
|
+
else if (entry.isDirectory())
|
|
79
|
+
await collectMarkdown(root, child, out, problems);
|
|
80
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
81
|
+
out.push(child);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Compare strings by Unicode code points (not UTF-16 units, not locale). */
|
|
85
|
+
export function compareCodePoints(a, b) {
|
|
86
|
+
const left = Array.from(a);
|
|
87
|
+
const right = Array.from(b);
|
|
88
|
+
const length = Math.min(left.length, right.length);
|
|
89
|
+
for (let index = 0; index < length; index += 1) {
|
|
90
|
+
const difference = left[index].codePointAt(0) - right[index].codePointAt(0);
|
|
91
|
+
if (difference !== 0)
|
|
92
|
+
return difference;
|
|
93
|
+
}
|
|
94
|
+
return left.length - right.length;
|
|
95
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Slugs and paths for new files (spec/cli.md §3.8). */
|
|
2
|
+
/** `"María López — PM"` → `maria-lopez-pm`; at most 60 characters, never cutting a word. */
|
|
3
|
+
export function slugify(title) {
|
|
4
|
+
let slug = title
|
|
5
|
+
.normalize("NFKD")
|
|
6
|
+
.replace(/\p{M}/gu, "")
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
9
|
+
.replace(/^-+|-+$/g, "");
|
|
10
|
+
if (slug.length > 60) {
|
|
11
|
+
slug = slug.slice(0, 60);
|
|
12
|
+
const cut = slug.lastIndexOf("-");
|
|
13
|
+
if (cut !== -1)
|
|
14
|
+
slug = slug.slice(0, cut);
|
|
15
|
+
}
|
|
16
|
+
return slug === "" ? "note" : slug;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The path of a new file of `type`, or for a project its folder's `project.md`. `date` is the
|
|
20
|
+
* note's `date`, else the input `file_date`, else `created`. `taken` says whether a path (for
|
|
21
|
+
* projects: the folder) already exists; `-2`, `-3`, … are appended until it is free.
|
|
22
|
+
*/
|
|
23
|
+
export function newNotePath(type, title, date, taken, sourceKey) {
|
|
24
|
+
const base = slugify(title);
|
|
25
|
+
for (let n = 1;; n += 1) {
|
|
26
|
+
const slug = n === 1 ? base : `${base}-${n}`;
|
|
27
|
+
const { path, claim } = layout(type, slug, date, sourceKey);
|
|
28
|
+
if (!taken(claim))
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The folder for a source key (decision 0032). Unknown keys stay in the legacy notion folder for schema validation. */
|
|
33
|
+
export function sourceFolder(sourceKey) {
|
|
34
|
+
if (typeof sourceKey === "string") {
|
|
35
|
+
if (sourceKey.startsWith("file:"))
|
|
36
|
+
return "files";
|
|
37
|
+
if (sourceKey.startsWith("url:") || sourceKey.startsWith("agent:"))
|
|
38
|
+
return "text";
|
|
39
|
+
}
|
|
40
|
+
return "notion";
|
|
41
|
+
}
|
|
42
|
+
function layout(type, slug, date, sourceKey) {
|
|
43
|
+
const file = (path) => ({ path, claim: path });
|
|
44
|
+
switch (type) {
|
|
45
|
+
case "area": return file(`areas/${slug}.md`);
|
|
46
|
+
case "goal": return file(`goals/${slug}.md`);
|
|
47
|
+
case "project": return { path: `projects/${slug}/project.md`, claim: `projects/${slug}` };
|
|
48
|
+
case "person": return file(`knowledge/people/${slug}.md`);
|
|
49
|
+
case "org": return file(`knowledge/orgs/${slug}.md`);
|
|
50
|
+
case "question": return file(`knowledge/questions/${slug}.md`);
|
|
51
|
+
case "concept": return file(`knowledge/concepts/${slug}.md`);
|
|
52
|
+
case "meeting": return file(`knowledge/meetings/${date}-${slug}.md`);
|
|
53
|
+
case "decision": return file(`knowledge/decisions/${date}-${slug}.md`);
|
|
54
|
+
case "source": return file(`sources/${sourceFolder(sourceKey)}/${date}-${slug}.md`);
|
|
55
|
+
case "memory": return file(`memories/${date.slice(0, 4)}/${date.slice(5, 7)}/${date}-${slug}.md`);
|
|
56
|
+
case "proposal": return file(`_proposals/${date}-${slug}.md`);
|
|
57
|
+
default: throw new Error(`No path layout for type ${type}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { BrainError } from "./errors.js";
|
|
3
|
+
import { serializeNewFile } from "./frontmatter.js";
|
|
4
|
+
import { generateId } from "./ids.js";
|
|
5
|
+
import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
|
|
6
|
+
import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
|
|
7
|
+
import { compareCodePoints } from "./notes.js";
|
|
8
|
+
import { newNotePath } from "./paths.js";
|
|
9
|
+
import { loadSchemas } from "./schemas.js";
|
|
10
|
+
import { formatDate } from "./time.js";
|
|
11
|
+
export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item"];
|
|
12
|
+
export const PROPOSAL_STATUSES = ["pending", "approved", "rejected", "edited", "applied", "closed"];
|
|
13
|
+
/** Input keys of `proposal new`: the schema §11 fields the CLI does not set (spec/cli.md §5.6). */
|
|
14
|
+
const INPUT_KEYS = ["title", "kind", "target", "field", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
|
|
15
|
+
const ID = /^[a-z]{2,4}-[0-9a-hjkmnp-tv-z]{8}$/;
|
|
16
|
+
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
17
|
+
const ADD_LINK_FIELDS = new Set(["aliases", "tags", "projects", "people", "attendees", "deciders"]);
|
|
18
|
+
/** Fields that make two pending proposals the same change (dedupe, spec/cli.md §5.6). */
|
|
19
|
+
const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into"];
|
|
20
|
+
/**
|
|
21
|
+
* Stage proposals for `proposal new` (spec/cli.md §5.6) and validate them. Throws INPUT_INVALID,
|
|
22
|
+
* UNKNOWN_ID or BEFORE_MISMATCH for the whole batch; nothing is staged in that case.
|
|
23
|
+
*/
|
|
24
|
+
export function stageProposals(workspace, items, now) {
|
|
25
|
+
if (!Array.isArray(items))
|
|
26
|
+
assertNoProblems([{ pointer: "/proposals", message: "must be a list" }]);
|
|
27
|
+
const list = items;
|
|
28
|
+
const problems = [];
|
|
29
|
+
list.forEach((item, index) => problems.push(...checkShape(item, `/proposals/${index}`)));
|
|
30
|
+
assertNoProblems(problems);
|
|
31
|
+
const today = formatDate(now);
|
|
32
|
+
const created = [];
|
|
33
|
+
const skipped = [];
|
|
34
|
+
const pointers = new Map();
|
|
35
|
+
// A new_item's note is checked alone first, so errors in other items are never blamed on it.
|
|
36
|
+
list.forEach((item, index) => {
|
|
37
|
+
if (item.kind === "new_item")
|
|
38
|
+
checkNewItem(workspace, item.note, `/proposals/${index}/note`, today);
|
|
39
|
+
});
|
|
40
|
+
const start = workspace.checkpoint();
|
|
41
|
+
try {
|
|
42
|
+
list.forEach((item, index) => {
|
|
43
|
+
const pointer = `/proposals/${index}`;
|
|
44
|
+
const fields = resolveFields(workspace, item, pointer, today);
|
|
45
|
+
if (item.kind === "add_link" && listField(workspace.get(String(fields.target)), String(fields.field)).some((value) => isDeepStrictEqual(value, fields.after))) {
|
|
46
|
+
skipped.push({ input_index: index, reason: "ALREADY_LINKED" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const duplicate = workspace.notes.find((note) => note.type === "proposal" && note.attributes.status === "pending" &&
|
|
50
|
+
DEDUPE_KEYS.every((key) => isDeepStrictEqual(note.attributes[key], fields[key])));
|
|
51
|
+
if (duplicate !== undefined) {
|
|
52
|
+
skipped.push({ input_index: index, reason: "DUPLICATE_PENDING", existing: duplicate.id });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const id = generateId("prp", workspace.ids());
|
|
56
|
+
const attributes = { id, type: "proposal", created: today, updated: today, ...fields, status: "pending" };
|
|
57
|
+
const path = newNotePath("proposal", String(item.title), today, (candidate) => workspace.exists(candidate));
|
|
58
|
+
workspace.write(path, serializeNewFile(attributes, ""));
|
|
59
|
+
pointers.set(path, pointer);
|
|
60
|
+
const result = {
|
|
61
|
+
id,
|
|
62
|
+
kind: String(fields.kind),
|
|
63
|
+
title: String(fields.title),
|
|
64
|
+
...(fields.target === undefined ? {} : { target: String(fields.target) }),
|
|
65
|
+
path,
|
|
66
|
+
...(fields.before === undefined ? {} : { before: fields.before }),
|
|
67
|
+
};
|
|
68
|
+
created.push(result);
|
|
69
|
+
});
|
|
70
|
+
assertStagedValid(workspace, pointers, "/proposals");
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
workspace.restore(start);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return { created, skipped };
|
|
77
|
+
}
|
|
78
|
+
function checkShape(item, pointer) {
|
|
79
|
+
if (!isRecord(item))
|
|
80
|
+
return [{ pointer, message: "must be an object" }];
|
|
81
|
+
const problems = unknownKeys(item, INPUT_KEYS, pointer);
|
|
82
|
+
const at = (key, message) => problems.push({ pointer: `${pointer}/${key}`, message });
|
|
83
|
+
if (!PROPOSAL_KINDS.includes(item.kind))
|
|
84
|
+
at("kind", `must be one of ${PROPOSAL_KINDS.join(", ")}`);
|
|
85
|
+
for (const key of ["title", "why", "source"]) {
|
|
86
|
+
if (typeof item[key] !== "string" || item[key] === "")
|
|
87
|
+
at(key, "must be a non-empty string");
|
|
88
|
+
}
|
|
89
|
+
if (item.kind === "new_item") {
|
|
90
|
+
if (item.target !== undefined)
|
|
91
|
+
at("target", "a new_item has no target");
|
|
92
|
+
problems.push(...checkNoteInput(item.note, `${pointer}/note`, false));
|
|
93
|
+
}
|
|
94
|
+
else if (typeof item.target !== "string") {
|
|
95
|
+
at("target", "must be the id of an existing note");
|
|
96
|
+
}
|
|
97
|
+
for (const key of ["supersedes", "merge_into"]) {
|
|
98
|
+
if (item[key] !== undefined && typeof item[key] !== "string")
|
|
99
|
+
at(key, "must be a note id");
|
|
100
|
+
}
|
|
101
|
+
// Everything else (field names, value shapes, required keys per kind) is checked by the proposal schema.
|
|
102
|
+
return problems;
|
|
103
|
+
}
|
|
104
|
+
/** The proposal's own fields, with `before` and `from` filled in and per-kind rules checked. */
|
|
105
|
+
function resolveFields(workspace, item, pointer, today) {
|
|
106
|
+
const fields = {};
|
|
107
|
+
for (const key of INPUT_KEYS)
|
|
108
|
+
if (item[key] !== undefined)
|
|
109
|
+
fields[key] = item[key];
|
|
110
|
+
const kind = item.kind;
|
|
111
|
+
const invalid = (key, message) => {
|
|
112
|
+
throw new BrainError("INPUT_INVALID", `${pointer}/${key} ${message}`, [{ pointer: `${pointer}/${key}`, message }]);
|
|
113
|
+
};
|
|
114
|
+
const existing = (key) => {
|
|
115
|
+
const id = item[key];
|
|
116
|
+
const note = typeof id === "string" ? workspace.get(id) : undefined;
|
|
117
|
+
if (note === undefined) {
|
|
118
|
+
throw new BrainError("UNKNOWN_ID", `${pointer}/${key}: no note with id ${String(id)}`, [{ pointer: `${pointer}/${key}`, message: "does not exist" }]);
|
|
119
|
+
}
|
|
120
|
+
return note;
|
|
121
|
+
};
|
|
122
|
+
if (typeof item.source === "string" && ID.test(item.source))
|
|
123
|
+
existing("source");
|
|
124
|
+
if (kind === "new_item")
|
|
125
|
+
return fields;
|
|
126
|
+
const target = existing("target");
|
|
127
|
+
if (!KNOWLEDGE_TYPES.has(target.type))
|
|
128
|
+
invalid("target", `is a ${target.type}; proposals change knowledge notes only`);
|
|
129
|
+
if (kind === "update_fact" || kind === "contradiction") {
|
|
130
|
+
if (typeof item.field !== "string")
|
|
131
|
+
invalid("field", "is required for this kind");
|
|
132
|
+
const facts = isRecord(target.attributes.facts) ? target.attributes.facts : {};
|
|
133
|
+
const current = Object.hasOwn(facts, item.field) ? facts[item.field] : undefined;
|
|
134
|
+
if (kind === "contradiction" && current === undefined)
|
|
135
|
+
invalid("field", `${target.id} has no current facts.${String(item.field)} to contradict`);
|
|
136
|
+
if (item.before !== undefined && !isDeepStrictEqual(item.before, current)) {
|
|
137
|
+
throw new BrainError("BEFORE_MISMATCH", `${pointer}/before is ${JSON.stringify(item.before)}, but ${target.id} facts.${String(item.field)} is ${current === undefined ? "absent" : JSON.stringify(current)}`, [{ pointer: `${pointer}/before`, message: "differs from the note's current value" }]);
|
|
138
|
+
}
|
|
139
|
+
if (current === undefined)
|
|
140
|
+
delete fields.before;
|
|
141
|
+
else
|
|
142
|
+
fields.before = current;
|
|
143
|
+
if (fields.from === undefined) {
|
|
144
|
+
const meeting = typeof item.source === "string" && item.source.startsWith("mtg-") ? workspace.get(item.source) : undefined;
|
|
145
|
+
fields.from = typeof meeting?.attributes.date === "string" ? meeting.attributes.date : today;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (kind === "add_link") {
|
|
149
|
+
const field = typeof item.field === "string" ? item.field : "";
|
|
150
|
+
if (!ADD_LINK_FIELDS.has(field))
|
|
151
|
+
invalid("field", "must be aliases, tags, projects, people, attendees or deciders");
|
|
152
|
+
if (!loadSchemas().knownKeys(target.type)?.has(field))
|
|
153
|
+
invalid("field", `is not declared by ${target.type}`);
|
|
154
|
+
if (["projects", "people", "attendees", "deciders"].includes(field)) {
|
|
155
|
+
const linked = existing("after");
|
|
156
|
+
const prefix = field === "projects" ? "prj-" : "per-";
|
|
157
|
+
if (!linked.id.startsWith(prefix))
|
|
158
|
+
invalid("after", `must be an existing ${prefix} id for ${field}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (kind === "supersede") {
|
|
162
|
+
const older = existing("supersedes");
|
|
163
|
+
if (older.id === target.id)
|
|
164
|
+
invalid("supersedes", "must differ from target");
|
|
165
|
+
if (older.type !== target.type)
|
|
166
|
+
invalid("supersedes", `is a ${older.type}, target is a ${target.type}`);
|
|
167
|
+
const relations = Array.isArray(older.attributes.relations) ? older.attributes.relations : [];
|
|
168
|
+
if (relations.some((relation) => isRecord(relation) && relation.type === "superseded_by"))
|
|
169
|
+
invalid("supersedes", `${older.id} is already superseded`);
|
|
170
|
+
}
|
|
171
|
+
if (kind === "merge") {
|
|
172
|
+
const survivor = existing("merge_into");
|
|
173
|
+
if (survivor.id === target.id)
|
|
174
|
+
invalid("merge_into", "must differ from target");
|
|
175
|
+
if (survivor.type !== target.type)
|
|
176
|
+
invalid("merge_into", `is a ${survivor.type}, target is a ${target.type}`);
|
|
177
|
+
}
|
|
178
|
+
return fields;
|
|
179
|
+
}
|
|
180
|
+
function listField(note, field) {
|
|
181
|
+
return Array.isArray(note.attributes[field]) ? note.attributes[field] : [];
|
|
182
|
+
}
|
|
183
|
+
/** A new_item's note must pass `note new` validation as apply would write it (spec/cli.md §5.6). */
|
|
184
|
+
function checkNewItem(workspace, note, pointer, today) {
|
|
185
|
+
const before = workspace.checkpoint();
|
|
186
|
+
try {
|
|
187
|
+
const staged = stageNewItem(workspace, note, pointer, new Date(`${today}T00:00:00Z`));
|
|
188
|
+
assertStagedValid(workspace, staged.pointers, pointer);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
workspace.restore(before);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Stage a new_item's note as a new note and add it to its meeting's `decisions` or `questions`
|
|
196
|
+
* (spec/schema.md §11.2). Not validated: the caller does that.
|
|
197
|
+
*/
|
|
198
|
+
export function stageNewItem(workspace, note, pointer, now) {
|
|
199
|
+
const staged = stageNotes(workspace, [note], () => pointer, { now });
|
|
200
|
+
const created = staged.created[0];
|
|
201
|
+
if (created === undefined) {
|
|
202
|
+
const existingId = staged.skipped[0]?.existing;
|
|
203
|
+
throw new BrainError("INPUT_INVALID", `${pointer} duplicates source ${String(existingId)}`, [{ pointer, message: `same content as existing source ${String(existingId)}` }]);
|
|
204
|
+
}
|
|
205
|
+
const pointers = new Map(staged.pointers);
|
|
206
|
+
const link = note.type === "decision" ? ["decided_in", "decisions"] : note.type === "question" ? ["raised_in", "questions"] : undefined;
|
|
207
|
+
const meetingId = link === undefined ? undefined : note.frontmatter[link[0]];
|
|
208
|
+
const meeting = typeof meetingId === "string" ? workspace.get(meetingId) : undefined;
|
|
209
|
+
if (link === undefined || meeting === undefined || meeting.type !== "meeting")
|
|
210
|
+
return { note: created, pointers };
|
|
211
|
+
const document = workspace.edit(meeting);
|
|
212
|
+
const ids = Array.isArray(document.attributes[link[1]]) ? document.attributes[link[1]] : [];
|
|
213
|
+
if (!ids.includes(created.id)) {
|
|
214
|
+
document.attributes[link[1]] = [...ids, created.id];
|
|
215
|
+
document.attributes.updated = formatDate(now);
|
|
216
|
+
workspace.save(meeting.path, document);
|
|
217
|
+
}
|
|
218
|
+
pointers.set(meeting.path, pointer);
|
|
219
|
+
return { note: created, meetingPath: meeting.path, pointers };
|
|
220
|
+
}
|
|
221
|
+
/** Proposals with these statuses and target, sorted by `created` then id (spec/cli.md §5.6). */
|
|
222
|
+
export function listProposals(workspace, statuses, target) {
|
|
223
|
+
return workspace.notes
|
|
224
|
+
.filter((note) => note.type === "proposal")
|
|
225
|
+
.filter((note) => statuses.length === 0 || statuses.includes(String(note.attributes.status)))
|
|
226
|
+
.filter((note) => target === undefined || note.attributes.target === target)
|
|
227
|
+
.map((note) => ({ ...note.attributes, path: note.path }))
|
|
228
|
+
.sort((a, b) => compareCodePoints(String(a.created), String(b.created)) || compareCodePoints(String(a.id), String(b.id)));
|
|
229
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { BrainError } from "./errors.js";
|
|
4
|
+
import { compareCodePoints } from "./notes.js";
|
|
5
|
+
/** V1 note types accepted by `brain query --types` (spec/cli.md §5.13). */
|
|
6
|
+
export const QUERY_TYPES = [
|
|
7
|
+
"area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "memory", "proposal",
|
|
8
|
+
];
|
|
9
|
+
const DEFAULT_TYPES = new Set(QUERY_TYPES.filter((type) => !["view", "proposal", "memory"].includes(type)));
|
|
10
|
+
const STOPWORDS = new Set([
|
|
11
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "did", "do", "does", "for", "from", "has", "have", "how", "in", "is", "it", "of", "on", "or", "that", "the", "this", "to", "was", "we", "what", "when", "where", "which", "who", "why", "will", "with",
|
|
12
|
+
"al", "como", "con", "de", "del", "el", "en", "es", "la", "las", "lo", "los", "para", "por", "que", "se", "su", "un", "una", "y",
|
|
13
|
+
]);
|
|
14
|
+
/** Normalize a query or field into its query words (spec/cli.md §5.13). */
|
|
15
|
+
export function queryWords(value) {
|
|
16
|
+
return value
|
|
17
|
+
.normalize("NFKD")
|
|
18
|
+
.replace(/\p{M}/gu, "")
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(/[^a-z0-9]+/)
|
|
21
|
+
.filter((word) => word.length >= 2 && !STOPWORDS.has(word));
|
|
22
|
+
}
|
|
23
|
+
/** Parse an explicit `--types` value, or select the contract's default search types. */
|
|
24
|
+
export function queryTypes(value) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return DEFAULT_TYPES;
|
|
27
|
+
const types = value.split(",");
|
|
28
|
+
if (types.some((type) => !QUERY_TYPES.includes(type))) {
|
|
29
|
+
throw new BrainError("USAGE", `--types must contain only: ${QUERY_TYPES.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
return new Set(types);
|
|
32
|
+
}
|
|
33
|
+
/** Read `.brain` context from a consuming repository, without changing it. */
|
|
34
|
+
export async function queryContext(repoPath) {
|
|
35
|
+
const root = repoPath === undefined ? process.cwd() : resolve(repoPath);
|
|
36
|
+
let directory;
|
|
37
|
+
try {
|
|
38
|
+
directory = (await stat(root)).isDirectory();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
directory = false;
|
|
42
|
+
}
|
|
43
|
+
if (!directory) {
|
|
44
|
+
if (repoPath === undefined)
|
|
45
|
+
return { repo: null, project: null, area: null };
|
|
46
|
+
throw new BrainError("USAGE", `--repo must be a directory: ${repoPath}`);
|
|
47
|
+
}
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(join(root, ".brain"), "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { repo: null, project: null, area: null };
|
|
54
|
+
}
|
|
55
|
+
let marker;
|
|
56
|
+
try {
|
|
57
|
+
marker = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new BrainError("USAGE", `.brain in ${root} is not valid JSON`);
|
|
61
|
+
}
|
|
62
|
+
if (!isMarker(marker))
|
|
63
|
+
throw new BrainError("USAGE", `.brain in ${root} has an invalid format`);
|
|
64
|
+
return {
|
|
65
|
+
repo: marker.repo ?? basename(root),
|
|
66
|
+
project: marker.project ?? null,
|
|
67
|
+
area: marker.area ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Rank matching notes deterministically, including repository context. */
|
|
71
|
+
export function rankCandidates(notes, tokens, types, context) {
|
|
72
|
+
return notes
|
|
73
|
+
.filter((note) => types.has(note.type))
|
|
74
|
+
.map((note) => ({ candidate: candidateFor(note, tokens, context), updated: stringValue(note.attributes.updated) ?? "" }))
|
|
75
|
+
.filter((entry) => entry.candidate !== null)
|
|
76
|
+
.sort((left, right) => right.candidate.score - left.candidate.score
|
|
77
|
+
|| compareCodePoints(right.updated, left.updated)
|
|
78
|
+
|| compareCodePoints(left.candidate.id, right.candidate.id))
|
|
79
|
+
.map((entry) => entry.candidate);
|
|
80
|
+
}
|
|
81
|
+
function candidateFor(note, tokens, context) {
|
|
82
|
+
const titleWords = wordsOf(note.attributes.title);
|
|
83
|
+
const aliasWords = stringsOf(note.attributes.aliases).flatMap(queryWords);
|
|
84
|
+
const summaryWords = wordsOf(note.attributes.summary);
|
|
85
|
+
const otherWords = otherFrontmatterStrings(note.attributes).flatMap(queryWords);
|
|
86
|
+
let score = 0;
|
|
87
|
+
for (const token of tokens) {
|
|
88
|
+
if (matches(titleWords, token) || matches(aliasWords, token))
|
|
89
|
+
score += 5;
|
|
90
|
+
if (matches(summaryWords, token))
|
|
91
|
+
score += 3;
|
|
92
|
+
if (matches(otherWords, token))
|
|
93
|
+
score += 2;
|
|
94
|
+
score += Math.min(3, note.body.split(/\r?\n/).filter((line) => matches(queryWords(line), token)).length);
|
|
95
|
+
}
|
|
96
|
+
if (context.project !== null && (note.id === context.project || stringsOf(note.attributes.projects).includes(context.project))) {
|
|
97
|
+
score += 3;
|
|
98
|
+
}
|
|
99
|
+
else if (context.area !== null && note.attributes.area === context.area) {
|
|
100
|
+
score += 2;
|
|
101
|
+
}
|
|
102
|
+
if (score === 0)
|
|
103
|
+
return null;
|
|
104
|
+
return {
|
|
105
|
+
id: note.id,
|
|
106
|
+
type: note.type,
|
|
107
|
+
title: stringValue(note.attributes.title),
|
|
108
|
+
path: note.path,
|
|
109
|
+
summary: stringValue(note.attributes.summary),
|
|
110
|
+
lifecycle: stringValue(note.attributes.lifecycle),
|
|
111
|
+
assessment: stringValue(note.attributes.assessment),
|
|
112
|
+
date: stringValue(note.attributes.date),
|
|
113
|
+
score,
|
|
114
|
+
snippets: snippets(note, tokens),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function snippets(note, tokens) {
|
|
118
|
+
const offset = note.raw.slice(0, note.raw.length - note.body.length).split(/\r?\n/).length;
|
|
119
|
+
const found = [];
|
|
120
|
+
for (const [index, text] of note.body.split(/\r?\n/).entries()) {
|
|
121
|
+
if (!tokens.some((token) => matches(queryWords(text), token)))
|
|
122
|
+
continue;
|
|
123
|
+
found.push({ line: offset + index, text: text.trim().slice(0, 200) });
|
|
124
|
+
if (found.length === 3)
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
function otherFrontmatterStrings(attributes) {
|
|
130
|
+
return Object.entries(attributes)
|
|
131
|
+
.flatMap(([key, value]) => ["title", "aliases", "summary"].includes(key) ? [] : frontmatterStrings(value));
|
|
132
|
+
}
|
|
133
|
+
function frontmatterStrings(value) {
|
|
134
|
+
if (typeof value === "string")
|
|
135
|
+
return [value];
|
|
136
|
+
if (Array.isArray(value))
|
|
137
|
+
return value.flatMap(frontmatterStrings);
|
|
138
|
+
if (typeof value !== "object" || value === null)
|
|
139
|
+
return [];
|
|
140
|
+
return Object.values(value).flatMap(frontmatterStrings);
|
|
141
|
+
}
|
|
142
|
+
function stringsOf(value) {
|
|
143
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
144
|
+
}
|
|
145
|
+
function wordsOf(value) {
|
|
146
|
+
return typeof value === "string" ? queryWords(value) : [];
|
|
147
|
+
}
|
|
148
|
+
function matches(words, token) {
|
|
149
|
+
return words.some((word) => word.startsWith(token));
|
|
150
|
+
}
|
|
151
|
+
function stringValue(value) {
|
|
152
|
+
return typeof value === "string" ? value : null;
|
|
153
|
+
}
|
|
154
|
+
function isMarker(value) {
|
|
155
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
156
|
+
return false;
|
|
157
|
+
const marker = value;
|
|
158
|
+
return Object.keys(marker).every((key) => ["repo", "project", "area", "stacks"].includes(key))
|
|
159
|
+
&& (marker.repo === undefined || typeof marker.repo === "string")
|
|
160
|
+
&& (marker.project === undefined || typeof marker.project === "string")
|
|
161
|
+
&& (marker.area === undefined || typeof marker.area === "string")
|
|
162
|
+
&& (marker.stacks === undefined || Array.isArray(marker.stacks));
|
|
163
|
+
}
|