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,216 @@
|
|
|
1
|
+
import { compareCodePoints } from "./notes.js";
|
|
2
|
+
export const INDEX_HEADER = "<!-- GENERATED by brain index. Do not edit. -->";
|
|
3
|
+
/** index.md sections, in order (spec/schema.md §13). */
|
|
4
|
+
const SECTIONS = [
|
|
5
|
+
["area", "Areas"],
|
|
6
|
+
["goal", "Goals"],
|
|
7
|
+
["project", "Projects"],
|
|
8
|
+
["person", "People"],
|
|
9
|
+
["org", "Orgs"],
|
|
10
|
+
["decision", "Decisions"],
|
|
11
|
+
["question", "Questions"],
|
|
12
|
+
["meeting", "Meetings"],
|
|
13
|
+
["concept", "Concepts"],
|
|
14
|
+
];
|
|
15
|
+
const NON_KNOWLEDGE = new Set(["source", "view", "proposal"]);
|
|
16
|
+
/** Computed inverses exposed as backlinks (spec/schema.md §6). */
|
|
17
|
+
const RELATION_INVERSES = {
|
|
18
|
+
about: "mentioned_in",
|
|
19
|
+
depends_on: "needed_by",
|
|
20
|
+
contradicts: "contradicts",
|
|
21
|
+
derived_from: "used_by",
|
|
22
|
+
related_to: "related_to",
|
|
23
|
+
};
|
|
24
|
+
const FIELD_INVERSES = [
|
|
25
|
+
["projects", "project_of"],
|
|
26
|
+
["area", "area_of"],
|
|
27
|
+
["goal", "goal_of"],
|
|
28
|
+
["client", "client_of"],
|
|
29
|
+
["org", "member"],
|
|
30
|
+
["attendees", "attended"],
|
|
31
|
+
["decided_in", "decisions"],
|
|
32
|
+
["raised_in", "questions"],
|
|
33
|
+
];
|
|
34
|
+
/** Build index.md and the agent-facing index from notes sorted by path. */
|
|
35
|
+
export function buildIndex(allNotes) {
|
|
36
|
+
const byId = new Map();
|
|
37
|
+
const duplicates = [];
|
|
38
|
+
const notes = [];
|
|
39
|
+
for (const note of allNotes) {
|
|
40
|
+
const kept = byId.get(note.id);
|
|
41
|
+
if (kept !== undefined) {
|
|
42
|
+
duplicates.push({ id: note.id, path: note.path, kept: kept.path });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
byId.set(note.id, note);
|
|
46
|
+
notes.push(note);
|
|
47
|
+
}
|
|
48
|
+
const counts = {};
|
|
49
|
+
for (const note of notes)
|
|
50
|
+
counts[note.type] = (counts[note.type] ?? 0) + 1;
|
|
51
|
+
return {
|
|
52
|
+
markdown: renderMarkdown(notes, byId),
|
|
53
|
+
entries: notes.map(toEntry),
|
|
54
|
+
backlinks: computeBacklinks(notes, byId),
|
|
55
|
+
counts,
|
|
56
|
+
duplicates,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function renderMarkdown(notes, byId) {
|
|
60
|
+
const lines = [INDEX_HEADER, "", "# Index"];
|
|
61
|
+
for (const [type, heading] of SECTIONS) {
|
|
62
|
+
const section = notes
|
|
63
|
+
.filter((note) => note.type === type)
|
|
64
|
+
.sort((a, b) => compareCodePoints(sortKey(a), sortKey(b)) || compareCodePoints(a.id, b.id));
|
|
65
|
+
if (section.length === 0)
|
|
66
|
+
continue;
|
|
67
|
+
lines.push("", `## ${heading}`, ...section.map((note) => renderLine(note, byId)));
|
|
68
|
+
}
|
|
69
|
+
return `${lines.join("\n")}\n`;
|
|
70
|
+
}
|
|
71
|
+
function sortKey(note) {
|
|
72
|
+
return text(note.attributes.title).normalize("NFC").toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
function renderLine(note, byId) {
|
|
75
|
+
const a = note.attributes;
|
|
76
|
+
const title = oneLine(text(a.title)).replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
77
|
+
const segments = [`- [${title}](${note.path}) — ${note.id}`];
|
|
78
|
+
for (const [label, value] of typeFields(note, byId)) {
|
|
79
|
+
if (value !== undefined && value !== "")
|
|
80
|
+
segments.push(`${label}: ${oneLine(value)}`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof a.summary === "string")
|
|
83
|
+
segments.push(oneLine(a.summary));
|
|
84
|
+
const aliases = list(a.aliases).filter(isScalar).map((alias) => oneLine(String(alias)));
|
|
85
|
+
if (aliases.length > 0)
|
|
86
|
+
segments.push(`aliases: ${aliases.join(", ")}`);
|
|
87
|
+
if (a.lifecycle === "disputed" || a.lifecycle === "archived")
|
|
88
|
+
segments.push(String(a.lifecycle));
|
|
89
|
+
return segments.join(" · ");
|
|
90
|
+
}
|
|
91
|
+
/** Per-type index fields, rendered (references as titles). */
|
|
92
|
+
function typeFields(note, byId) {
|
|
93
|
+
const a = note.attributes;
|
|
94
|
+
const ref = (value) => (typeof value === "string" ? titleOf(value, byId) : undefined);
|
|
95
|
+
const refs = (value) => {
|
|
96
|
+
const items = list(value).filter((item) => typeof item === "string").map((item) => titleOf(item, byId));
|
|
97
|
+
return items.length === 0 ? undefined : items.join(", ");
|
|
98
|
+
};
|
|
99
|
+
const scalar = (value) => (isScalar(value) ? String(value) : undefined);
|
|
100
|
+
const notionField = (key) => scalar(record(a.notion_fields)[key]);
|
|
101
|
+
const fact = (key) => scalar(record(a.facts)[key]);
|
|
102
|
+
switch (note.type) {
|
|
103
|
+
case "goal":
|
|
104
|
+
return [["area", ref(a.area)], ["status", notionField("status")], ["deadline", fact("deadline")]];
|
|
105
|
+
case "project":
|
|
106
|
+
return [["area", ref(a.area)], ["status", notionField("status")], ["client", ref(a.client)]];
|
|
107
|
+
case "person":
|
|
108
|
+
return [["org", ref(a.org)], ["role", fact("role")]];
|
|
109
|
+
case "org":
|
|
110
|
+
return [["relationship", notionField("relationship")]];
|
|
111
|
+
case "decision":
|
|
112
|
+
return [["date", scalar(a.date)], ["assessment", scalar(a.assessment)], ["project", refs(a.projects)]];
|
|
113
|
+
case "question":
|
|
114
|
+
return [["kind", scalar(a.kind)], ["status", notionField("status")]];
|
|
115
|
+
case "meeting":
|
|
116
|
+
return [["date", scalar(a.date)], ["project", refs(a.projects)]];
|
|
117
|
+
default:
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function toEntry(note) {
|
|
122
|
+
const a = note.attributes;
|
|
123
|
+
const nullable = (value) => (value === undefined ? null : value);
|
|
124
|
+
return {
|
|
125
|
+
id: note.id,
|
|
126
|
+
type: note.type,
|
|
127
|
+
title: nullable(a.title),
|
|
128
|
+
path: note.path,
|
|
129
|
+
summary: nullable(a.summary),
|
|
130
|
+
lifecycle: NON_KNOWLEDGE.has(note.type) ? null : nullable(a.lifecycle),
|
|
131
|
+
aliases: list(a.aliases),
|
|
132
|
+
tags: list(a.tags),
|
|
133
|
+
area: nullable(a.area),
|
|
134
|
+
projects: list(a.projects),
|
|
135
|
+
updated: nullable(a.updated),
|
|
136
|
+
notion: nullable(a.notion),
|
|
137
|
+
fields: entryFields(note),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Raw (unrendered) per-type fields for the JSON index (spec/cli.md §5.8). */
|
|
141
|
+
function entryFields(note) {
|
|
142
|
+
const a = note.attributes;
|
|
143
|
+
const notionFields = record(a.notion_fields);
|
|
144
|
+
const facts = record(a.facts);
|
|
145
|
+
const picks = {
|
|
146
|
+
goal: [["status", notionFields.status], ["deadline", facts.deadline]],
|
|
147
|
+
project: [["status", notionFields.status], ["client", a.client], ["goal", a.goal]],
|
|
148
|
+
person: [["org", a.org], ["role", facts.role]],
|
|
149
|
+
org: [["relationship", notionFields.relationship]],
|
|
150
|
+
decision: [["date", a.date], ["assessment", a.assessment]],
|
|
151
|
+
question: [["kind", a.kind], ["status", notionFields.status]],
|
|
152
|
+
meeting: [["date", a.date]],
|
|
153
|
+
source: [["source_key", a.source_key]],
|
|
154
|
+
view: [["view_kind", a.view_kind], ["project", a.project]],
|
|
155
|
+
proposal: [["kind", a.kind], ["status", a.status], ["target", a.target]],
|
|
156
|
+
memory: [["scope", a.scope]],
|
|
157
|
+
};
|
|
158
|
+
const fields = {};
|
|
159
|
+
for (const [key, value] of picks[note.type] ?? []) {
|
|
160
|
+
if (value !== undefined && value !== null)
|
|
161
|
+
fields[key] = value;
|
|
162
|
+
}
|
|
163
|
+
return fields;
|
|
164
|
+
}
|
|
165
|
+
function computeBacklinks(notes, byId) {
|
|
166
|
+
const collected = new Map();
|
|
167
|
+
const add = (target, type, source) => {
|
|
168
|
+
if (typeof target !== "string" || !byId.has(target))
|
|
169
|
+
return;
|
|
170
|
+
const bucket = collected.get(target) ?? new Map();
|
|
171
|
+
bucket.set(`${type}\u0000${source}`, { type, source });
|
|
172
|
+
collected.set(target, bucket);
|
|
173
|
+
};
|
|
174
|
+
for (const note of notes) {
|
|
175
|
+
const a = note.attributes;
|
|
176
|
+
for (const relation of list(a.relations)) {
|
|
177
|
+
const { type, target } = record(relation);
|
|
178
|
+
const inverse = typeof type === "string" ? RELATION_INVERSES[type] : undefined;
|
|
179
|
+
if (inverse !== undefined)
|
|
180
|
+
add(target, inverse, note.id);
|
|
181
|
+
}
|
|
182
|
+
for (const [field, inverse] of FIELD_INVERSES) {
|
|
183
|
+
const value = a[field];
|
|
184
|
+
for (const target of Array.isArray(value) ? value : [value])
|
|
185
|
+
add(target, inverse, note.id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const result = {};
|
|
189
|
+
for (const target of [...collected.keys()].sort(compareCodePoints)) {
|
|
190
|
+
result[target] = [...collected.get(target).values()].sort((x, y) => compareCodePoints(x.type, y.type) || compareCodePoints(x.source, y.source));
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
function titleOf(value, byId) {
|
|
195
|
+
const id = String(value);
|
|
196
|
+
const title = byId.get(id)?.attributes.title;
|
|
197
|
+
return typeof title === "string" ? title : id;
|
|
198
|
+
}
|
|
199
|
+
/** Collapse every line break (and the spaces around it) into one space, then trim. */
|
|
200
|
+
function oneLine(value) {
|
|
201
|
+
return value.replace(/[ \t]*(?:\r\n|\r|\n)[ \t]*/g, " ").trim();
|
|
202
|
+
}
|
|
203
|
+
function isScalar(value) {
|
|
204
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
205
|
+
}
|
|
206
|
+
function text(value) {
|
|
207
|
+
return value === undefined || value === null ? "" : String(value);
|
|
208
|
+
}
|
|
209
|
+
function list(value) {
|
|
210
|
+
return Array.isArray(value) ? value : [];
|
|
211
|
+
}
|
|
212
|
+
function record(value) {
|
|
213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
214
|
+
? value
|
|
215
|
+
: {};
|
|
216
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { BrainError } from "./errors.js";
|
|
3
|
+
/** Read the JSON input of `--input <path|->` (spec/cli.md §3.2). */
|
|
4
|
+
export async function readJsonInput(path, command) {
|
|
5
|
+
if (path === undefined)
|
|
6
|
+
throw new BrainError("USAGE", `brain ${command} needs --input <file|->`);
|
|
7
|
+
let text;
|
|
8
|
+
try {
|
|
9
|
+
text = path === "-" ? await readStdin() : await readFile(path, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
throw new BrainError("INPUT_UNREADABLE", `Cannot read input ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(text.startsWith("\uFEFF") ? text.slice(1) : text);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new BrainError("INPUT_UNREADABLE", `Input is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Throw INPUT_INVALID listing every problem, when there are any. */
|
|
22
|
+
export function assertNoProblems(problems) {
|
|
23
|
+
if (problems.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
const count = problems.length === 1 ? "1 problem" : `${problems.length} problems`;
|
|
26
|
+
throw new BrainError("INPUT_INVALID", `${count} in input: ${problems[0].pointer} ${problems[0].message}`, problems);
|
|
27
|
+
}
|
|
28
|
+
/** Problems for keys of `value` outside `allowed` (unknown keys are errors, §3.2). */
|
|
29
|
+
export function unknownKeys(value, allowed, pointer) {
|
|
30
|
+
return Object.keys(value)
|
|
31
|
+
.filter((key) => !allowed.includes(key))
|
|
32
|
+
.map((key) => ({ pointer: `${pointer}/${escapePointer(key)}`, message: "unknown key" }));
|
|
33
|
+
}
|
|
34
|
+
export function escapePointer(key) {
|
|
35
|
+
return key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
36
|
+
}
|
|
37
|
+
export function isRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
async function readStdin() {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
for await (const chunk of process.stdin)
|
|
43
|
+
chunks.push(Buffer.from(chunk));
|
|
44
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
45
|
+
}
|
package/dist/lib/lint.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
import { buildIndex } from "./index-builder.js";
|
|
5
|
+
import { compareCodePoints } from "./notes.js";
|
|
6
|
+
import { describeErrors, loadSchemas } from "./schemas.js";
|
|
7
|
+
import { deriveView, generatedBlockRange } from "./views.js";
|
|
8
|
+
const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
|
|
9
|
+
/** Frontmatter keys whose values are references (spec/schema.md §3–§11); `relations` and `timeline` are handled by shape. */
|
|
10
|
+
const REFERENCE_KEYS = new Set([
|
|
11
|
+
"area", "goal", "client", "org", "projects", "people", "attendees", "decided_in", "raised_in", "deciders",
|
|
12
|
+
"source", "sources", "decisions", "questions", "answer", "target", "supersedes", "merge_into", "project",
|
|
13
|
+
"generated_from", "facts",
|
|
14
|
+
]);
|
|
15
|
+
/** Where each type must live (spec/schema.md §2). */
|
|
16
|
+
const FOLDERS = {
|
|
17
|
+
area: /^areas\/[^/]+\.md$/,
|
|
18
|
+
goal: /^(archive\/)?goals\/[^/]+\.md$/,
|
|
19
|
+
project: /^(archive\/)?projects\/[^/]+\/project\.md$/,
|
|
20
|
+
view: /^(archive\/)?projects\/[^/]+\/[^/]+\.md$/,
|
|
21
|
+
person: /^knowledge\/people\/[^/]+\.md$/,
|
|
22
|
+
org: /^knowledge\/orgs\/[^/]+\.md$/,
|
|
23
|
+
meeting: /^knowledge\/meetings\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
24
|
+
decision: /^knowledge\/decisions\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
25
|
+
question: /^knowledge\/questions\/[^/]+\.md$/,
|
|
26
|
+
concept: /^knowledge\/concepts\/[^/]+\.md$/,
|
|
27
|
+
source: /^sources\/notion\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
28
|
+
memory: /^memories\/\d{4}\/\d{2}\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
29
|
+
proposal: /^_proposals\/[^/]+\.md$/,
|
|
30
|
+
};
|
|
31
|
+
const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
32
|
+
const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept"]);
|
|
33
|
+
const FRESHNESS_WORDS = /\b(currently|now|at the moment|this week|latest|recently|soon)\b/i;
|
|
34
|
+
const HAS_DATE = /\b\d{4}-\d{2}(-\d{2})?\b/;
|
|
35
|
+
const MEMORY_REACH = { general: null, area: "areas", project: "projects", stack: "stacks" };
|
|
36
|
+
/** Run every lint rule of spec/cli.md §5.10 over the brain. Issues are sorted by path, line, rule, message. */
|
|
37
|
+
export function lintBrain(input) {
|
|
38
|
+
const schemas = loadSchemas();
|
|
39
|
+
const issues = [];
|
|
40
|
+
const add = (severity, rule, path, line, id, message) => issues.push({ severity, rule, path, line, id, message });
|
|
41
|
+
for (const problem of input.problems) {
|
|
42
|
+
if (problem.code === "NOT_A_NOTE")
|
|
43
|
+
add("error", "not-a-note", problem.path, 1, null, problem.message);
|
|
44
|
+
if (problem.code === "UNPARSEABLE" || problem.code === "UNREADABLE")
|
|
45
|
+
add("error", "unparseable", problem.path, 1, null, problem.message);
|
|
46
|
+
}
|
|
47
|
+
if (!schemas.brain(input.brainConfig)) {
|
|
48
|
+
add("error", "brain-config", "brain.json", 1, null, describeErrors(schemas.brain.errors).join("; "));
|
|
49
|
+
}
|
|
50
|
+
const byId = new Map();
|
|
51
|
+
const pathsById = new Map();
|
|
52
|
+
for (const note of input.notes) {
|
|
53
|
+
if (!byId.has(note.id))
|
|
54
|
+
byId.set(note.id, note);
|
|
55
|
+
pathsById.set(note.id, [...(pathsById.get(note.id) ?? []), note.path]);
|
|
56
|
+
}
|
|
57
|
+
for (const note of input.notes) {
|
|
58
|
+
const others = pathsById.get(note.id).filter((path) => path !== note.path);
|
|
59
|
+
if (others.length > 0)
|
|
60
|
+
add("error", "id-duplicate", note.path, lineOfKey(note, "id"), note.id, `${note.id} is also used by ${others.join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
const onDisk = caseSensitiveExists(input.root);
|
|
63
|
+
const exists = (path) => input.pendingPaths?.has(path) === true || onDisk(path);
|
|
64
|
+
for (const note of input.notes) {
|
|
65
|
+
const a = note.attributes;
|
|
66
|
+
const err = (rule, line, message) => add("error", rule, note.path, line, note.id, message);
|
|
67
|
+
const warn = (rule, line, message) => add("warning", rule, note.path, line, note.id, message);
|
|
68
|
+
// Shape
|
|
69
|
+
const validate = note.type === "proposal" ? schemas.proposal : schemas.note;
|
|
70
|
+
if (!validate(a)) {
|
|
71
|
+
const firstKey = validate.errors?.find((error) => error.instancePath !== "")?.instancePath.split("/")[1];
|
|
72
|
+
err("schema", firstKey === undefined ? 1 : lineOfKey(note, firstKey), describeErrors(validate.errors).join("; "));
|
|
73
|
+
}
|
|
74
|
+
if (!ID.test(note.id))
|
|
75
|
+
err("id-format", lineOfKey(note, "id"), `"${note.id}" is not <prefix>-<8 chars> with a schema prefix`);
|
|
76
|
+
const folder = FOLDERS[note.type];
|
|
77
|
+
if (folder !== undefined && !folder.test(note.path))
|
|
78
|
+
err("wrong-folder", 1, `a ${note.type} must live in its folder (spec/schema.md §2)`);
|
|
79
|
+
const known = schemas.knownKeys(note.type);
|
|
80
|
+
if (known !== undefined) {
|
|
81
|
+
for (const key of Object.keys(a))
|
|
82
|
+
if (!known.has(key))
|
|
83
|
+
warn("unknown-key", lineOfKey(note, key), `${note.type} does not declare "${key}"`);
|
|
84
|
+
}
|
|
85
|
+
// References and links
|
|
86
|
+
const references = Object.entries(a).filter(([key]) => REFERENCE_KEYS.has(key));
|
|
87
|
+
for (const relation of list(a.relations))
|
|
88
|
+
references.push(["relations", record(relation).target]);
|
|
89
|
+
for (const entry of list(a.timeline))
|
|
90
|
+
references.push(["timeline", record(entry).source]);
|
|
91
|
+
for (const [key, value] of references) {
|
|
92
|
+
for (const ref of collectIds(value)) {
|
|
93
|
+
if (!byId.has(ref))
|
|
94
|
+
err("missing-ref", lineOfKey(note, key), `${key} references ${ref}, which does not exist`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const bodyStart = bodyLineOffset(note);
|
|
98
|
+
for (const [index, raw] of proseLines(note.body)) {
|
|
99
|
+
const line = bodyStart + index;
|
|
100
|
+
if (raw.includes("[["))
|
|
101
|
+
err("wikilink", line, "use a relative Markdown link, not [[wikilinks]] (0002)");
|
|
102
|
+
for (const match of raw.matchAll(/\^\[([^\]]*)\]/g)) {
|
|
103
|
+
if (match[1] !== "inferred" && match[1] !== "ambiguous")
|
|
104
|
+
err("unknown-marker", line, `^[${match[1]}] is not a provenance marker (0004)`);
|
|
105
|
+
}
|
|
106
|
+
for (const target of linkTargets(raw)) {
|
|
107
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#"))
|
|
108
|
+
continue;
|
|
109
|
+
const resolved = posix.normalize(posix.join(posix.dirname(note.path), decodePath(target.split(/[?#]/)[0])));
|
|
110
|
+
if (resolved.startsWith("../") || !exists(resolved))
|
|
111
|
+
err("broken-link", line, `link target ${target} does not exist`);
|
|
112
|
+
}
|
|
113
|
+
const prose = raw.replace(/\]\([^)]*\)/g, "]");
|
|
114
|
+
if (UNDATED.has(note.type) && FRESHNESS_WORDS.test(prose) && !HAS_DATE.test(prose)) {
|
|
115
|
+
warn("freshness", line, `"${prose.match(FRESHNESS_WORDS)[0]}" in an undated note without a date; write (as of YYYY-MM-DD) (0011)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Type rules
|
|
119
|
+
if (note.type === "source") {
|
|
120
|
+
const hash = `sha256:${createHash("sha256").update(note.body, "utf8").digest("hex")}`;
|
|
121
|
+
if (a.content_hash !== hash)
|
|
122
|
+
err("source-hash", lineOfKey(note, "content_hash"), "the body no longer matches content_hash; sources are immutable");
|
|
123
|
+
}
|
|
124
|
+
for (const source of list(a.sources)) {
|
|
125
|
+
if (typeof source === "string" && byId.get(source)?.type === "view")
|
|
126
|
+
err("view-cited", lineOfKey(note, "sources"), `${source} is a derived view and can't be evidence`);
|
|
127
|
+
}
|
|
128
|
+
if (note.type === "view" && (a.view_kind === "brief" || a.view_kind === "decision_log")) {
|
|
129
|
+
const project = byId.get(String(a.project));
|
|
130
|
+
const range = generatedBlockRange(note.body);
|
|
131
|
+
if (project?.type === "project" && range !== undefined) {
|
|
132
|
+
const derived = deriveView(project, a.view_kind, input.notes);
|
|
133
|
+
const generatedFrom = Array.isArray(a.generated_from) ? a.generated_from : [];
|
|
134
|
+
if (note.body.slice(range.start, range.end) !== derived.generatedBlock || !sameStringList(generatedFrom, derived.generatedFrom)) {
|
|
135
|
+
warn("view-stale", 1, "generated block is out of date; run brain views");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const facts = record(a.facts);
|
|
140
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
141
|
+
const entries = list(a.timeline).map(record).filter((entry) => entry.field === key);
|
|
142
|
+
const accepted = entries.some((entry) => entry.assessment === "accepted" && entry.value === value);
|
|
143
|
+
// Unless contested (schema §5): when the latest entry is contested, facts may keep the earlier value.
|
|
144
|
+
const contested = entries.at(-1)?.assessment === "contested" && entries.some((entry) => entry.value === value);
|
|
145
|
+
if (!accepted && !contested)
|
|
146
|
+
err("fact-timeline", lineOfKey(note, "facts"), `facts.${key} has no accepted timeline entry with value ${JSON.stringify(value)}`);
|
|
147
|
+
}
|
|
148
|
+
for (const relation of list(a.relations)) {
|
|
149
|
+
const { type, target } = record(relation);
|
|
150
|
+
const inverse = type === "supersedes" ? "superseded_by" : type === "superseded_by" ? "supersedes" : undefined;
|
|
151
|
+
if (inverse === undefined || typeof target !== "string" || !byId.has(target))
|
|
152
|
+
continue;
|
|
153
|
+
const paired = list(byId.get(target).attributes.relations).some((other) => record(other).type === inverse && record(other).target === note.id);
|
|
154
|
+
if (!paired)
|
|
155
|
+
err("supersession-pair", lineOfKey(note, "relations"), `${type} ${target}, but ${target} has no ${inverse} ${note.id}`);
|
|
156
|
+
}
|
|
157
|
+
for (const [field, list_, label] of [["decided_in", "decisions", "decision"], ["raised_in", "questions", "question"]]) {
|
|
158
|
+
const meeting = typeof a[field] === "string" ? byId.get(a[field]) : undefined;
|
|
159
|
+
if (meeting !== undefined && !list(meeting.attributes[list_]).includes(note.id)) {
|
|
160
|
+
err("meeting-backref", lineOfKey(note, field), `${field} ${meeting.id}, but that meeting's ${list_} does not list this ${label}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (note.type === "meeting") {
|
|
164
|
+
for (const [list_, field] of [["decisions", "decided_in"], ["questions", "raised_in"]]) {
|
|
165
|
+
for (const ref of list(a[list_])) {
|
|
166
|
+
const other = typeof ref === "string" ? byId.get(ref) : undefined;
|
|
167
|
+
if (other !== undefined && other.attributes[field] !== note.id) {
|
|
168
|
+
err("meeting-backref", lineOfKey(note, list_), `${list_} lists ${ref}, whose ${field} is not this meeting`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (note.type === "decision") {
|
|
174
|
+
if (a.assessment === "superseded" && a.valid_until === undefined)
|
|
175
|
+
err("superseded-valid-until", lineOfKey(note, "assessment"), "a superseded decision needs valid_until");
|
|
176
|
+
if (a.assessment === "superseded" && !list(a.relations).some((relation) => record(relation).type === "superseded_by")) {
|
|
177
|
+
err("superseded-unlinked", lineOfKey(note, "assessment"), "a superseded decision needs a superseded_by relation (0005)");
|
|
178
|
+
}
|
|
179
|
+
if (list(a.sources).length === 0)
|
|
180
|
+
err("decision-sources", lineOfKey(note, "sources"), "a decision needs sources (0005)");
|
|
181
|
+
}
|
|
182
|
+
if (note.type === "memory") {
|
|
183
|
+
const reach = MEMORY_REACH[String(a.scope)];
|
|
184
|
+
const present = ["areas", "projects", "stacks"].filter((key) => a[key] !== undefined);
|
|
185
|
+
if (reach === undefined) {
|
|
186
|
+
err("memory-scope", lineOfKey(note, "scope"), `scope ${String(a.scope)} is not general, area, project or stack (0009)`);
|
|
187
|
+
}
|
|
188
|
+
else if (reach === null ? present.length > 0 : present.length !== 1 || present[0] !== reach || list(a[reach]).length === 0) {
|
|
189
|
+
err("memory-scope", lineOfKey(note, "scope"), `scope ${String(a.scope)} must have ${reach === null ? "no reach lists" : `only a non-empty ${reach}`} (0009)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (note.type === "proposal") {
|
|
193
|
+
const problem = proposalStatusProblem(a);
|
|
194
|
+
if (problem !== null)
|
|
195
|
+
err("proposal-status", lineOfKey(note, "status"), problem);
|
|
196
|
+
}
|
|
197
|
+
// Quality warnings
|
|
198
|
+
if (KNOWLEDGE.has(note.type)) {
|
|
199
|
+
if (typeof a.summary === "string" && a.summary.length > 200)
|
|
200
|
+
warn("summary-length", lineOfKey(note, "summary"), `summary is ${a.summary.length} characters; aim for 200`);
|
|
201
|
+
if (!["area", "goal", "project", "decision"].includes(note.type) && list(a.sources).length === 0) {
|
|
202
|
+
warn("no-sources", 1, "knowledge without sources can't be traced");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (input.indexMarkdown !== undefined && input.indexMarkdown !== buildIndex(input.notes).markdown) {
|
|
207
|
+
add("warning", "index-stale", "index.md", 1, null, "index.md is out of date; run brain index");
|
|
208
|
+
}
|
|
209
|
+
return issues.sort((x, y) => compareCodePoints(x.path, y.path) || x.line - y.line || compareCodePoints(x.rule, y.rule) || compareCodePoints(x.message, y.message));
|
|
210
|
+
}
|
|
211
|
+
/** Status/decision agreement (spec/schema.md §11.1). */
|
|
212
|
+
function proposalStatusProblem(a) {
|
|
213
|
+
const status = a.status;
|
|
214
|
+
const action = record(a.decision).action;
|
|
215
|
+
const stale = a.closed_reason === "stale";
|
|
216
|
+
if (a.closed_reason !== undefined && status !== "closed")
|
|
217
|
+
return "closed_reason is only allowed on a closed proposal";
|
|
218
|
+
const expected = {
|
|
219
|
+
pending: [undefined],
|
|
220
|
+
approved: ["approve"],
|
|
221
|
+
edited: ["edit"],
|
|
222
|
+
rejected: ["reject"],
|
|
223
|
+
applied: ["approve", "edit"],
|
|
224
|
+
closed: stale ? ["approve", "edit"] : ["reject"],
|
|
225
|
+
};
|
|
226
|
+
const allowed = expected[String(status)];
|
|
227
|
+
if (allowed === undefined)
|
|
228
|
+
return null; // the schema reports unknown statuses
|
|
229
|
+
if (!allowed.includes(action)) {
|
|
230
|
+
return `status ${String(status)}${stale ? " (stale)" : ""} needs decision ${allowed.map((value) => value ?? "absent").join(" or ")}, found ${action === undefined ? "none" : String(action)}`;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
function collectIds(value, out = []) {
|
|
235
|
+
if (typeof value === "string") {
|
|
236
|
+
if (ID.test(value))
|
|
237
|
+
out.push(value);
|
|
238
|
+
}
|
|
239
|
+
else if (Array.isArray(value)) {
|
|
240
|
+
for (const item of value)
|
|
241
|
+
collectIds(item, out);
|
|
242
|
+
}
|
|
243
|
+
else if (value !== null && typeof value === "object") {
|
|
244
|
+
for (const item of Object.values(value))
|
|
245
|
+
collectIds(item, out);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** Body lines outside fenced code blocks, with inline code removed: [index, text]. */
|
|
250
|
+
function proseLines(body) {
|
|
251
|
+
const out = [];
|
|
252
|
+
let fence = null;
|
|
253
|
+
body.split("\n").forEach((line, index) => {
|
|
254
|
+
const marker = /^\s*(```|~~~)/.exec(line)?.[1];
|
|
255
|
+
if (marker !== undefined) {
|
|
256
|
+
fence = fence === null ? marker : fence === marker ? null : fence;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (fence === null)
|
|
260
|
+
out.push([index, line.replace(/`[^`]*`/g, "")]);
|
|
261
|
+
});
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/** Targets of inline links (`[x](t)`, `[x](t "title")`, `[x](<t>)`) and reference definitions (`[x]: t`). */
|
|
265
|
+
function linkTargets(line) {
|
|
266
|
+
const targets = [];
|
|
267
|
+
for (const match of line.matchAll(/\]\(\s*(?:<([^>]*)>|([^)\s]+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g)) {
|
|
268
|
+
targets.push(match[1] ?? match[2]);
|
|
269
|
+
}
|
|
270
|
+
const definition = /^\s{0,3}\[[^\]]+\]:\s*(?:<([^>]*)>|(\S+))/.exec(line);
|
|
271
|
+
if (definition !== null)
|
|
272
|
+
targets.push(definition[1] ?? definition[2]);
|
|
273
|
+
return targets;
|
|
274
|
+
}
|
|
275
|
+
/** Percent-decode each path segment; a segment that is not valid encoding is kept as written. */
|
|
276
|
+
function decodePath(path) {
|
|
277
|
+
return path.split("/").map((segment) => {
|
|
278
|
+
try {
|
|
279
|
+
return decodeURIComponent(segment);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return segment;
|
|
283
|
+
}
|
|
284
|
+
}).join("/");
|
|
285
|
+
}
|
|
286
|
+
/** File existence with exact case on every segment, so macOS and Linux agree. */
|
|
287
|
+
function caseSensitiveExists(root) {
|
|
288
|
+
const listings = new Map();
|
|
289
|
+
const entries = (dir) => {
|
|
290
|
+
if (!listings.has(dir)) {
|
|
291
|
+
try {
|
|
292
|
+
listings.set(dir, new Set(readdirSync(`${root}/${dir}`)));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
listings.set(dir, null);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return listings.get(dir);
|
|
299
|
+
};
|
|
300
|
+
return (relative) => {
|
|
301
|
+
const parts = relative.split("/").filter((part) => part !== "" && part !== ".");
|
|
302
|
+
let dir = "";
|
|
303
|
+
for (const part of parts) {
|
|
304
|
+
if (!entries(dir === "" ? "." : dir)?.has(part))
|
|
305
|
+
return false;
|
|
306
|
+
dir = dir === "" ? part : `${dir}/${part}`;
|
|
307
|
+
}
|
|
308
|
+
return parts.length > 0;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** 1-based line of a top-level frontmatter key, or 1. */
|
|
312
|
+
function lineOfKey(note, key) {
|
|
313
|
+
const lines = note.raw.split("\n");
|
|
314
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
315
|
+
const line = lines[index];
|
|
316
|
+
if (line === "---" || line === "---\r")
|
|
317
|
+
break;
|
|
318
|
+
if (line.startsWith(`${key}:`))
|
|
319
|
+
return index + 1;
|
|
320
|
+
}
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
/** 1-based line number of the first body line. */
|
|
324
|
+
function bodyLineOffset(note) {
|
|
325
|
+
const head = note.raw.slice(0, note.raw.length - note.body.length);
|
|
326
|
+
return head.split("\n").length;
|
|
327
|
+
}
|
|
328
|
+
function list(value) {
|
|
329
|
+
return Array.isArray(value) ? value : [];
|
|
330
|
+
}
|
|
331
|
+
function record(value) {
|
|
332
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
333
|
+
}
|
|
334
|
+
function sameStringList(left, right) {
|
|
335
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
336
|
+
}
|
package/dist/lib/log.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { ensureDirectory, resolveWritableTarget } from "./fs-safe.js";
|
|
3
|
+
import { formatTimestamp } from "./time.js";
|
|
4
|
+
export const LOG_HEADER = "<!-- GENERATED by brain. Append-only. -->";
|
|
5
|
+
/** Format one log line (spec/cli.md §3.9). Always a single line: CR and LF are escaped. */
|
|
6
|
+
export function formatLogLine(now, entry) {
|
|
7
|
+
const pairs = (entry.pairs ?? []).map(([key, value]) => ` ${key}=${quoteIfNeeded(String(value))}`).join("");
|
|
8
|
+
return `- ${formatTimestamp(now)} ${entry.op} ${entry.id} ${quote(entry.title)}${pairs}`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Append lines to `log/<YYYY-MM>.md` (month of `now`). The file is created exclusively with
|
|
12
|
+
* its header, then lines are appended, so concurrent writers never overwrite each other and an
|
|
13
|
+
* unreadable log is an error, never a reason to start over. Returns the relative path.
|
|
14
|
+
*/
|
|
15
|
+
export async function appendLog(root, now, entries) {
|
|
16
|
+
const path = `log/${formatTimestamp(now).slice(0, 7)}.md`;
|
|
17
|
+
await ensureDirectory(root, "log");
|
|
18
|
+
const { target } = await resolveWritableTarget(root, path);
|
|
19
|
+
try {
|
|
20
|
+
await writeFile(target, `${LOG_HEADER}\n\n`, { flag: "wx" });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
24
|
+
throw error;
|
|
25
|
+
if ((await stat(target)).size === 0)
|
|
26
|
+
await appendFile(target, `${LOG_HEADER}\n\n`);
|
|
27
|
+
}
|
|
28
|
+
await appendFile(target, entries.map((entry) => `${formatLogLine(now, entry)}\n`).join(""));
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
function quote(value) {
|
|
32
|
+
const escaped = value
|
|
33
|
+
.replaceAll("\\", "\\\\")
|
|
34
|
+
.replaceAll('"', '\\"')
|
|
35
|
+
.replaceAll("\r", "\\r")
|
|
36
|
+
.replaceAll("\n", "\\n");
|
|
37
|
+
return `"${escaped}"`;
|
|
38
|
+
}
|
|
39
|
+
function quoteIfNeeded(value) {
|
|
40
|
+
return /[\s"\\]/.test(value) || value === "" ? quote(value) : value;
|
|
41
|
+
}
|
|
42
|
+
function isNodeError(error) {
|
|
43
|
+
return error instanceof Error && "code" in error;
|
|
44
|
+
}
|