ultimate-brain 0.3.2 → 0.4.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 +10 -6
- package/.agents/skills/brain-catch-up/SKILL.md +27 -14
- package/.agents/skills/brain-howto/references/guide.md +44 -11
- package/.agents/skills/brain-ingest/SKILL.md +14 -8
- package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
- package/.agents/skills/brain-ingest/references/extraction.md +11 -7
- package/.agents/skills/brain-ingest/references/notion.md +25 -11
- package/.agents/skills/brain-ingest/references/sources.md +24 -1
- package/.agents/skills/brain-notion-change/SKILL.md +16 -2
- package/.agents/skills/brain-query/SKILL.md +22 -2
- package/.agents/skills/brain-sync/SKILL.md +6 -4
- package/.agents/skills/brain-sync/references/formats.md +1 -1
- package/dist/cli.js +5 -2
- package/dist/commands/proposal.js +21 -1
- package/dist/commands/repo.js +110 -0
- package/dist/commands/report.js +115 -0
- package/dist/commands/source.js +56 -0
- package/dist/commands/status.js +4 -1
- package/dist/commands/views.js +30 -15
- package/dist/lib/apply.js +83 -10
- package/dist/lib/errors.js +3 -0
- package/dist/lib/frontmatter.js +9 -4
- package/dist/lib/lint.js +34 -5
- package/dist/lib/note-new.js +44 -0
- package/dist/lib/notes.js +1 -0
- package/dist/lib/parts.js +82 -0
- package/dist/lib/proposals.js +183 -8
- package/dist/lib/repos.js +129 -0
- package/dist/lib/sync.js +165 -26
- package/dist/lib/views.js +53 -15
- package/package.json +1 -1
- package/templates/brain/AGENTS.md +4 -4
package/dist/commands/views.js
CHANGED
|
@@ -3,36 +3,50 @@ import { serializeNewFile } from "../lib/frontmatter.js";
|
|
|
3
3
|
import { generateId } from "../lib/ids.js";
|
|
4
4
|
import { compareCodePoints } from "../lib/notes.js";
|
|
5
5
|
import { formatDate, formatTimestamp } from "../lib/time.js";
|
|
6
|
-
import { deriveView, generatedBlockRange, projectViewPath } from "../lib/views.js";
|
|
6
|
+
import { areaBriefPath, deriveView, generatedBlockRange, projectViewPath } from "../lib/views.js";
|
|
7
7
|
import { Workspace } from "../lib/workspace.js";
|
|
8
|
-
/** `brain views [--project <id>] [--check]` (spec/cli.md §5.9). */
|
|
8
|
+
/** `brain views [--project <id> | --area <id>] [--check]` (spec/cli.md §5.9). */
|
|
9
9
|
export const viewsCommand = {
|
|
10
10
|
name: "views",
|
|
11
|
-
usage: "brain views [--project <id>] [--check] [--json]",
|
|
12
|
-
summary: "Regenerate project briefs
|
|
13
|
-
options: { project: "string", check: "boolean" },
|
|
11
|
+
usage: "brain views [--project <id> | --area <id>] [--check] [--json]",
|
|
12
|
+
summary: "Regenerate project briefs, decision logs and area briefs while preserving user blocks.",
|
|
13
|
+
options: { project: "string", area: "string", check: "boolean" },
|
|
14
14
|
maxPositionals: 0,
|
|
15
15
|
needsBrain: true,
|
|
16
16
|
async run({ args, brain, now }) {
|
|
17
17
|
const workspace = await Workspace.open(brain);
|
|
18
18
|
const requested = args.options.project;
|
|
19
|
-
|
|
19
|
+
const requestedArea = args.options.area;
|
|
20
|
+
if (requested !== undefined && requestedArea !== undefined)
|
|
21
|
+
throw new BrainError("USAGE", "Use --project or --area, not both");
|
|
22
|
+
let projects = requestedArea === undefined ? workspace.notes.filter((note) => note.type === "project") : [];
|
|
23
|
+
let areas = requested === undefined ? workspace.notes.filter((note) => note.type === "area") : [];
|
|
20
24
|
if (requested !== undefined) {
|
|
21
25
|
const project = workspace.get(requested);
|
|
22
26
|
if (project?.type !== "project")
|
|
23
27
|
throw new BrainError("UNKNOWN_ID", `No project with id ${requested}`);
|
|
24
28
|
projects = [project];
|
|
25
29
|
}
|
|
30
|
+
if (requestedArea !== undefined) {
|
|
31
|
+
const area = workspace.get(requestedArea);
|
|
32
|
+
if (area?.type !== "area")
|
|
33
|
+
throw new BrainError("UNKNOWN_ID", `No area with id ${requestedArea}`);
|
|
34
|
+
areas = [area];
|
|
35
|
+
}
|
|
26
36
|
projects.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
37
|
+
areas.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
38
|
+
const targets = [
|
|
39
|
+
...projects.flatMap((project) => ["brief", "decision_log"].map((kind) => ({ owner: project, kind, path: projectViewPath(project, kind) }))),
|
|
40
|
+
...areas.map((area) => ({ owner: area, kind: "area_brief", path: areaBriefPath(area) })),
|
|
41
|
+
];
|
|
27
42
|
const written = [];
|
|
28
43
|
const created = [];
|
|
29
44
|
const unchanged = [];
|
|
30
45
|
const errors = [];
|
|
31
46
|
const ids = workspace.ids();
|
|
32
47
|
const check = args.options.check === true;
|
|
33
|
-
for (const project of
|
|
34
|
-
|
|
35
|
-
const path = projectViewPath(project, kind);
|
|
48
|
+
for (const { owner: project, kind, path } of targets) {
|
|
49
|
+
{
|
|
36
50
|
const existing = workspace.notes.find((note) => note.path === path);
|
|
37
51
|
const derived = deriveView(project, kind, workspace.notes);
|
|
38
52
|
if (existing === undefined && workspace.exists(path)) {
|
|
@@ -48,7 +62,8 @@ export const viewsCommand = {
|
|
|
48
62
|
}
|
|
49
63
|
continue;
|
|
50
64
|
}
|
|
51
|
-
const
|
|
65
|
+
const owned = kind === "area_brief" ? existing.attributes.area === project.id : existing.attributes.project === project.id;
|
|
66
|
+
const range = existing.type === "view" && owned && existing.attributes.view_kind === kind
|
|
52
67
|
? generatedBlockRange(existing.body)
|
|
53
68
|
: undefined;
|
|
54
69
|
if (range === undefined) {
|
|
@@ -86,20 +101,20 @@ export const viewsCommand = {
|
|
|
86
101
|
errors.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
87
102
|
const exitCode = errors.length > 0 || (check && (written.length > 0 || created.length > 0)) ? 1 : 0;
|
|
88
103
|
return {
|
|
89
|
-
data: { projects: projects.length, written, created, unchanged, errors, check },
|
|
104
|
+
data: { projects: projects.length, areas: areas.length, written, created, unchanged, errors, check },
|
|
90
105
|
exitCode,
|
|
91
106
|
human: errors.length > 0 ? `${errors.map((error) => `${error.path}: ${error.reason}`).join("\n")}\n` : "Views are current.\n",
|
|
92
107
|
};
|
|
93
108
|
},
|
|
94
109
|
};
|
|
95
|
-
function newViewAttributes(id,
|
|
96
|
-
const suffix = kind === "
|
|
110
|
+
function newViewAttributes(id, owner, kind, generatedFrom, now) {
|
|
111
|
+
const suffix = kind === "decision_log" ? "decision log" : "brief";
|
|
97
112
|
return {
|
|
98
113
|
id,
|
|
99
114
|
type: "view",
|
|
100
|
-
title: `${String(
|
|
115
|
+
title: `${String(owner.attributes.title)} — ${suffix}`,
|
|
101
116
|
view_kind: kind,
|
|
102
|
-
project:
|
|
117
|
+
...(kind === "area_brief" ? { area: owner.id } : { project: owner.id }),
|
|
103
118
|
generated_from: generatedFrom,
|
|
104
119
|
generated_at: formatTimestamp(now),
|
|
105
120
|
created: formatDate(now),
|
package/dist/lib/apply.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stageNewItem } from "./proposals.js";
|
|
|
6
6
|
import { loadSchemas } from "./schemas.js";
|
|
7
7
|
import { formatDate, normalizeTimestamp } from "./time.js";
|
|
8
8
|
const ACTIONS = ["approve", "reject", "edit"];
|
|
9
|
-
export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note"]);
|
|
9
|
+
export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note", "answer"]);
|
|
10
10
|
/** Reference fields a merge leaves alone: the meeting side of a persisted pair (spec/schema.md §11.2). */
|
|
11
11
|
const PAIR_FIELDS = new Set(["decisions", "questions", "decided_in", "raised_in"]);
|
|
12
12
|
/** Corrections change knowledge notes only: sources are immutable and proposals are records. */
|
|
@@ -60,8 +60,8 @@ export function parseApplyInput(input) {
|
|
|
60
60
|
problems.push({ pointer: `${pointer}/value`, message: "is only allowed for edit" });
|
|
61
61
|
if (item.note !== undefined && typeof item.note !== "string")
|
|
62
62
|
problems.push({ pointer: `${pointer}/note`, message: "must be a string" });
|
|
63
|
-
if (item.exclude !== undefined && (!Array.isArray(item.exclude) || item.exclude.some((entry) => !isRecord(entry) || typeof entry.field
|
|
64
|
-
problems.push({ pointer: `${pointer}/exclude`, message: "must be a list of {field, value}
|
|
63
|
+
if (item.exclude !== undefined && (!Array.isArray(item.exclude) || item.exclude.some((entry) => !isRecord(entry) || !(isNoteExclude(entry) || (typeof entry.field === "string" && Object.hasOwn(entry, "value") && Object.keys(entry).every((key) => ["target", "field", "value"].includes(key)))))))
|
|
64
|
+
problems.push({ pointer: `${pointer}/exclude`, message: "must be a list of {field, value}, {target, field, value} or {note} entries" });
|
|
65
65
|
if (!isTimestamp(item.decided_at))
|
|
66
66
|
problems.push({ pointer: `${pointer}/decided_at`, message: "must be an ISO 8601 UTC timestamp" });
|
|
67
67
|
if (item.decided_by !== "notion" && item.decided_by !== "chat")
|
|
@@ -193,11 +193,23 @@ function applyDecision(workspace, input, today, recordOnly) {
|
|
|
193
193
|
throw new ItemFailure("EDIT_NOT_ALLOWED", `edit is not allowed on a ${String(p.kind)} proposal (spec/schema.md §11.1)`);
|
|
194
194
|
}
|
|
195
195
|
if (decision.exclude !== undefined) {
|
|
196
|
-
if (decision.action !== "approve" || p.kind !== "add_link")
|
|
197
|
-
throw new ItemFailure("INPUT_INVALID", "exclude is only allowed on an approved add_link proposal");
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
if (decision.action !== "approve" || (p.kind !== "add_link" && p.kind !== "review_notes"))
|
|
197
|
+
throw new ItemFailure("INPUT_INVALID", "exclude is only allowed on an approved add_link or review_notes proposal");
|
|
198
|
+
if (p.kind === "add_link") {
|
|
199
|
+
if (decision.exclude.some((excluded) => excluded.target !== undefined || isNoteExclude(excluded)))
|
|
200
|
+
throw new ItemFailure("INPUT_INVALID", "exclude on an add_link takes {field, value} entries");
|
|
201
|
+
const links = proposalLinks(p);
|
|
202
|
+
if (decision.exclude.some((excluded) => !links.some((link) => link.field === excluded.field && isDeepStrictEqual(link.value, excluded.value))))
|
|
203
|
+
throw new ItemFailure("EXCLUDE_UNKNOWN", "exclude contains a link that is not in the proposal");
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
if (decision.exclude.some((excluded) => !isNoteExclude(excluded) && excluded.target === undefined))
|
|
207
|
+
throw new ItemFailure("INPUT_INVALID", "exclude on a review_notes takes {note} and {target, field, value} entries");
|
|
208
|
+
const notes = list(p.notes);
|
|
209
|
+
const links = reviewLinks(p);
|
|
210
|
+
if (decision.exclude.some((excluded) => isNoteExclude(excluded) ? !notes.includes(excluded.note) : !links.some((link) => sameLink(link, excluded))))
|
|
211
|
+
throw new ItemFailure("EXCLUDE_UNKNOWN", "exclude contains an entry that is not in the proposal");
|
|
212
|
+
}
|
|
201
213
|
}
|
|
202
214
|
const action = decision.action === incoming.action ? undefined : decision.action;
|
|
203
215
|
p.decision = decision;
|
|
@@ -210,11 +222,19 @@ function applyDecision(workspace, input, today, recordOnly) {
|
|
|
210
222
|
if (decision.action === "reject") {
|
|
211
223
|
p.status = "closed";
|
|
212
224
|
workspace.save(proposal.path, document);
|
|
213
|
-
|
|
225
|
+
// A rejected review_notes archives the notes still draft, with no successor (spec/schema.md §11.2).
|
|
226
|
+
const notes = p.kind === "review_notes" ? reviewNotes(workspace, p, "reject", [], today) : undefined;
|
|
227
|
+
return { result: "closed", action, ...(notes === undefined ? {} : { notes }) };
|
|
214
228
|
}
|
|
215
229
|
const D = decision.decided_at.slice(0, 10);
|
|
216
230
|
const V = decision.action === "edit" ? decision.value : undefined;
|
|
217
|
-
|
|
231
|
+
if (p.kind === "review_notes") {
|
|
232
|
+
const notes = reviewNotes(workspace, p, "approve", decision.exclude ?? [], today);
|
|
233
|
+
p.status = "applied";
|
|
234
|
+
workspace.save(proposal.path, document);
|
|
235
|
+
return { result: "applied", action, notes };
|
|
236
|
+
}
|
|
237
|
+
const stale = applyKind(workspace, p, proposal.path, V, D, today, (decision.exclude ?? []));
|
|
218
238
|
if (stale !== null) {
|
|
219
239
|
p.status = "closed";
|
|
220
240
|
p.closed_reason = "stale";
|
|
@@ -276,6 +296,10 @@ function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
|
|
|
276
296
|
case "merge":
|
|
277
297
|
merge(workspace, target, existing(workspace, String(p.merge_into)), today);
|
|
278
298
|
return null;
|
|
299
|
+
case "answer":
|
|
300
|
+
// edit: your Note is the answer, as text; otherwise the note that holds it, else the sentence.
|
|
301
|
+
a.answer = V !== undefined ? String(V) : p.answer_note ?? p.answer;
|
|
302
|
+
break;
|
|
279
303
|
default:
|
|
280
304
|
throw new ItemFailure("INVALID_RESULT", `Unknown proposal kind ${kind}`);
|
|
281
305
|
}
|
|
@@ -283,6 +307,55 @@ function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
|
|
|
283
307
|
workspace.save(target.path, document);
|
|
284
308
|
return null;
|
|
285
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Apply or reject a review_notes proposal (spec/schema.md §11.2): approve marks the listed draft
|
|
312
|
+
* notes `reviewed` and applies the links, reject archives the draft notes. Excluded entries stay
|
|
313
|
+
* as they are; a note no longer `draft` is left alone and reported `unchanged`.
|
|
314
|
+
*/
|
|
315
|
+
function reviewNotes(workspace, p, action, exclude, today) {
|
|
316
|
+
const results = [];
|
|
317
|
+
for (const id of list(p.notes).map(String)) {
|
|
318
|
+
const note = existing(workspace, id);
|
|
319
|
+
const lifecycle = String(note.attributes.lifecycle);
|
|
320
|
+
const excluded = exclude.some((entry) => isNoteExclude(entry) && entry.note === id);
|
|
321
|
+
if (lifecycle !== "draft")
|
|
322
|
+
results.push({ id, result: "unchanged", lifecycle });
|
|
323
|
+
else if (excluded)
|
|
324
|
+
results.push({ id, result: "kept_draft", lifecycle });
|
|
325
|
+
else {
|
|
326
|
+
const next = action === "approve" ? "reviewed" : "archived";
|
|
327
|
+
const document = workspace.edit(note);
|
|
328
|
+
document.attributes.lifecycle = next;
|
|
329
|
+
document.attributes.updated = today;
|
|
330
|
+
workspace.save(note.path, document);
|
|
331
|
+
results.push({ id, result: action === "approve" ? "reviewed" : "archived", lifecycle: next });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (action === "approve") {
|
|
335
|
+
for (const link of reviewLinks(p)) {
|
|
336
|
+
if (exclude.some((entry) => sameLink(link, entry)))
|
|
337
|
+
continue;
|
|
338
|
+
const target = existing(workspace, String(link.target));
|
|
339
|
+
const document = workspace.edit(target);
|
|
340
|
+
const values = list(document.attributes[String(link.field)]);
|
|
341
|
+
if (values.some((value) => isDeepStrictEqual(value, link.value)))
|
|
342
|
+
continue;
|
|
343
|
+
document.attributes[String(link.field)] = [...values, link.value];
|
|
344
|
+
document.attributes.updated = today;
|
|
345
|
+
workspace.save(target.path, document);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
function reviewLinks(proposal) {
|
|
351
|
+
return list(proposal.links).filter(isRecord).map((link) => ({ target: link.target, field: link.field, value: link.value }));
|
|
352
|
+
}
|
|
353
|
+
function sameLink(link, entry) {
|
|
354
|
+
return entry.target === link.target && entry.field === link.field && isDeepStrictEqual(entry.value, link.value);
|
|
355
|
+
}
|
|
356
|
+
function isNoteExclude(entry) {
|
|
357
|
+
return typeof entry.note === "string" && Object.keys(entry).length === 1;
|
|
358
|
+
}
|
|
286
359
|
function proposalLinks(proposal) {
|
|
287
360
|
if (Array.isArray(proposal.links))
|
|
288
361
|
return proposal.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
|
package/dist/lib/errors.js
CHANGED
package/dist/lib/frontmatter.js
CHANGED
|
@@ -16,13 +16,15 @@ const TYPE_FIELDS = {
|
|
|
16
16
|
meeting: ["date", "attendees", "source", "decisions", "questions", "tasks"],
|
|
17
17
|
decision: ["date", "assessment", "decided_in", "deciders", "valid_from", "valid_until"],
|
|
18
18
|
question: ["kind", "raised_in", "answer"],
|
|
19
|
-
source: ["source_key", "kind", "file_name", "converter", "content_hash", "captured", "origin_edited"],
|
|
20
|
-
view: ["view_kind", "project", "generated_from", "generated_at"],
|
|
19
|
+
source: ["source_key", "kind", "file_name", "converter", "content_hash", "captured", "origin_edited", "repo", "repo_path", "commit", "attached_to"],
|
|
20
|
+
view: ["view_kind", "project", "area", "projects", "question", "asked_in", "generated_from", "generated_at"],
|
|
21
21
|
memory: ["scope", "areas", "projects", "stacks", "confidence", "origin"],
|
|
22
22
|
};
|
|
23
|
+
/** A source's system fields, last and in this order (spec/cli.md §3.6.5). */
|
|
24
|
+
const SOURCE_SYSTEM = ["notion_text", "notion_parts", "notion_text_requested", "notion", "last_synced", "notion_published", "tasks"];
|
|
23
25
|
/** Proposal key order: the spec/schema.md §11 example, then its table. */
|
|
24
26
|
const PROPOSAL_ORDER = [
|
|
25
|
-
"id", "type", "title", "created", "updated", "kind", "target", "field", "before", "after", "from",
|
|
27
|
+
"id", "type", "title", "created", "updated", "kind", "target", "field", "links", "notes", "answer", "answer_note", "before", "after", "from",
|
|
26
28
|
"content", "note", "supersedes", "merge_into", "why", "source", "status", "decision", "closed_reason",
|
|
27
29
|
"notion", "notion_published",
|
|
28
30
|
];
|
|
@@ -30,7 +32,10 @@ const PROPOSAL_ORDER = [
|
|
|
30
32
|
function keyOrder(type) {
|
|
31
33
|
const keys = type === "proposal"
|
|
32
34
|
? PROPOSAL_ORDER
|
|
33
|
-
:
|
|
35
|
+
: type === "source"
|
|
36
|
+
// Sources: `digest` right after `summary`, and the system fields last (spec/cli.md §3.6.5).
|
|
37
|
+
? [...KNOWLEDGE_HEAD, "digest", ...TYPE_FIELDS.source, ...KNOWLEDGE_TAIL.filter((key) => !SOURCE_SYSTEM.includes(key)), ...SOURCE_SYSTEM]
|
|
38
|
+
: [...KNOWLEDGE_HEAD, ...(TYPE_FIELDS[String(type)] ?? []), ...KNOWLEDGE_TAIL];
|
|
34
39
|
const ranks = new Map();
|
|
35
40
|
for (const key of keys)
|
|
36
41
|
if (!ranks.has(key))
|
package/dist/lib/lint.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join, posix } from "node:path";
|
|
|
4
4
|
import { installedProduct } from "./skills.js";
|
|
5
5
|
import { buildIndex } from "./index-builder.js";
|
|
6
6
|
import { compareCodePoints } from "./notes.js";
|
|
7
|
+
import { rangesProblem, splitParts } from "./parts.js";
|
|
7
8
|
import { sourceFolder } from "./paths.js";
|
|
8
9
|
import { describeErrors, loadSchemas } from "./schemas.js";
|
|
9
10
|
import { deriveView, generatedBlockRange } from "./views.js";
|
|
@@ -12,8 +13,13 @@ const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghj
|
|
|
12
13
|
const REFERENCE_KEYS = new Set([
|
|
13
14
|
"area", "goal", "client", "org", "projects", "people", "attendees", "decided_in", "raised_in", "deciders",
|
|
14
15
|
"source", "sources", "decisions", "questions", "answer", "target", "supersedes", "merge_into", "project",
|
|
15
|
-
"generated_from", "facts",
|
|
16
|
+
"generated_from", "facts", "notes", "answer_note", "links", "attached_to",
|
|
16
17
|
]);
|
|
18
|
+
/** Where each kind of view must live (spec/schema.md §2, 0041, 0043). */
|
|
19
|
+
const VIEW_FOLDERS = {
|
|
20
|
+
area_brief: /^areas\/briefs\/[^/]+\.md$/,
|
|
21
|
+
report: /^reports\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
22
|
+
};
|
|
17
23
|
/** Where each type must live (spec/schema.md §2). */
|
|
18
24
|
const FOLDERS = {
|
|
19
25
|
area: /^areas\/[^/]+\.md$/,
|
|
@@ -61,6 +67,14 @@ export function lintBrain(input) {
|
|
|
61
67
|
if (others.length > 0)
|
|
62
68
|
add("error", "id-duplicate", note.path, lineOfKey(note, "id"), note.id, `${note.id} is also used by ${others.join(", ")}`);
|
|
63
69
|
}
|
|
70
|
+
// Every review_notes proposal listing each note id (rule review-notes, 0041).
|
|
71
|
+
const reviewedBy = new Map();
|
|
72
|
+
for (const note of input.notes) {
|
|
73
|
+
if (note.type !== "proposal" || note.attributes.kind !== "review_notes")
|
|
74
|
+
continue;
|
|
75
|
+
for (const id of new Set(list(note.attributes.notes).map(String)))
|
|
76
|
+
reviewedBy.set(id, [...(reviewedBy.get(id) ?? []), note.id]);
|
|
77
|
+
}
|
|
64
78
|
const onDisk = caseSensitiveExists(input.root);
|
|
65
79
|
const exists = (path) => input.pendingPaths?.has(path) === true || onDisk(path);
|
|
66
80
|
for (const note of input.notes) {
|
|
@@ -75,7 +89,7 @@ export function lintBrain(input) {
|
|
|
75
89
|
}
|
|
76
90
|
if (!ID.test(note.id))
|
|
77
91
|
err("id-format", lineOfKey(note, "id"), `"${note.id}" is not <prefix>-<8 chars> with a schema prefix`);
|
|
78
|
-
const folder = FOLDERS[note.type];
|
|
92
|
+
const folder = note.type === "view" ? VIEW_FOLDERS[String(a.view_kind)] ?? FOLDERS.view : FOLDERS[note.type];
|
|
79
93
|
const correctFolder = note.type === "source"
|
|
80
94
|
? new RegExp(`^sources/${sourceFolder(a.source_key)}/\\d{4}-\\d{2}-\\d{2}-[^/]+\\.md$`)
|
|
81
95
|
: folder;
|
|
@@ -130,10 +144,10 @@ export function lintBrain(input) {
|
|
|
130
144
|
if (typeof source === "string" && byId.get(source)?.type === "view")
|
|
131
145
|
err("view-cited", lineOfKey(note, "sources"), `${source} is a derived view and can't be evidence`);
|
|
132
146
|
}
|
|
133
|
-
if (note.type === "view" && (a.view_kind === "brief" || a.view_kind === "decision_log")) {
|
|
134
|
-
const project = byId.get(String(a.project));
|
|
147
|
+
if (note.type === "view" && (a.view_kind === "brief" || a.view_kind === "decision_log" || a.view_kind === "area_brief")) {
|
|
148
|
+
const project = byId.get(String(a.view_kind === "area_brief" ? a.area : a.project));
|
|
135
149
|
const range = generatedBlockRange(note.body);
|
|
136
|
-
if (project?.type === "project" && range !== undefined) {
|
|
150
|
+
if (project?.type === (a.view_kind === "area_brief" ? "area" : "project") && range !== undefined) {
|
|
137
151
|
const derived = deriveView(project, a.view_kind, input.notes);
|
|
138
152
|
const generatedFrom = Array.isArray(a.generated_from) ? a.generated_from : [];
|
|
139
153
|
if (note.body.slice(range.start, range.end) !== derived.generatedBlock || !sameStringList(generatedFrom, derived.generatedFrom)) {
|
|
@@ -199,6 +213,21 @@ export function lintBrain(input) {
|
|
|
199
213
|
if (problem !== null)
|
|
200
214
|
err("proposal-status", lineOfKey(note, "status"), problem);
|
|
201
215
|
}
|
|
216
|
+
if (note.type === "proposal" && a.kind === "review_notes") {
|
|
217
|
+
for (const id of list(a.notes)) {
|
|
218
|
+
const listed = byId.get(String(id));
|
|
219
|
+
if (listed !== undefined && !KNOWLEDGE.has(listed.type))
|
|
220
|
+
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is a ${listed.type}, not a knowledge note`);
|
|
221
|
+
const others = (reviewedBy.get(String(id)) ?? []).filter((other) => other !== note.id);
|
|
222
|
+
if (others.length > 0)
|
|
223
|
+
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is also listed by ${others.join(", ")}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (note.type === "source" && Array.isArray(a.notion_parts)) {
|
|
227
|
+
const problem = rangesProblem(a.notion_parts, splitParts(note.body).parts.length);
|
|
228
|
+
if (problem !== null)
|
|
229
|
+
err("notion-parts", lineOfKey(note, "notion_parts"), problem);
|
|
230
|
+
}
|
|
202
231
|
// Quality warnings
|
|
203
232
|
if (KNOWLEDGE.has(note.type)) {
|
|
204
233
|
if (typeof a.summary === "string" && a.summary.length > 200)
|
package/dist/lib/note-new.js
CHANGED
|
@@ -3,6 +3,7 @@ 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";
|
|
6
7
|
import { newNotePath } from "./paths.js";
|
|
7
8
|
import { formatDate, formatTimestamp } from "./time.js";
|
|
8
9
|
const ITEM_KEYS = ["type", "id", "title", "file_date", "frontmatter", "body"];
|
|
@@ -50,6 +51,17 @@ export function checkNoteInput(value, pointer, withId = true) {
|
|
|
50
51
|
if (forbidden.includes(key))
|
|
51
52
|
at(`frontmatter/${escapePointer(key)}`, "is set by the CLI");
|
|
52
53
|
}
|
|
54
|
+
if (value.type === "source") {
|
|
55
|
+
// Order, overlap and the part count are not in the JSON Schema (spec/cli.md §5.5).
|
|
56
|
+
if (value.frontmatter.notion_text_requested !== undefined)
|
|
57
|
+
at("frontmatter/notion_text_requested", "is set by brain source full-text");
|
|
58
|
+
const parts = value.frontmatter.notion_parts;
|
|
59
|
+
if (Array.isArray(parts) && typeof value.body === "string") {
|
|
60
|
+
const problem = rangesProblem(parts, splitParts(value.body).parts.length);
|
|
61
|
+
if (problem !== null)
|
|
62
|
+
at("frontmatter/notion_parts", problem);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
return problems;
|
|
55
67
|
}
|
|
@@ -71,6 +83,31 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
71
83
|
});
|
|
72
84
|
if (clashes.length > 0)
|
|
73
85
|
throw new BrainError("ID_IN_USE", `${clashes[0].pointer} ${clashes[0].message}`, clashes);
|
|
86
|
+
// attached_to names a source that exists, or comes earlier in the batch, and isn't attached itself (spec/cli.md §5.5, 0042).
|
|
87
|
+
const parents = [];
|
|
88
|
+
items.forEach((item, index) => {
|
|
89
|
+
const parent = item.type === "source" ? item.frontmatter.attached_to : undefined;
|
|
90
|
+
if (parent === undefined)
|
|
91
|
+
return;
|
|
92
|
+
const existingParent = workspace.get(String(parent));
|
|
93
|
+
const earlier = items.slice(0, index).find((candidate) => candidate.id === parent);
|
|
94
|
+
const type = existingParent?.type ?? earlier?.type;
|
|
95
|
+
const attached = existingParent !== undefined ? existingParent.attributes.attached_to : earlier?.frontmatter.attached_to;
|
|
96
|
+
const problem = type === undefined ? `${String(parent)} is not an existing source or one earlier in the batch`
|
|
97
|
+
: type !== "source" ? `${String(parent)} is a ${type}, not a source`
|
|
98
|
+
: attached !== undefined ? `${String(parent)} is attached to ${String(attached)} itself; attach to that source instead`
|
|
99
|
+
: undefined;
|
|
100
|
+
if (problem !== undefined)
|
|
101
|
+
parents.push({ pointer: `${pointerOf(index)}/frontmatter/attached_to`, message: problem });
|
|
102
|
+
});
|
|
103
|
+
// A repo file names a linked repo of brain.json (0042).
|
|
104
|
+
const repoNames = new Set(Array.isArray(workspace.config.repos) ? workspace.config.repos.filter(isRecord).map((repo) => repo.name) : []);
|
|
105
|
+
items.forEach((item, index) => {
|
|
106
|
+
const repo = item.type === "source" ? item.frontmatter.repo : undefined;
|
|
107
|
+
if (repo !== undefined && !repoNames.has(repo))
|
|
108
|
+
parents.push({ pointer: `${pointerOf(index)}/frontmatter/repo`, message: `${String(repo)} is not a linked repo in brain.json repos` });
|
|
109
|
+
});
|
|
110
|
+
assertNoProblems(parents);
|
|
74
111
|
const today = formatDate(options.now);
|
|
75
112
|
const taken = new Set([...existing, ...seen]);
|
|
76
113
|
const result = { created: [], skipped: [], pointers: new Map() };
|
|
@@ -91,6 +128,13 @@ export function stageNotes(workspace, items, pointerOf, options) {
|
|
|
91
128
|
}
|
|
92
129
|
attributes.content_hash = hash;
|
|
93
130
|
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
|
+
}
|
|
94
138
|
}
|
|
95
139
|
const id = item.id ?? generateId(TYPE_PREFIX[item.type], taken);
|
|
96
140
|
taken.add(id);
|
package/dist/lib/notes.js
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const SLIDE = /^<!-- Slide number: \d+ -->$/;
|
|
2
|
+
const HEADING = /^(#{1,6}) /;
|
|
3
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
4
|
+
/**
|
|
5
|
+
* Split a body into parts: slides when it has a slide marker, else the ATX headings outside
|
|
6
|
+
* fenced code at the smallest level present, else one part holding the whole body.
|
|
7
|
+
*/
|
|
8
|
+
export function splitParts(body) {
|
|
9
|
+
const lines = body.split(/(?<=\n)/);
|
|
10
|
+
const bare = (line) => line.replace(/\n$/, "");
|
|
11
|
+
const slides = lines.some((line) => SLIDE.test(bare(line)));
|
|
12
|
+
let starts;
|
|
13
|
+
if (slides) {
|
|
14
|
+
starts = lines.flatMap((line, index) => (SLIDE.test(bare(line)) ? [index] : []));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const headings = [];
|
|
18
|
+
let fence;
|
|
19
|
+
lines.forEach((line, index) => {
|
|
20
|
+
const open = FENCE.exec(line)?.[1];
|
|
21
|
+
if (fence !== undefined) {
|
|
22
|
+
if (open !== undefined && open[0] === fence[0] && open.length >= fence.length && bare(line).trim() === open)
|
|
23
|
+
fence = undefined;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (open !== undefined) {
|
|
27
|
+
fence = open;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const level = HEADING.exec(line)?.[1]?.length;
|
|
31
|
+
if (level !== undefined)
|
|
32
|
+
headings.push({ index, level });
|
|
33
|
+
});
|
|
34
|
+
const smallest = Math.min(...headings.map(({ level }) => level));
|
|
35
|
+
starts = headings.filter(({ level }) => level === smallest).map(({ index }) => index);
|
|
36
|
+
}
|
|
37
|
+
if (starts.length === 0)
|
|
38
|
+
return { kind: "sections", preamble: "", parts: [body] };
|
|
39
|
+
const parts = starts.map((start, i) => lines.slice(start, starts[i + 1] ?? lines.length).join(""));
|
|
40
|
+
return { kind: slides ? "slides" : "sections", preamble: lines.slice(0, starts[0]).join(""), parts };
|
|
41
|
+
}
|
|
42
|
+
/** Why a `notion_parts` list is invalid for a body of `count` parts, or null when it is valid. */
|
|
43
|
+
export function rangesProblem(ranges, count) {
|
|
44
|
+
if (ranges.length === 0)
|
|
45
|
+
return "must list at least one part";
|
|
46
|
+
let previous = 0;
|
|
47
|
+
for (const range of ranges) {
|
|
48
|
+
const match = typeof range === "string" ? /^([1-9]\d*)(?:-([1-9]\d*))?$/.exec(range) : null;
|
|
49
|
+
if (match === null)
|
|
50
|
+
return `"${String(range)}" is not a part number or a range n-m`;
|
|
51
|
+
const from = Number(match[1]);
|
|
52
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
53
|
+
if (match[2] !== undefined && from >= to)
|
|
54
|
+
return `"${range}" must run from a lower to a higher part`;
|
|
55
|
+
if (from <= previous)
|
|
56
|
+
return `"${range}" is out of order or overlaps the range before it`;
|
|
57
|
+
if (count !== undefined && to > count)
|
|
58
|
+
return `"${range}" is above the body's ${count} part${count === 1 ? "" : "s"}`;
|
|
59
|
+
previous = to;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** The 1-based part numbers a valid range list names, ascending. */
|
|
64
|
+
export function selectedParts(ranges) {
|
|
65
|
+
return ranges.flatMap((range) => {
|
|
66
|
+
const [from, to = from] = range.split("-").map(Number);
|
|
67
|
+
return Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The "Full text" section of a source (spec/cli.md §5.14.4): the preamble, then the chosen
|
|
72
|
+
* parts in order; a partial choice starts with a line naming it (`*Slides 3–9 of 12*`).
|
|
73
|
+
*/
|
|
74
|
+
export function fullText(body, ranges) {
|
|
75
|
+
const { kind, preamble, parts } = splitParts(body);
|
|
76
|
+
const chosen = ranges === null ? parts : selectedParts(ranges).map((n) => parts[n - 1]);
|
|
77
|
+
const text = `${preamble}${chosen.join("")}`;
|
|
78
|
+
if (ranges === null)
|
|
79
|
+
return text;
|
|
80
|
+
const label = `*${kind === "slides" ? "Slides" : "Sections"} ${ranges.map((range) => range.replace("-", "–")).join(", ")} of ${parts.length}*`;
|
|
81
|
+
return `${label}\n\n${text}`;
|
|
82
|
+
}
|