ultimate-brain 0.4.1 → 0.6.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/brain-apply/SKILL.md +35 -22
- package/.agents/skills/brain-catch-up/SKILL.md +51 -36
- package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
- package/.agents/skills/brain-howto/SKILL.md +1 -1
- package/.agents/skills/brain-howto/references/guide.md +117 -48
- package/.agents/skills/brain-ingest/SKILL.md +14 -6
- package/.agents/skills/brain-ingest/references/cli-inputs.md +31 -5
- package/.agents/skills/brain-ingest/references/extraction.md +36 -1
- package/.agents/skills/brain-ingest/references/notion.md +8 -6
- package/.agents/skills/brain-ingest/references/sources.md +36 -14
- package/.agents/skills/brain-notion-change/SKILL.md +2 -0
- package/.agents/skills/brain-query/SKILL.md +18 -8
- package/.agents/skills/brain-sync/SKILL.md +31 -12
- package/.agents/skills/brain-sync/references/formats.md +28 -1
- package/dist/cli.js +5 -4
- package/dist/commands/modules.js +54 -0
- package/dist/commands/report.js +81 -4
- package/dist/commands/source.js +27 -35
- package/dist/commands/status.js +5 -5
- package/dist/commands/sync.js +3 -1
- package/dist/commands/upgrade.js +33 -18
- package/dist/lib/apply.js +237 -33
- package/dist/lib/diff.js +31 -0
- package/dist/lib/errors.js +2 -0
- package/dist/lib/ids.js +2 -0
- package/dist/lib/index-builder.js +6 -1
- package/dist/lib/lint.js +46 -8
- package/dist/lib/modules.js +91 -0
- package/dist/lib/note-new.js +111 -18
- package/dist/lib/paths.js +1 -0
- package/dist/lib/proposals.js +74 -18
- package/dist/lib/query.js +1 -1
- package/dist/lib/schemas.js +1 -1
- package/dist/lib/sync.js +246 -86
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +36 -0
- package/spec/schemas/note.schema.json +105 -18
- package/templates/brain/brain.json +3 -1
- package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The changed lines of `after` against `before`, as `- ` and `+ ` lines with no context, by the
|
|
3
|
+
* longest common subsequence walk of spec/cli.md §5.19 (a tie removes first).
|
|
4
|
+
*/
|
|
5
|
+
export function lineDiff(before, after) {
|
|
6
|
+
const o = before.replace(/\r\n/g, "\n").split("\n");
|
|
7
|
+
const n = after.replace(/\r\n/g, "\n").split("\n");
|
|
8
|
+
const L = Array.from({ length: o.length + 1 }, () => new Array(n.length + 1).fill(0));
|
|
9
|
+
for (let i = o.length - 1; i >= 0; i -= 1) {
|
|
10
|
+
for (let j = n.length - 1; j >= 0; j -= 1)
|
|
11
|
+
L[i][j] = o[i] === n[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
12
|
+
}
|
|
13
|
+
const out = [];
|
|
14
|
+
let i = 0;
|
|
15
|
+
let j = 0;
|
|
16
|
+
while (i < o.length && j < n.length) {
|
|
17
|
+
if (o[i] === n[j]) {
|
|
18
|
+
i += 1;
|
|
19
|
+
j += 1;
|
|
20
|
+
}
|
|
21
|
+
else if (L[i + 1][j] >= L[i][j + 1])
|
|
22
|
+
out.push(`- ${o[i++]}`);
|
|
23
|
+
else
|
|
24
|
+
out.push(`+ ${n[j++]}`);
|
|
25
|
+
}
|
|
26
|
+
while (i < o.length)
|
|
27
|
+
out.push(`- ${o[i++]}`);
|
|
28
|
+
while (j < n.length)
|
|
29
|
+
out.push(`+ ${n[j++]}`);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
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,
|
package/dist/lib/ids.js
CHANGED
|
@@ -10,6 +10,7 @@ export const ID_PREFIXES = [
|
|
|
10
10
|
"dec",
|
|
11
11
|
"que",
|
|
12
12
|
"con",
|
|
13
|
+
"ref",
|
|
13
14
|
"src",
|
|
14
15
|
"mem",
|
|
15
16
|
"prp",
|
|
@@ -44,6 +45,7 @@ export const TYPE_PREFIX = {
|
|
|
44
45
|
decision: "dec",
|
|
45
46
|
question: "que",
|
|
46
47
|
concept: "con",
|
|
48
|
+
reference: "ref",
|
|
47
49
|
source: "src",
|
|
48
50
|
memory: "mem",
|
|
49
51
|
proposal: "prp",
|
|
@@ -11,8 +11,10 @@ const SECTIONS = [
|
|
|
11
11
|
["question", "Questions"],
|
|
12
12
|
["meeting", "Meetings"],
|
|
13
13
|
["concept", "Concepts"],
|
|
14
|
+
["reference", "Documents"],
|
|
14
15
|
];
|
|
15
16
|
const NON_KNOWLEDGE = new Set(["source", "view", "proposal"]);
|
|
17
|
+
const TYPES = [...SECTIONS.map(([type]) => type), "source", "view", "proposal", "memory"];
|
|
16
18
|
/** Computed inverses exposed as backlinks (spec/schema.md §6). */
|
|
17
19
|
const RELATION_INVERSES = {
|
|
18
20
|
about: "mentioned_in",
|
|
@@ -45,7 +47,8 @@ export function buildIndex(allNotes) {
|
|
|
45
47
|
byId.set(note.id, note);
|
|
46
48
|
notes.push(note);
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
// Every type has a count, zero included (spec/cli.md §5.8).
|
|
51
|
+
const counts = Object.fromEntries(TYPES.map((type) => [type, 0]));
|
|
49
52
|
for (const note of notes)
|
|
50
53
|
counts[note.type] = (counts[note.type] ?? 0) + 1;
|
|
51
54
|
return {
|
|
@@ -114,6 +117,8 @@ function typeFields(note, byId) {
|
|
|
114
117
|
return [["kind", scalar(a.kind)], ["status", notionField("status")]];
|
|
115
118
|
case "meeting":
|
|
116
119
|
return [["date", scalar(a.date)], ["project", refs(a.projects)]];
|
|
120
|
+
case "reference":
|
|
121
|
+
return [["origin", scalar(a.origin)], ["area", ref(a.area)], ["project", refs(a.projects)]];
|
|
117
122
|
default:
|
|
118
123
|
return [];
|
|
119
124
|
}
|
package/dist/lib/lint.js
CHANGED
|
@@ -8,12 +8,12 @@ import { rangesProblem, splitParts } from "./parts.js";
|
|
|
8
8
|
import { sourceFolder } from "./paths.js";
|
|
9
9
|
import { describeErrors, loadSchemas } from "./schemas.js";
|
|
10
10
|
import { deriveView, generatedBlockRange } from "./views.js";
|
|
11
|
-
const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
|
|
11
|
+
const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
|
|
12
12
|
/** Frontmatter keys whose values are references (spec/schema.md §3–§11); `relations` and `timeline` are handled by shape. */
|
|
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",
|
|
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 = {
|
|
@@ -32,12 +32,13 @@ const FOLDERS = {
|
|
|
32
32
|
decision: /^knowledge\/decisions\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
33
33
|
question: /^knowledge\/questions\/[^/]+\.md$/,
|
|
34
34
|
concept: /^knowledge\/concepts\/[^/]+\.md$/,
|
|
35
|
+
reference: /^knowledge\/references\/[^/]+\.md$/,
|
|
35
36
|
source: /^sources\/(notion|files|text)\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
36
37
|
memory: /^memories\/\d{4}\/\d{2}\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
37
38
|
proposal: /^_proposals\/[^/]+\.md$/,
|
|
38
39
|
};
|
|
39
|
-
const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
40
|
-
const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept"]);
|
|
40
|
+
const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
|
|
41
|
+
const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept", "reference"]);
|
|
41
42
|
const FRESHNESS_WORDS = /\b(currently|now|at the moment|this week|recently|soon)\b/i;
|
|
42
43
|
const HAS_DATE = /\b\d{4}-\d{2}(-\d{2})?\b/;
|
|
43
44
|
const MEMORY_REACH = { general: null, area: "areas", project: "projects", stack: "stacks" };
|
|
@@ -75,6 +76,12 @@ export function lintBrain(input) {
|
|
|
75
76
|
for (const id of new Set(list(note.attributes.notes).map(String)))
|
|
76
77
|
reviewedBy.set(id, [...(reviewedBy.get(id) ?? []), note.id]);
|
|
77
78
|
}
|
|
79
|
+
// Every source replacing each source id (rule source-replaces, 0045).
|
|
80
|
+
const replacedBy = new Map();
|
|
81
|
+
for (const note of input.notes) {
|
|
82
|
+
if (note.type === "source" && typeof note.attributes.replaces === "string")
|
|
83
|
+
replacedBy.set(note.attributes.replaces, [...(replacedBy.get(note.attributes.replaces) ?? []), note.id]);
|
|
84
|
+
}
|
|
78
85
|
const onDisk = caseSensitiveExists(input.root);
|
|
79
86
|
const exists = (path) => input.pendingPaths?.has(path) === true || onDisk(path);
|
|
80
87
|
for (const note of input.notes) {
|
|
@@ -218,11 +225,35 @@ export function lintBrain(input) {
|
|
|
218
225
|
const listed = byId.get(String(id));
|
|
219
226
|
if (listed !== undefined && !KNOWLEDGE.has(listed.type))
|
|
220
227
|
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is a ${listed.type}, not a knowledge note`);
|
|
221
|
-
|
|
228
|
+
// A deferred row lists again the notes the row it came from excluded (0045).
|
|
229
|
+
const others = (reviewedBy.get(String(id)) ?? []).filter((other) => other !== note.id && !deferredPair(note, byId.get(other), String(id)));
|
|
222
230
|
if (others.length > 0)
|
|
223
231
|
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is also listed by ${others.join(", ")}`);
|
|
224
232
|
}
|
|
225
233
|
}
|
|
234
|
+
if (note.type === "proposal") {
|
|
235
|
+
const bodies = [...(a.kind === "update_body" ? [a.target] : []), ...list(a.changes).map(record).filter((change) => change.kind === "update_body").map((change) => change.target)];
|
|
236
|
+
for (const target of bodies) {
|
|
237
|
+
const reference = typeof target === "string" ? byId.get(target) : undefined;
|
|
238
|
+
if (reference !== undefined && reference.type !== "reference")
|
|
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)`);
|
|
242
|
+
}
|
|
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
|
+
}
|
|
249
|
+
if (note.type === "source" && typeof a.replaces === "string") {
|
|
250
|
+
const replaced = byId.get(a.replaces);
|
|
251
|
+
if (replaced !== undefined && replaced.type !== "source")
|
|
252
|
+
err("source-replaces", lineOfKey(note, "replaces"), `replaces ${a.replaces}, a ${replaced.type}, not a source`);
|
|
253
|
+
const others = (replacedBy.get(a.replaces) ?? []).filter((other) => other !== note.id);
|
|
254
|
+
if (others.length > 0)
|
|
255
|
+
err("source-replaces", lineOfKey(note, "replaces"), `${a.replaces} is also replaced by ${others.join(", ")}`);
|
|
256
|
+
}
|
|
226
257
|
if (note.type === "source" && Array.isArray(a.notion_parts)) {
|
|
227
258
|
const problem = rangesProblem(a.notion_parts, splitParts(note.body).parts.length);
|
|
228
259
|
if (problem !== null)
|
|
@@ -253,10 +284,10 @@ function proposalStatusProblem(a) {
|
|
|
253
284
|
const expected = {
|
|
254
285
|
pending: [undefined],
|
|
255
286
|
approved: ["approve"],
|
|
256
|
-
edited: ["edit"],
|
|
287
|
+
edited: ["fix", "edit"],
|
|
257
288
|
rejected: ["reject"],
|
|
258
|
-
applied: ["approve", "edit"],
|
|
259
|
-
closed: stale ? ["approve", "edit"] : ["reject"],
|
|
289
|
+
applied: ["approve", "fix", "edit"],
|
|
290
|
+
closed: stale ? ["approve", "fix", "edit"] : ["reject"],
|
|
260
291
|
};
|
|
261
292
|
const allowed = expected[String(status)];
|
|
262
293
|
if (allowed === undefined)
|
|
@@ -266,6 +297,13 @@ function proposalStatusProblem(a) {
|
|
|
266
297
|
}
|
|
267
298
|
return null;
|
|
268
299
|
}
|
|
300
|
+
/** A deferred row and the row whose excluded note it took over (spec/schema.md §15, 0045). */
|
|
301
|
+
function deferredPair(note, other, id) {
|
|
302
|
+
if (other === undefined)
|
|
303
|
+
return false;
|
|
304
|
+
const excluded = (proposal) => list(record(proposal.attributes.decision).exclude).some((entry) => record(entry).note === id);
|
|
305
|
+
return (note.attributes.deferred_from === other.id && excluded(other)) || (other.attributes.deferred_from === note.id && excluded(note));
|
|
306
|
+
}
|
|
269
307
|
function collectIds(value, out = []) {
|
|
270
308
|
if (typeof value === "string") {
|
|
271
309
|
if (ID.test(value))
|
|
@@ -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,11 +3,10 @@ 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"];
|
|
10
|
-
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
9
|
+
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
|
|
11
10
|
const NEW_NOTE_TYPES = new Set([...KNOWLEDGE_TYPES, "source"]);
|
|
12
11
|
const FORBIDDEN_KEYS = ["id", "type", "title", "created", "updated", "lifecycle"];
|
|
13
12
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -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
|
}
|
|
@@ -100,6 +99,24 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
100
99
|
if (problem !== undefined)
|
|
101
100
|
parents.push({ pointer: `${pointerOf(index)}/frontmatter/attached_to`, message: problem });
|
|
102
101
|
});
|
|
102
|
+
// replaces names a source, existing or earlier in the batch, that no other source replaces (spec/cli.md §5.5, 0045).
|
|
103
|
+
const replaced = new Map();
|
|
104
|
+
for (const note of workspace.notes)
|
|
105
|
+
if (note.type === "source" && typeof note.attributes.replaces === "string")
|
|
106
|
+
replaced.set(note.attributes.replaces, note.id);
|
|
107
|
+
items.forEach((item, index) => {
|
|
108
|
+
const earlier = item.type === "source" ? item.frontmatter.replaces : undefined;
|
|
109
|
+
if (earlier === undefined)
|
|
110
|
+
return;
|
|
111
|
+
const type = workspace.get(String(earlier))?.type ?? items.slice(0, index).find((candidate) => candidate.id === earlier)?.type;
|
|
112
|
+
const problem = type === undefined ? `${String(earlier)} is not an existing source or one earlier in the batch`
|
|
113
|
+
: type !== "source" ? `${String(earlier)} is a ${type}, not a source`
|
|
114
|
+
: replaced.has(String(earlier)) ? `${String(earlier)} is already replaced by ${replaced.get(String(earlier))}`
|
|
115
|
+
: undefined;
|
|
116
|
+
if (problem !== undefined)
|
|
117
|
+
parents.push({ pointer: `${pointerOf(index)}/frontmatter/replaces`, message: problem });
|
|
118
|
+
replaced.set(String(earlier), item.id ?? `/notes/${index}`);
|
|
119
|
+
});
|
|
103
120
|
// A repo file names a linked repo of brain.json (0042).
|
|
104
121
|
const repoNames = new Set(Array.isArray(workspace.config.repos) ? workspace.config.repos.filter(isRecord).map((repo) => repo.name) : []);
|
|
105
122
|
items.forEach((item, index) => {
|
|
@@ -107,20 +124,39 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
107
124
|
if (repo !== undefined && !repoNames.has(repo))
|
|
108
125
|
parents.push({ pointer: `${pointerOf(index)}/frontmatter/repo`, message: `${String(repo)} is not a linked repo in brain.json repos` });
|
|
109
126
|
});
|
|
127
|
+
parents.push(...documentProblems(workspace, items, pointerOf));
|
|
110
128
|
assertNoProblems(parents);
|
|
129
|
+
assertPlacesExist(workspace, items, pointerOf);
|
|
111
130
|
const today = formatDate(options.now);
|
|
112
131
|
const taken = new Set([...existing, ...seen]);
|
|
113
132
|
const result = { created: [], skipped: [], pointers: new Map() };
|
|
133
|
+
const wholeKept = new Map();
|
|
114
134
|
items.forEach((item, index) => {
|
|
115
135
|
const lf = item.body.replace(/\r\n?/g, "\n");
|
|
116
136
|
const body = lf === "" || lf.endsWith("\n") ? lf : `${lf}\n`;
|
|
117
137
|
const attributes = { type: item.type, title: item.title, ...item.frontmatter };
|
|
118
138
|
if (KNOWLEDGE_TYPES.has(item.type))
|
|
119
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
|
+
}
|
|
120
155
|
if (item.type === "source") {
|
|
121
156
|
const hash = `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
122
157
|
const sourceKey = attributes.source_key;
|
|
123
|
-
|
|
158
|
+
// A new version (replaces) is compared by its text only, so a corrected input can be read again (0045).
|
|
159
|
+
const duplicateByKey = attributes.replaces === undefined && typeof sourceKey === "string" && (sourceKey.startsWith("file:") || attributes.kind === "capture");
|
|
124
160
|
const duplicate = workspace.notes.find((note) => note.type === "source" && (note.attributes.content_hash === hash || (duplicateByKey && note.attributes.source_key === sourceKey)));
|
|
125
161
|
if (duplicate !== undefined) {
|
|
126
162
|
result.skipped.push({ input_index: index, reason: "DUPLICATE_SOURCE", existing: duplicate.id });
|
|
@@ -128,13 +164,6 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
128
164
|
}
|
|
129
165
|
attributes.content_hash = hash;
|
|
130
166
|
attributes.captured ??= formatTimestamp(options.now);
|
|
131
|
-
// The kind sets whether the Inbox row shows the text (0043): full for email and chat.
|
|
132
|
-
if (attributes.notion_text === undefined && attributes.attached_to === undefined) {
|
|
133
|
-
if (attributes.kind === "email" || attributes.kind === "chat")
|
|
134
|
-
attributes.notion_text = "full";
|
|
135
|
-
else if (attributes.kind === "document" || attributes.kind === "note")
|
|
136
|
-
attributes.notion_text = "digest";
|
|
137
|
-
}
|
|
138
167
|
}
|
|
139
168
|
const id = item.id ?? generateId(TYPE_PREFIX[item.type], taken);
|
|
140
169
|
taken.add(id);
|
|
@@ -146,10 +175,74 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
146
175
|
const path = newNotePath(item.type, item.title, date, (candidate) => workspace.exists(candidate), attributes.source_key);
|
|
147
176
|
workspace.write(path, serializeNewFile(attributes, body));
|
|
148
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);
|
|
149
180
|
result.pointers.set(path, pointerOf(index));
|
|
150
181
|
});
|
|
151
182
|
return result;
|
|
152
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
|
+
}
|
|
153
246
|
/**
|
|
154
247
|
* Throw INPUT_INVALID if the staged writes add lint errors anywhere in the brain (spec/cli.md
|
|
155
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/paths.js
CHANGED
|
@@ -49,6 +49,7 @@ function layout(type, slug, date, sourceKey) {
|
|
|
49
49
|
case "org": return file(`knowledge/orgs/${slug}.md`);
|
|
50
50
|
case "question": return file(`knowledge/questions/${slug}.md`);
|
|
51
51
|
case "concept": return file(`knowledge/concepts/${slug}.md`);
|
|
52
|
+
case "reference": return file(`knowledge/references/${slug}.md`);
|
|
52
53
|
case "meeting": return file(`knowledge/meetings/${date}-${slug}.md`);
|
|
53
54
|
case "decision": return file(`knowledge/decisions/${date}-${slug}.md`);
|
|
54
55
|
case "source": return file(`sources/${sourceFolder(sourceKey)}/${date}-${slug}.md`);
|