ultimate-brain 0.3.1 → 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.
Files changed (38) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +10 -6
  2. package/.agents/skills/brain-catch-up/SKILL.md +27 -14
  3. package/.agents/skills/brain-connect-notion/SKILL.md +2 -2
  4. package/.agents/skills/brain-howto/references/guide.md +44 -11
  5. package/.agents/skills/brain-ingest/SKILL.md +14 -8
  6. package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
  7. package/.agents/skills/brain-ingest/references/extraction.md +11 -7
  8. package/.agents/skills/brain-ingest/references/notion.md +25 -11
  9. package/.agents/skills/brain-ingest/references/sources.md +24 -1
  10. package/.agents/skills/brain-notion-change/SKILL.md +16 -2
  11. package/.agents/skills/brain-query/SKILL.md +22 -2
  12. package/.agents/skills/brain-sync/SKILL.md +6 -4
  13. package/.agents/skills/brain-sync/references/formats.md +1 -1
  14. package/README.md +1 -1
  15. package/dist/cli.js +5 -2
  16. package/dist/commands/proposal.js +21 -1
  17. package/dist/commands/repo.js +110 -0
  18. package/dist/commands/report.js +115 -0
  19. package/dist/commands/source.js +56 -0
  20. package/dist/commands/status.js +4 -1
  21. package/dist/commands/views.js +30 -15
  22. package/dist/lib/apply.js +83 -10
  23. package/dist/lib/errors.js +3 -0
  24. package/dist/lib/frontmatter.js +9 -4
  25. package/dist/lib/lint.js +34 -5
  26. package/dist/lib/note-new.js +44 -0
  27. package/dist/lib/notes.js +1 -0
  28. package/dist/lib/parts.js +82 -0
  29. package/dist/lib/proposals.js +183 -8
  30. package/dist/lib/repos.js +129 -0
  31. package/dist/lib/sync.js +167 -28
  32. package/dist/lib/views.js +53 -15
  33. package/package.json +1 -1
  34. package/spec/schemas/brain.schema.json +46 -0
  35. package/spec/schemas/note.schema.json +343 -3
  36. package/spec/schemas/proposal.schema.json +393 -10
  37. package/templates/brain/AGENTS.md +4 -4
  38. package/templates/brain/brain.json +2 -1
@@ -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 and decision logs while preserving user blocks.",
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
- let projects = workspace.notes.filter((note) => note.type === "project");
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 projects) {
34
- for (const kind of ["brief", "decision_log"]) {
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 range = existing.type === "view" && existing.attributes.project === project.id && existing.attributes.view_kind === kind
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, project, kind, generatedFrom, now) {
96
- const suffix = kind === "brief" ? "brief" : "decision log";
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(project.attributes.title)} — ${suffix}`,
115
+ title: `${String(owner.attributes.title)} — ${suffix}`,
101
116
  view_kind: kind,
102
- project: project.id,
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 !== "string" || !Object.hasOwn(entry, "value"))))
64
- problems.push({ pointer: `${pointer}/exclude`, message: "must be a list of {field, value} pairs" });
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
- const links = proposalLinks(p);
199
- if (decision.exclude.some((excluded) => !links.some((link) => link.field === excluded.field && isDeepStrictEqual(link.value, excluded.value))))
200
- throw new ItemFailure("EXCLUDE_UNKNOWN", "exclude contains a link that is not in the proposal");
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
- return { result: "closed", action };
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
- const stale = applyKind(workspace, p, proposal.path, V, D, today, decision.exclude ?? []);
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 }));
@@ -12,6 +12,9 @@ export const EXIT_CODES = {
12
12
  UNKNOWN_ID: 4,
13
13
  ID_IN_USE: 4,
14
14
  BEFORE_MISMATCH: 4,
15
+ REPO_NOT_FOUND: 4,
16
+ REPO_IS_BRAIN: 4,
17
+ REPO_COMMIT_UNKNOWN: 4,
15
18
  TARGET_NOT_EMPTY: 5,
16
19
  ALREADY_A_BRAIN: 5,
17
20
  OUTSIDE_ROOT: 5,
@@ -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
- : [...KNOWLEDGE_HEAD, ...(TYPE_FIELDS[String(type)] ?? []), ...KNOWLEDGE_TAIL];
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)
@@ -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
@@ -9,6 +9,7 @@ export const NOTE_FOLDERS = [
9
9
  "archive",
10
10
  "knowledge",
11
11
  "sources",
12
+ "reports",
12
13
  "memories",
13
14
  "_proposals",
14
15
  ];
@@ -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
+ }