ultimate-brain 0.5.0 → 0.6.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/brain-apply/SKILL.md +3 -3
- package/.agents/skills/brain-catch-up/SKILL.md +38 -31
- package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
- package/.agents/skills/brain-howto/references/guide.md +63 -17
- package/.agents/skills/brain-ingest/SKILL.md +11 -7
- package/.agents/skills/brain-ingest/references/cli-inputs.md +18 -8
- package/.agents/skills/brain-ingest/references/extraction.md +24 -11
- package/.agents/skills/brain-ingest/references/notion.md +8 -6
- package/.agents/skills/brain-ingest/references/sources.md +21 -19
- package/.agents/skills/brain-notion-change/SKILL.md +2 -2
- package/.agents/skills/brain-query/SKILL.md +8 -7
- package/.agents/skills/brain-sync/SKILL.md +24 -10
- package/.agents/skills/brain-sync/references/formats.md +28 -1
- package/dist/cli.js +2 -1
- package/dist/commands/modules.js +54 -0
- package/dist/commands/source.js +4 -46
- package/dist/commands/status.js +4 -4
- package/dist/commands/sync.js +3 -1
- package/dist/commands/upgrade.js +33 -18
- package/dist/lib/errors.js +2 -0
- package/dist/lib/index-builder.js +2 -2
- package/dist/lib/lint.js +8 -1
- package/dist/lib/modules.js +91 -0
- package/dist/lib/note-new.js +90 -16
- package/dist/lib/proposals.js +2 -0
- package/dist/lib/sync.js +166 -57
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +36 -0
- package/spec/schemas/note.schema.json +105 -18
- package/templates/brain/AGENTS.md +2 -1
- package/templates/brain/brain.json +2 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { BrainError } from "../lib/errors.js";
|
|
2
|
+
import { atomicWriteFile } from "../lib/fs-safe.js";
|
|
3
|
+
import { appendLog } from "../lib/log.js";
|
|
4
|
+
import { isModuleName, missingFor, MODULE_DATABASES, MODULE_DEFAULTS, MODULES, modulesOf, withModules } from "../lib/modules.js";
|
|
5
|
+
import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
6
|
+
/** `brain modules` (spec/cli.md §5.20). */
|
|
7
|
+
export const modulesListCommand = {
|
|
8
|
+
name: "modules",
|
|
9
|
+
usage: "brain modules [--json]",
|
|
10
|
+
summary: "List the optional modules, whether each is on, and the Notion pieces it still lacks.",
|
|
11
|
+
options: {},
|
|
12
|
+
maxPositionals: 0,
|
|
13
|
+
needsBrain: true,
|
|
14
|
+
async run({ brain }) {
|
|
15
|
+
const on = modulesOf(brain.config);
|
|
16
|
+
const modules = MODULES.map((name) => ({ name, on: on[name], default: MODULE_DEFAULTS[name], databases: [...MODULE_DATABASES[name]], missing: missingFor(brain.config, name) }));
|
|
17
|
+
const human = modules.map((module) => `${module.on ? "on " : "off"} ${module.name}${module.on && module.missing.length > 0 ? ` (missing: ${module.missing.join(", ")})` : ""}\n`).join("");
|
|
18
|
+
return { data: { modules }, human };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export const modulesOnCommand = switchCommand(true);
|
|
22
|
+
export const modulesOffCommand = switchCommand(false);
|
|
23
|
+
function switchCommand(value) {
|
|
24
|
+
const word = value ? "on" : "off";
|
|
25
|
+
return {
|
|
26
|
+
name: `modules ${word}`,
|
|
27
|
+
usage: `brain modules ${word} <module> [--json]`,
|
|
28
|
+
summary: `Switch an optional module ${word} in brain.json.`,
|
|
29
|
+
options: {},
|
|
30
|
+
maxPositionals: 1,
|
|
31
|
+
needsBrain: true,
|
|
32
|
+
async run({ args, brain, now }) {
|
|
33
|
+
const [name] = args.positionals;
|
|
34
|
+
if (name === undefined)
|
|
35
|
+
throw new BrainError("USAGE", `brain modules ${word} needs a module: ${MODULES.join(", ")}`);
|
|
36
|
+
if (!isModuleName(name))
|
|
37
|
+
throw new BrainError("USAGE", `Unknown module: ${name}. Optional modules: ${MODULES.join(", ")} (core has no switch)`);
|
|
38
|
+
const module = name;
|
|
39
|
+
const config = brain.config;
|
|
40
|
+
const missing = missingFor(config, module);
|
|
41
|
+
if (modulesOf(config)[module] === value) {
|
|
42
|
+
return { data: { module, on: value, changed: false, missing }, human: `${module} is already ${word}\n` };
|
|
43
|
+
}
|
|
44
|
+
// An absent block means every module is on (schema §12): write it out whole, then switch.
|
|
45
|
+
const next = withModules(config, { ...modulesOf(config), [module]: value });
|
|
46
|
+
const schemas = loadSchemas();
|
|
47
|
+
if (!schemas.brain(next))
|
|
48
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", `brain.json would be invalid: ${describeErrors(schemas.brain.errors).join("; ")}`);
|
|
49
|
+
await atomicWriteFile(brain.root, "brain.json", `${JSON.stringify(next, null, 2)}\n`);
|
|
50
|
+
await appendLog(brain.root, now, [{ op: "modules", id: "-", title: module, pairs: [["state", word]] }]);
|
|
51
|
+
return { data: { module, on: value, changed: true, missing }, human: `${module}: ${word}${value && missing.length > 0 ? ` (Notion still needs: ${missing.join(", ")})` : ""}\n` };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/commands/source.js
CHANGED
|
@@ -1,58 +1,16 @@
|
|
|
1
1
|
import { BrainError } from "../lib/errors.js";
|
|
2
|
-
import { rangesProblem, splitParts } from "../lib/parts.js";
|
|
3
2
|
import { lineDiff } from "../lib/diff.js";
|
|
4
|
-
import { hasInboxBody } from "../lib/sync.js";
|
|
5
|
-
import { formatTimestamp } from "../lib/time.js";
|
|
6
3
|
import { Workspace } from "../lib/workspace.js";
|
|
7
|
-
/** `brain source full-text
|
|
4
|
+
/** `brain source full-text` (spec/cli.md §5.17): retired in 0.6.0 by decision 0049. Writes nothing. */
|
|
8
5
|
export const sourceFullTextCommand = {
|
|
9
6
|
name: "source full-text",
|
|
10
7
|
usage: "brain source full-text <src-id> [--parts <range>[,<range>…]] [--json]",
|
|
11
|
-
summary: "
|
|
8
|
+
summary: "Retired in 0.6.0: use Keep as document on the source's Inbox row.",
|
|
12
9
|
options: { parts: "string" },
|
|
13
10
|
maxPositionals: 1,
|
|
14
11
|
needsBrain: true,
|
|
15
|
-
async run(
|
|
16
|
-
|
|
17
|
-
if (id === undefined)
|
|
18
|
-
throw new BrainError("USAGE", "Name the source: brain source full-text <src-id>");
|
|
19
|
-
const workspace = await Workspace.open(brain);
|
|
20
|
-
const note = workspace.get(id);
|
|
21
|
-
if (note === undefined)
|
|
22
|
-
throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
|
|
23
|
-
const invalid = (message) => { throw new BrainError("INPUT_INVALID", message, [{ pointer: "", message }]); };
|
|
24
|
-
if (note.type !== "source")
|
|
25
|
-
invalid(`${id} is a ${note.type}, not a source`);
|
|
26
|
-
if (!hasInboxBody(note))
|
|
27
|
-
invalid(`${id} has no Inbox body the brain writes (a minutes or capture source, or an attached file)`);
|
|
28
|
-
const count = splitParts(note.body).parts.length;
|
|
29
|
-
const raw = args.options.parts;
|
|
30
|
-
const parts = raw === undefined ? undefined : raw.split(",").map((part) => part.trim());
|
|
31
|
-
if (parts !== undefined) {
|
|
32
|
-
if (parts.some((part) => part === ""))
|
|
33
|
-
invalid(`--parts ${raw} has an empty item`);
|
|
34
|
-
const problem = rangesProblem(parts, count);
|
|
35
|
-
if (problem !== null)
|
|
36
|
-
invalid(`--parts ${problem}`);
|
|
37
|
-
}
|
|
38
|
-
const requested = formatTimestamp(now);
|
|
39
|
-
const document = workspace.edit(note);
|
|
40
|
-
document.attributes.notion_text = "full";
|
|
41
|
-
if (parts === undefined)
|
|
42
|
-
delete document.attributes.notion_parts;
|
|
43
|
-
else
|
|
44
|
-
document.attributes.notion_parts = parts;
|
|
45
|
-
document.attributes.notion_text_requested = requested;
|
|
46
|
-
// System fields only: `updated` stays (spec/cli.md §3.6.6).
|
|
47
|
-
workspace.save(note.path, document);
|
|
48
|
-
const errors = workspace.newErrors();
|
|
49
|
-
if (errors.length > 0)
|
|
50
|
-
throw new BrainError("INPUT_INVALID", errors.map((issue) => `${issue.path}: ${issue.rule}: ${issue.message}`).join("; "));
|
|
51
|
-
await workspace.commit([{ op: "full-text", id: note.id, title: String(note.attributes.title), pairs: [["parts", parts === undefined ? "all" : parts.join(",")]] }], now);
|
|
52
|
-
return {
|
|
53
|
-
data: { id: note.id, path: note.path, parts: count, notion_text: "full", notion_parts: parts ?? null, notion_text_requested: requested },
|
|
54
|
-
human: `${note.path}: full text${parts === undefined ? "" : ` (parts ${parts.join(", ")} of ${count})`} on the next publish\n`,
|
|
55
|
-
};
|
|
12
|
+
async run() {
|
|
13
|
+
throw new BrainError("RETIRED", "brain source full-text was retired in 0.6.0: use Keep as document");
|
|
56
14
|
},
|
|
57
15
|
};
|
|
58
16
|
/** `brain source diff <src-id>` (spec/cli.md §5.19, 0045): the lines that changed from the version it replaces. */
|
package/dist/commands/status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { lintBrain } from "../lib/lint.js";
|
|
|
6
6
|
import { scanNotes } from "../lib/notes.js";
|
|
7
7
|
import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
8
8
|
import { isRecord } from "../lib/input.js";
|
|
9
|
+
import { databaseId, modulesOf } from "../lib/modules.js";
|
|
9
10
|
import { expandPath, linkedRepos, workTreeTop } from "../lib/repos.js";
|
|
10
11
|
const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory", "reference"];
|
|
11
12
|
const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
|
|
@@ -61,6 +62,7 @@ export const statusCommand = {
|
|
|
61
62
|
notes: noteCounts,
|
|
62
63
|
proposals: proposalCounts,
|
|
63
64
|
notion,
|
|
65
|
+
modules: modulesOf(brain.config),
|
|
64
66
|
settings,
|
|
65
67
|
repos,
|
|
66
68
|
index_stale: currentIndex !== index,
|
|
@@ -72,12 +74,10 @@ export const statusCommand = {
|
|
|
72
74
|
};
|
|
73
75
|
function readNotion(config) {
|
|
74
76
|
const notion = config.notion;
|
|
75
|
-
const databases = notion.databases;
|
|
76
77
|
const configured = [];
|
|
77
78
|
const missing = [];
|
|
78
|
-
for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts", "
|
|
79
|
-
|
|
80
|
-
(typeof value === "string" && value !== "" ? configured : missing).push(key);
|
|
79
|
+
for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts", "documents"]) {
|
|
80
|
+
(databaseId(config, key) !== "" ? configured : missing).push(key);
|
|
81
81
|
}
|
|
82
82
|
const tasks = notion.tasks;
|
|
83
83
|
return { configured, missing, tasks_database: typeof tasks.database === "string" && tasks.database !== "" };
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readJsonInput } from "../lib/input.js";
|
|
2
2
|
import { applyPlan, buildPlan, parsePlan, parseSnapshot, record, render, renderTasks } from "../lib/sync.js";
|
|
3
3
|
import { Workspace } from "../lib/workspace.js";
|
|
4
|
+
import { modulesOf } from "../lib/modules.js";
|
|
4
5
|
import { BrainError } from "../lib/errors.js";
|
|
5
6
|
import { lintWarnings } from "../lib/warnings.js";
|
|
6
7
|
export const syncPlanCommand = {
|
|
@@ -43,7 +44,8 @@ export const syncRenderCommand = {
|
|
|
43
44
|
export const syncTasksCommand = {
|
|
44
45
|
name: "sync tasks", usage: "brain sync tasks (--meeting <mtg-id> | --source <src-id>) --input <file|-> [--json]", summary: "Render action-item task payloads for a meeting or source.", options: { meeting: "string", source: "string", input: "string" }, maxPositionals: 0, needsBrain: true,
|
|
45
46
|
async run({ args, brain }) { const meeting = args.options.meeting; const source = args.options.source; const input = args.options.input; if ((meeting === undefined) === (source === undefined) || input === undefined)
|
|
46
|
-
throw new BrainError("USAGE", "sync tasks needs exactly one of --meeting or --source, and --input");
|
|
47
|
+
throw new BrainError("USAGE", "sync tasks needs exactly one of --meeting or --source, and --input"); if (!modulesOf(brain.config).tasks)
|
|
48
|
+
throw new BrainError("MODULE_OFF", "The Tasks module is off: brain modules on tasks"); return { data: renderTasks(await Workspace.open(brain), meeting ?? source, await readJsonInput(input, "sync tasks")), human: "Task payloads rendered.\n" }; },
|
|
47
49
|
};
|
|
48
50
|
export const syncRecordCommand = {
|
|
49
51
|
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,
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -5,7 +5,9 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { BrainError } from "../lib/errors.js";
|
|
6
6
|
import { atomicWriteFile, ensureDirectory } from "../lib/fs-safe.js";
|
|
7
7
|
import { appendLog } from "../lib/log.js";
|
|
8
|
+
import { isRecord } from "../lib/input.js";
|
|
8
9
|
import { lintBrain } from "../lib/lint.js";
|
|
10
|
+
import { modulesOf, withModules } from "../lib/modules.js";
|
|
9
11
|
import { scanNotes } from "../lib/notes.js";
|
|
10
12
|
import { assertPlainPath, installedProduct, listShippedSkillFiles } from "../lib/skills.js";
|
|
11
13
|
const exec = promisify(execFile);
|
|
@@ -50,32 +52,33 @@ export const upgradeCommand = {
|
|
|
50
52
|
changes.push({ path: "CLAUDE.md", action: "created", content: "@AGENTS.md\n" });
|
|
51
53
|
else
|
|
52
54
|
unchanged += 1;
|
|
53
|
-
// 0.3.0: Inbox is the former Capture Inbox.
|
|
55
|
+
// 0.3.0: Inbox is the former Capture Inbox; 0.6.0: Documents are the former References (0049).
|
|
56
|
+
// Keep each database id, but retire the old key. 0.6.0 also writes the modules block (0048).
|
|
54
57
|
const brainJson = "brain.json";
|
|
55
58
|
await assertPlainPath(root, brainJson);
|
|
56
59
|
const brainText = await readOptional(join(root, brainJson));
|
|
57
60
|
if (brainText !== null) {
|
|
58
61
|
try {
|
|
59
|
-
|
|
62
|
+
let config = JSON.parse(brainText);
|
|
63
|
+
let changed = false;
|
|
60
64
|
const notion = config.notion;
|
|
61
|
-
if (notion
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for (const [key, value] of Object.entries(values)) {
|
|
69
|
-
if (key === "capture_inbox")
|
|
70
|
-
nextDatabases.inbox = inbox;
|
|
71
|
-
else if (key !== "inbox")
|
|
72
|
-
nextDatabases[key] = value;
|
|
73
|
-
}
|
|
74
|
-
const next = { ...config, notion: { ...notion, databases: nextDatabases } };
|
|
75
|
-
changes.push({ path: brainJson, action: "updated", content: `${JSON.stringify(next, null, 2)}\n` });
|
|
76
|
-
}
|
|
65
|
+
if (isRecord(notion) && isRecord(notion.databases)) {
|
|
66
|
+
let databases = notion.databases;
|
|
67
|
+
for (const [old, key] of [["capture_inbox", "inbox"], ["references", "documents"]]) {
|
|
68
|
+
if (databases[old] === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
databases = renameKey(databases, old, key);
|
|
71
|
+
changed = true;
|
|
77
72
|
}
|
|
73
|
+
if (changed)
|
|
74
|
+
config = { ...config, notion: { ...notion, databases } };
|
|
75
|
+
}
|
|
76
|
+
if (isRecord(config) && !("modules" in config)) {
|
|
77
|
+
config = withModules(config, modulesOf(config));
|
|
78
|
+
changed = true;
|
|
78
79
|
}
|
|
80
|
+
if (changed)
|
|
81
|
+
changes.push({ path: brainJson, action: "updated", content: `${JSON.stringify(config, null, 2)}\n` });
|
|
79
82
|
}
|
|
80
83
|
catch {
|
|
81
84
|
changes.push({ path: brainJson, action: "skipped" });
|
|
@@ -261,3 +264,15 @@ async function gitDirty(root) {
|
|
|
261
264
|
return false;
|
|
262
265
|
return (await exec("git", ["status", "--porcelain"], { cwd: root })).stdout !== "";
|
|
263
266
|
}
|
|
267
|
+
/** Rename `old` to `key` in place; when `key` already holds an id, just drop `old` (spec/cli.md §5.15 step 5). */
|
|
268
|
+
function renameKey(values, old, key) {
|
|
269
|
+
const value = typeof values[key] === "string" && values[key] !== "" ? values[key] : values[old];
|
|
270
|
+
const next = {};
|
|
271
|
+
for (const [name, existing] of Object.entries(values)) {
|
|
272
|
+
if (name === old)
|
|
273
|
+
next[key] = value;
|
|
274
|
+
else if (name !== key)
|
|
275
|
+
next[name] = existing;
|
|
276
|
+
}
|
|
277
|
+
return next;
|
|
278
|
+
}
|
package/dist/lib/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Error codes and exit codes from spec/cli.md §3.4–3.5. */
|
|
2
2
|
export const EXIT_CODES = {
|
|
3
3
|
USAGE: 2,
|
|
4
|
+
RETIRED: 2,
|
|
4
5
|
BRAIN_NOT_CONFIGURED: 3,
|
|
5
6
|
BRAIN_NOT_FOUND: 3,
|
|
6
7
|
BRAIN_IS_PRODUCT_REPO: 3,
|
|
@@ -15,6 +16,7 @@ export const EXIT_CODES = {
|
|
|
15
16
|
REPO_NOT_FOUND: 4,
|
|
16
17
|
REPO_IS_BRAIN: 4,
|
|
17
18
|
REPO_COMMIT_UNKNOWN: 4,
|
|
19
|
+
MODULE_OFF: 4,
|
|
18
20
|
TARGET_NOT_EMPTY: 5,
|
|
19
21
|
ALREADY_A_BRAIN: 5,
|
|
20
22
|
OUTSIDE_ROOT: 5,
|
|
@@ -11,7 +11,7 @@ const SECTIONS = [
|
|
|
11
11
|
["question", "Questions"],
|
|
12
12
|
["meeting", "Meetings"],
|
|
13
13
|
["concept", "Concepts"],
|
|
14
|
-
["reference", "
|
|
14
|
+
["reference", "Documents"],
|
|
15
15
|
];
|
|
16
16
|
const NON_KNOWLEDGE = new Set(["source", "view", "proposal"]);
|
|
17
17
|
const TYPES = [...SECTIONS.map(([type]) => type), "source", "view", "proposal", "memory"];
|
|
@@ -118,7 +118,7 @@ function typeFields(note, byId) {
|
|
|
118
118
|
case "meeting":
|
|
119
119
|
return [["date", scalar(a.date)], ["project", refs(a.projects)]];
|
|
120
120
|
case "reference":
|
|
121
|
-
return [["area", ref(a.area)], ["project", refs(a.projects)]];
|
|
121
|
+
return [["origin", scalar(a.origin)], ["area", ref(a.area)], ["project", refs(a.projects)]];
|
|
122
122
|
default:
|
|
123
123
|
return [];
|
|
124
124
|
}
|
package/dist/lib/lint.js
CHANGED
|
@@ -13,7 +13,7 @@ const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcde
|
|
|
13
13
|
const REFERENCE_KEYS = new Set([
|
|
14
14
|
"area", "goal", "client", "org", "projects", "people", "attendees", "decided_in", "raised_in", "deciders",
|
|
15
15
|
"source", "sources", "decisions", "questions", "answer", "target", "supersedes", "merge_into", "project",
|
|
16
|
-
"generated_from", "facts", "notes", "answer_note", "links", "attached_to", "changes", "replaces", "deferred_from",
|
|
16
|
+
"generated_from", "facts", "notes", "answer_note", "links", "attached_to", "changes", "replaces", "deferred_from", "promoted_from",
|
|
17
17
|
]);
|
|
18
18
|
/** Where each kind of view must live (spec/schema.md §2, 0041, 0043). */
|
|
19
19
|
const VIEW_FOLDERS = {
|
|
@@ -237,8 +237,15 @@ export function lintBrain(input) {
|
|
|
237
237
|
const reference = typeof target === "string" ? byId.get(target) : undefined;
|
|
238
238
|
if (reference !== undefined && reference.type !== "reference")
|
|
239
239
|
err("update-body", lineOfKey(note, a.kind === "update_body" ? "target" : "changes"), `update_body targets ${reference.id}, a ${reference.type}; only a reference has its body replaced (0045)`);
|
|
240
|
+
if (reference?.type === "reference" && reference.attributes.origin === "promoted")
|
|
241
|
+
err("update-body", lineOfKey(note, a.kind === "update_body" ? "target" : "changes"), `update_body targets ${reference.id}, a promoted Document, whose body is yours (0049)`);
|
|
240
242
|
}
|
|
241
243
|
}
|
|
244
|
+
if (note.type === "reference" && typeof a.promoted_from === "string") {
|
|
245
|
+
const from = byId.get(a.promoted_from);
|
|
246
|
+
if (from !== undefined && from.type !== "decision" && from.type !== "concept")
|
|
247
|
+
err("promoted-from", lineOfKey(note, "promoted_from"), `promoted_from names ${a.promoted_from}, a ${from.type}, not a decision or concept (0049)`);
|
|
248
|
+
}
|
|
242
249
|
if (note.type === "source" && typeof a.replaces === "string") {
|
|
243
250
|
const replaced = byId.get(a.replaces);
|
|
244
251
|
if (replaced !== undefined && replaced.type !== "source")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isRecord } from "./input.js";
|
|
2
|
+
/** The optional modules of spec/schema.md §12 (decision 0048), in contract order. */
|
|
3
|
+
export const MODULES = ["knowledge", "orgs", "documents", "reports", "tasks", "planning"];
|
|
4
|
+
export const MODULE_DEFAULTS = {
|
|
5
|
+
knowledge: true,
|
|
6
|
+
orgs: true,
|
|
7
|
+
documents: false,
|
|
8
|
+
reports: false,
|
|
9
|
+
tasks: false,
|
|
10
|
+
planning: false,
|
|
11
|
+
};
|
|
12
|
+
/** The `notion.databases` keys each module owns. Reports and Tasks own none (Tasks uses `notion.tasks`). */
|
|
13
|
+
export const MODULE_DATABASES = {
|
|
14
|
+
knowledge: ["decisions", "questions", "concepts"],
|
|
15
|
+
orgs: ["orgs"],
|
|
16
|
+
documents: ["documents"],
|
|
17
|
+
reports: [],
|
|
18
|
+
tasks: [],
|
|
19
|
+
planning: ["goals", "key_results"],
|
|
20
|
+
};
|
|
21
|
+
const MODULE_FOR_TYPE = {
|
|
22
|
+
decision: "knowledge",
|
|
23
|
+
question: "knowledge",
|
|
24
|
+
concept: "knowledge",
|
|
25
|
+
org: "orgs",
|
|
26
|
+
reference: "documents",
|
|
27
|
+
goal: "planning",
|
|
28
|
+
};
|
|
29
|
+
export function isModuleName(value) {
|
|
30
|
+
return MODULES.includes(value);
|
|
31
|
+
}
|
|
32
|
+
/** Which modules are on: every module when the block is absent, else each absent key at its default. */
|
|
33
|
+
export function modulesOf(config) {
|
|
34
|
+
const block = config.modules;
|
|
35
|
+
if (!isRecord(block))
|
|
36
|
+
return Object.fromEntries(MODULES.map((name) => [name, true]));
|
|
37
|
+
return Object.fromEntries(MODULES.map((name) => [name, typeof block[name] === "boolean" ? block[name] : MODULE_DEFAULTS[name]]));
|
|
38
|
+
}
|
|
39
|
+
/** The module a note type belongs to, or null for a core type (or one that is never published). */
|
|
40
|
+
export function moduleForType(type) {
|
|
41
|
+
return MODULE_FOR_TYPE[type] ?? null;
|
|
42
|
+
}
|
|
43
|
+
/** The module a `notion.databases` key belongs to, or null for a core database. */
|
|
44
|
+
export function moduleForDatabase(key) {
|
|
45
|
+
const name = MODULES.find((module) => MODULE_DATABASES[module].includes(key));
|
|
46
|
+
return name ?? null;
|
|
47
|
+
}
|
|
48
|
+
/** Whether rows of this note type are published and taken in. */
|
|
49
|
+
export function typeIsOn(config, type) {
|
|
50
|
+
const module = moduleForType(type);
|
|
51
|
+
return module === null || modulesOf(config)[module];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read a `notion.databases` id, accepting the keys renamed by `brain upgrade`: `inbox` was
|
|
55
|
+
* `capture_inbox` and `documents` was `references` (schema §12, 0049). Empty string when unset.
|
|
56
|
+
*/
|
|
57
|
+
export function databaseId(config, key) {
|
|
58
|
+
const notion = config.notion;
|
|
59
|
+
const databases = isRecord(notion) && isRecord(notion.databases) ? notion.databases : {};
|
|
60
|
+
const legacy = key === "inbox" ? "capture_inbox" : key === "documents" ? "references" : null;
|
|
61
|
+
const value = databases[key] === undefined && legacy !== null ? databases[legacy] : databases[key];
|
|
62
|
+
return typeof value === "string" ? value : "";
|
|
63
|
+
}
|
|
64
|
+
/** The Notion pieces a module still lacks: its empty database ids, and `tasks` when the tasks database is unset. */
|
|
65
|
+
export function missingFor(config, module) {
|
|
66
|
+
const missing = MODULE_DATABASES[module].filter((key) => databaseId(config, key) === "");
|
|
67
|
+
if (module === "tasks") {
|
|
68
|
+
const notion = config.notion;
|
|
69
|
+
const tasks = isRecord(notion) && isRecord(notion.tasks) ? notion.tasks : {};
|
|
70
|
+
if (typeof tasks.database !== "string" || tasks.database === "")
|
|
71
|
+
missing.push("tasks");
|
|
72
|
+
}
|
|
73
|
+
return missing;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A copy of `config` with `block` as its `modules` block: in place when it has one, else after
|
|
77
|
+
* `settings`, or after `budgets` when there is no `settings`, or at the end (spec/cli.md §5.15 step 5).
|
|
78
|
+
*/
|
|
79
|
+
export function withModules(config, block) {
|
|
80
|
+
const anchor = "modules" in config ? "modules" : "settings" in config ? "settings" : "budgets" in config ? "budgets" : null;
|
|
81
|
+
const next = {};
|
|
82
|
+
for (const [key, value] of Object.entries(config)) {
|
|
83
|
+
if (key !== "modules")
|
|
84
|
+
next[key] = value;
|
|
85
|
+
if (key === anchor)
|
|
86
|
+
next.modules = block;
|
|
87
|
+
}
|
|
88
|
+
if (anchor === null)
|
|
89
|
+
next.modules = block;
|
|
90
|
+
return next;
|
|
91
|
+
}
|
package/dist/lib/note-new.js
CHANGED
|
@@ -3,7 +3,6 @@ import { BrainError } from "./errors.js";
|
|
|
3
3
|
import { serializeNewFile } from "./frontmatter.js";
|
|
4
4
|
import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
5
5
|
import { assertNoProblems, escapePointer, isRecord, unknownKeys } from "./input.js";
|
|
6
|
-
import { rangesProblem, splitParts } from "./parts.js";
|
|
7
6
|
import { newNotePath } from "./paths.js";
|
|
8
7
|
import { formatDate, formatTimestamp } from "./time.js";
|
|
9
8
|
const ITEM_KEYS = ["type", "id", "title", "file_date", "frontmatter", "body"];
|
|
@@ -52,16 +51,16 @@ export function checkNoteInput(value, pointer, withId = true) {
|
|
|
52
51
|
at(`frontmatter/${escapePointer(key)}`, "is set by the CLI");
|
|
53
52
|
}
|
|
54
53
|
if (value.type === "source") {
|
|
55
|
-
//
|
|
56
|
-
if (value.frontmatter.
|
|
57
|
-
at("frontmatter/
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (problem !== null)
|
|
62
|
-
at("frontmatter/notion_parts", problem);
|
|
54
|
+
// Read on existing sources, never written again (spec/cli.md §5.5, 0049).
|
|
55
|
+
if (value.frontmatter.keep !== undefined)
|
|
56
|
+
at("frontmatter/keep", "is the name before 0.6.0: use only_keep");
|
|
57
|
+
for (const key of ["notion_text", "notion_parts", "notion_text_requested"]) {
|
|
58
|
+
if (value.frontmatter[key] !== undefined)
|
|
59
|
+
at(`frontmatter/${key}`, "is retired in 0.6.0: Keep as document gives the whole text");
|
|
63
60
|
}
|
|
64
61
|
}
|
|
62
|
+
if (value.type === "reference" && value.frontmatter.origin === undefined)
|
|
63
|
+
at("frontmatter/origin", "is required on a new Document: whole, part or promoted");
|
|
65
64
|
}
|
|
66
65
|
return problems;
|
|
67
66
|
}
|
|
@@ -125,16 +124,34 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
125
124
|
if (repo !== undefined && !repoNames.has(repo))
|
|
126
125
|
parents.push({ pointer: `${pointerOf(index)}/frontmatter/repo`, message: `${String(repo)} is not a linked repo in brain.json repos` });
|
|
127
126
|
});
|
|
127
|
+
parents.push(...documentProblems(workspace, items, pointerOf));
|
|
128
128
|
assertNoProblems(parents);
|
|
129
|
+
assertPlacesExist(workspace, items, pointerOf);
|
|
129
130
|
const today = formatDate(options.now);
|
|
130
131
|
const taken = new Set([...existing, ...seen]);
|
|
131
132
|
const result = { created: [], skipped: [], pointers: new Map() };
|
|
133
|
+
const wholeKept = new Map();
|
|
132
134
|
items.forEach((item, index) => {
|
|
133
135
|
const lf = item.body.replace(/\r\n?/g, "\n");
|
|
134
136
|
const body = lf === "" || lf.endsWith("\n") ? lf : `${lf}\n`;
|
|
135
137
|
const attributes = { type: item.type, title: item.title, ...item.frontmatter };
|
|
136
138
|
if (KNOWLEDGE_TYPES.has(item.type))
|
|
137
139
|
attributes.lifecycle = options.lifecycle ?? "draft";
|
|
140
|
+
if (item.type === "reference" && attributes.origin === "whole") {
|
|
141
|
+
// The source's own text needs no Review; one whole Document per source (spec/cli.md §5.5, 0049).
|
|
142
|
+
const source = String(attributes.sources[0]);
|
|
143
|
+
const kept = workspace.notes.find((note) => note.type === "reference" && note.attributes.origin === "whole" && Array.isArray(note.attributes.sources) && note.attributes.sources[0] === source)?.id
|
|
144
|
+
?? wholeKept.get(source);
|
|
145
|
+
if (kept !== undefined) {
|
|
146
|
+
result.skipped.push({ input_index: index, reason: "ALREADY_KEPT", existing: kept });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
attributes.lifecycle = "reviewed";
|
|
150
|
+
}
|
|
151
|
+
if (item.type === "reference" && attributes.origin === "promoted") {
|
|
152
|
+
// A promoted Document stands where its note stands (0049).
|
|
153
|
+
attributes.lifecycle = workspace.get(String(attributes.promoted_from))?.attributes.lifecycle ?? attributes.lifecycle;
|
|
154
|
+
}
|
|
138
155
|
if (item.type === "source") {
|
|
139
156
|
const hash = `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
140
157
|
const sourceKey = attributes.source_key;
|
|
@@ -147,13 +164,6 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
147
164
|
}
|
|
148
165
|
attributes.content_hash = hash;
|
|
149
166
|
attributes.captured ??= formatTimestamp(options.now);
|
|
150
|
-
// The kind sets whether the Inbox row shows the text (0043): full for email and chat.
|
|
151
|
-
if (attributes.notion_text === undefined && attributes.attached_to === undefined) {
|
|
152
|
-
if (attributes.kind === "email" || attributes.kind === "chat")
|
|
153
|
-
attributes.notion_text = "full";
|
|
154
|
-
else if (attributes.kind === "document" || attributes.kind === "note")
|
|
155
|
-
attributes.notion_text = "digest";
|
|
156
|
-
}
|
|
157
167
|
}
|
|
158
168
|
const id = item.id ?? generateId(TYPE_PREFIX[item.type], taken);
|
|
159
169
|
taken.add(id);
|
|
@@ -165,10 +175,74 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
165
175
|
const path = newNotePath(item.type, item.title, date, (candidate) => workspace.exists(candidate), attributes.source_key);
|
|
166
176
|
workspace.write(path, serializeNewFile(attributes, body));
|
|
167
177
|
result.created.push({ id, type: item.type, title: item.title, path });
|
|
178
|
+
if (item.type === "reference" && attributes.origin === "whole")
|
|
179
|
+
wholeKept.set(String(attributes.sources[0]), id);
|
|
168
180
|
result.pointers.set(path, pointerOf(index));
|
|
169
181
|
});
|
|
170
182
|
return result;
|
|
171
183
|
}
|
|
184
|
+
/** Normalized body as `stageNotes` writes it: LF line endings and a trailing newline. */
|
|
185
|
+
function writtenBody(body) {
|
|
186
|
+
const lf = body.replace(/\r\n?/g, "\n");
|
|
187
|
+
return lf === "" || lf.endsWith("\n") ? lf : `${lf}\n`;
|
|
188
|
+
}
|
|
189
|
+
function sha256(body) {
|
|
190
|
+
return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
191
|
+
}
|
|
192
|
+
/** The Document rules of spec/cli.md §5.5 (0049) that the JSON Schema can't state. */
|
|
193
|
+
function documentProblems(workspace, items, pointerOf) {
|
|
194
|
+
const problems = [];
|
|
195
|
+
const promoted = new Map();
|
|
196
|
+
for (const note of workspace.notes)
|
|
197
|
+
if (note.type === "reference" && note.attributes.origin === "promoted")
|
|
198
|
+
promoted.set(String(note.attributes.promoted_from), note.id);
|
|
199
|
+
items.forEach((item, index) => {
|
|
200
|
+
if (item.type !== "reference")
|
|
201
|
+
return;
|
|
202
|
+
const at = (key, message) => problems.push({ pointer: `${pointerOf(index)}/${key}`, message });
|
|
203
|
+
const { origin, sources, promoted_from: from } = item.frontmatter;
|
|
204
|
+
if (origin === "whole") {
|
|
205
|
+
if (!Array.isArray(sources) || sources.length !== 1 || typeof sources[0] !== "string" || !sources[0].startsWith("src-")) {
|
|
206
|
+
return at("frontmatter/sources", "a whole Document names exactly one source (src-)");
|
|
207
|
+
}
|
|
208
|
+
const id = sources[0];
|
|
209
|
+
const existing = workspace.get(id);
|
|
210
|
+
const earlier = items.slice(0, index).find((candidate) => candidate.id === id && candidate.type === "source");
|
|
211
|
+
const hash = existing?.type === "source" ? existing.attributes.content_hash : earlier !== undefined ? sha256(writtenBody(earlier.body)) : undefined;
|
|
212
|
+
if (hash === undefined)
|
|
213
|
+
return at("frontmatter/sources", `${id} is not an existing source or one earlier in the batch`);
|
|
214
|
+
if (sha256(writtenBody(item.body)) !== hash)
|
|
215
|
+
at("body", `a whole Document's body must be ${id}'s body byte for byte`);
|
|
216
|
+
}
|
|
217
|
+
if (origin === "promoted" && typeof from === "string") {
|
|
218
|
+
const type = workspace.get(from)?.type;
|
|
219
|
+
if (type !== "decision" && type !== "concept")
|
|
220
|
+
return at("frontmatter/promoted_from", `${from} is not an existing decision or concept`);
|
|
221
|
+
const other = promoted.get(from);
|
|
222
|
+
if (other !== undefined)
|
|
223
|
+
return at("frontmatter/promoted_from", `${from} is already promoted to ${other}`);
|
|
224
|
+
promoted.set(from, item.id ?? `${pointerOf(index)}`);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return problems;
|
|
228
|
+
}
|
|
229
|
+
/** A source's `area` and `projects` name existing notes of those types (spec/cli.md §5.5, 0050). */
|
|
230
|
+
function assertPlacesExist(workspace, items, pointerOf) {
|
|
231
|
+
const problems = [];
|
|
232
|
+
items.forEach((item, index) => {
|
|
233
|
+
if (item.type !== "source")
|
|
234
|
+
return;
|
|
235
|
+
const { area, projects } = item.frontmatter;
|
|
236
|
+
if (typeof area === "string" && workspace.get(area)?.type !== "area")
|
|
237
|
+
problems.push({ pointer: `${pointerOf(index)}/frontmatter/area`, message: `${area} is not an existing area` });
|
|
238
|
+
(Array.isArray(projects) ? projects : []).forEach((project, position) => {
|
|
239
|
+
if (typeof project === "string" && workspace.get(project)?.type !== "project")
|
|
240
|
+
problems.push({ pointer: `${pointerOf(index)}/frontmatter/projects/${position}`, message: `${project} is not an existing project` });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
if (problems.length > 0)
|
|
244
|
+
throw new BrainError("UNKNOWN_ID", `${problems[0].pointer} ${problems[0].message}`, problems);
|
|
245
|
+
}
|
|
172
246
|
/**
|
|
173
247
|
* Throw INPUT_INVALID if the staged writes add lint errors anywhere in the brain (spec/cli.md
|
|
174
248
|
* §3.6.1). Errors in a staged file point at its input item; others name the file and point at `root`.
|
package/dist/lib/proposals.js
CHANGED
|
@@ -376,6 +376,8 @@ function resolveFields(workspace, item, pointer, today) {
|
|
|
376
376
|
if (kind === "update_body") {
|
|
377
377
|
if (target.type !== "reference")
|
|
378
378
|
invalid("target", `is a ${target.type}; update_body replaces the body of a reference`);
|
|
379
|
+
if (target.attributes.origin === "promoted")
|
|
380
|
+
invalid("target", `is a promoted Document; its body is yours, edited in Notion (0049)`);
|
|
379
381
|
fields.before_hash = bodyHash(target.body);
|
|
380
382
|
}
|
|
381
383
|
if (kind === "add_link") {
|