ultimate-brain 0.4.0 → 0.5.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 (34) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +35 -22
  2. package/.agents/skills/brain-catch-up/SKILL.md +30 -22
  3. package/.agents/skills/brain-howto/SKILL.md +1 -1
  4. package/.agents/skills/brain-howto/references/guide.md +70 -43
  5. package/.agents/skills/brain-ingest/SKILL.md +10 -6
  6. package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -3
  7. package/.agents/skills/brain-ingest/references/extraction.md +23 -1
  8. package/.agents/skills/brain-ingest/references/notion.md +4 -3
  9. package/.agents/skills/brain-ingest/references/sources.md +25 -5
  10. package/.agents/skills/brain-notion-change/SKILL.md +2 -0
  11. package/.agents/skills/brain-query/SKILL.md +17 -7
  12. package/.agents/skills/brain-sync/SKILL.md +16 -10
  13. package/.agents/skills/brain-sync/references/formats.md +1 -1
  14. package/dist/cli.js +3 -3
  15. package/dist/commands/report.js +81 -4
  16. package/dist/commands/source.js +34 -0
  17. package/dist/commands/status.js +2 -2
  18. package/dist/lib/apply.js +237 -33
  19. package/dist/lib/diff.js +31 -0
  20. package/dist/lib/ids.js +2 -0
  21. package/dist/lib/index-builder.js +6 -1
  22. package/dist/lib/lint.js +39 -8
  23. package/dist/lib/note-new.js +21 -2
  24. package/dist/lib/paths.js +1 -0
  25. package/dist/lib/proposals.js +72 -18
  26. package/dist/lib/query.js +1 -1
  27. package/dist/lib/schemas.js +1 -1
  28. package/dist/lib/sync.js +129 -53
  29. package/package.json +1 -1
  30. package/spec/schemas/brain.schema.json +3 -0
  31. package/spec/schemas/note.schema.json +130 -2
  32. package/spec/schemas/proposal.schema.json +461 -14
  33. package/templates/brain/brain.json +2 -1
  34. package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/lib/sync.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { applyAll, EDITABLE } from "./apply.js";
3
+ import { applyAll } from "./apply.js";
4
4
  import { BrainError } from "./errors.js";
5
5
  import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
6
6
  import { generateId, TYPE_PREFIX } from "./ids.js";
@@ -9,11 +9,13 @@ import { compareCodePoints } from "./notes.js";
9
9
  import { fullText, splitParts } from "./parts.js";
10
10
  import { loadSchemas } from "./schemas.js";
11
11
  import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
12
- const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review", "inbox"];
12
+ import { lineDiff } from "./diff.js";
13
+ import { bodyHash, bodyOf } from "./proposals.js";
14
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "references", "review", "inbox"];
13
15
  const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
14
- const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
15
- const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", source: "inbox", meeting: "meetings", proposal: "review" };
16
- const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "source", "meeting", "proposal"]);
16
+ const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", references: "reference", review: "proposal" };
17
+ const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "references", source: "inbox", meeting: "meetings", proposal: "review" };
18
+ const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
17
19
  function property(value, key) {
18
20
  return Object.hasOwn(value, key) ? value[key] : undefined;
19
21
  }
@@ -72,7 +74,7 @@ export function parseSnapshot(input) {
72
74
  }
73
75
  /** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
74
76
  export function buildPlan(workspace, snapshot) {
75
- const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
77
+ const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
76
78
  const createsByPage = new Map();
77
79
  for (const row of snapshot.rows)
78
80
  if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
@@ -296,44 +298,56 @@ function planKeyFacts(row, note, published, updates, out) {
296
298
  updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
297
299
  }
298
300
  function planDecision(row, proposal, out) {
299
- const decision = property(row.properties, "Decision");
300
- if (decision === undefined || property(row.properties, "State") !== "Pending")
301
+ // A snapshot taken before migration 0009 has Decision and Note; Edit there is read as Fix (cli §5.14.2, 0045).
302
+ const call = property(row.properties, "Your call") ?? property(row.properties, "Decision");
303
+ const label = Object.hasOwn(row.properties, "Your call") ? "Your call" : "Decision";
304
+ const textLabel = Object.hasOwn(row.properties, "What to fix") || !Object.hasOwn(row.properties, "Note") ? "What to fix" : "Note";
305
+ if (call === undefined || call === null || property(row.properties, "State") !== "Pending")
301
306
  return;
302
307
  if (["applied", "closed"].includes(String(proposal.attributes.status))) {
303
308
  out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
304
309
  return;
305
310
  }
306
- const action = String(decision).toLowerCase();
307
- const note = property(row.properties, "Note");
308
- if (!["approve", "reject", "edit"].includes(action)) {
309
- out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "NOT_CORRECTABLE" });
311
+ const action = String(call).toLowerCase() === "edit" ? "fix" : String(call).toLowerCase();
312
+ const text = property(row.properties, textLabel);
313
+ const written = typeof text === "string" && text.trim() !== "";
314
+ if (!["approve", "reject", "fix"].includes(action)) {
315
+ out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, property: label, reason: "UNKNOWN_OPTION" });
310
316
  return;
311
317
  }
312
- if (action === "edit" && !EDITABLE.has(String(proposal.attributes.kind))) {
313
- out.conflicts.push(conflict(row, "EDIT_NOT_ALLOWED", `Edit is not allowed on a ${String(proposal.attributes.kind)} proposal`, "Decision", decision));
318
+ if (action === "fix" && !written) {
319
+ out.conflicts.push(conflict(row, "FIX_WITHOUT_TEXT", "Fix needs What to fix: what to correct", "What to fix"));
314
320
  return;
315
321
  }
316
- if (action === "edit" && (note === null || note === undefined || note === "")) {
317
- out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs a Note", "Note"));
322
+ if (action === "fix") {
323
+ out.conflicts.push(conflict(row, "FIX_TO_READ", "What to fix needs reading by the skill", "What to fix", text, "Fix"));
318
324
  return;
319
325
  }
320
- if (action !== "edit" && typeof note === "string" && note !== "") {
321
- out.conflicts.push(conflict(row, "NOTE_TO_READ", "Review Note needs interpretation by the skill", "Note", note, decision));
326
+ if (written) {
327
+ out.conflicts.push(conflict(row, "NOTE_TO_READ", "What to fix needs reading by the skill", "What to fix", text, action === "approve" ? "Approve" : "Reject"));
322
328
  return;
323
329
  }
324
- out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
330
+ out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
325
331
  }
326
- /** An Inbox row is read only for its Full text box (cli §5.14.2, 0043). */
332
+ /** An Inbox row is read for its Full text box (0043) and, on a Report row, Refresh and What to fix (cli §5.14.2, 0045). */
327
333
  function planInbox(workspace, row, out) {
334
+ const brainId = property(row.properties, "Brain ID");
335
+ const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
336
+ if (note !== undefined && isReport(note)) {
337
+ if (property(row.properties, "Refresh") === true)
338
+ out.refresh_requests.push({ page_id: row.page_id, report: note.id });
339
+ const text = property(row.properties, "What to fix");
340
+ const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
341
+ if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
342
+ out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
343
+ }
328
344
  if (property(row.properties, "Full text") !== true)
329
345
  return;
330
- const brainId = property(row.properties, "Brain ID");
331
346
  const ignore = (id) => out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: id, property: "Full text", reason: "NO_FULL_TEXT" });
332
347
  if (typeof brainId !== "string" || brainId === "") {
333
348
  ignore(null);
334
349
  return;
335
350
  }
336
- const note = workspace.get(brainId);
337
351
  if (note === undefined) {
338
352
  out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
339
353
  return;
@@ -369,7 +383,7 @@ function planKeyResult(workspace, asOf, row, out) {
369
383
  }
370
384
  function sortPlan(plan) {
371
385
  const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
372
- for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.conflicts, plan.ignored])
386
+ for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
373
387
  values.sort((a, b) => order(a).localeCompare(order(b)));
374
388
  }
375
389
  function notionFields(database, p, asOf) {
@@ -382,7 +396,7 @@ function notionFields(database, p, asOf) {
382
396
  }
383
397
  function brainProperties(database) {
384
398
  const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
385
- const extras = { goals: [["Why", "why"], ["Deadline", "facts.deadline"], ["Area", "area"]], projects: [["Key Facts", "facts"], ["Area", "area"], ["Goal", "goal"], ["Client", "client"], ["People", "people"]], people: [["Role", "facts.role"], ["Org", "org"]], decisions: [["Assessment", "assessment"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]] };
399
+ const extras = { goals: [["Why", "why"], ["Deadline", "facts.deadline"], ["Area", "area"]], projects: [["Key Facts", "facts"], ["Area", "area"], ["Goal", "goal"], ["Client", "client"], ["People", "people"]], people: [["Role", "facts.role"], ["Org", "org"]], decisions: [["Assessment", "assessment"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]], references: [["Project", "reference_projects"], ["Area", "reference_area"]] };
386
400
  return [...common, ...(extras[database] ?? [])];
387
401
  }
388
402
  function convertedValue(workspace, row, name, value, conflicts) {
@@ -446,9 +460,10 @@ export function parsePlan(input) {
446
460
  if (!isRecord(input))
447
461
  assertNoProblems([{ pointer: "", message: "must be an object" }]);
448
462
  const p = input;
449
- const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "conflicts", "ignored"], "");
450
- if (p.full_text_requests !== undefined && !Array.isArray(p.full_text_requests))
451
- problems.push({ pointer: "/full_text_requests", message: "must be a list" });
463
+ const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
464
+ for (const key of ["full_text_requests", "refresh_requests", "report_fixes"])
465
+ if (p[key] !== undefined && !Array.isArray(p[key]))
466
+ problems.push({ pointer: `/${key}`, message: "must be a list" });
452
467
  if (!isTimestamp(p.as_of))
453
468
  problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
454
469
  for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
@@ -466,7 +481,7 @@ export function parsePlan(input) {
466
481
  problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
467
482
  });
468
483
  assertNoProblems(problems);
469
- return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
484
+ return { ...p, full_text_requests: p.full_text_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
470
485
  }
471
486
  export function applyPlan(workspace, plan, now, recordOnly) {
472
487
  const create = [];
@@ -538,17 +553,17 @@ export function applyPlan(workspace, plan, now, recordOnly) {
538
553
  ...updates.filter((item) => item.result === "applied").map((item) => ({ op: "sync-update", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
539
554
  ...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
540
555
  ];
541
- return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_requests, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
556
+ return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_requests, refresh_requests: plan.refresh_requests, report_fixes: plan.report_fixes, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
542
557
  }
543
558
  function references(value) { if (Array.isArray(value))
544
559
  return value.flatMap(references); if (isRecord(value))
545
- return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem)-/.test(value) ? [value] : []; }
560
+ return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem)-/.test(value) ? [value] : []; }
546
561
  function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
547
562
  if (typeof item.result === "string" && Object.hasOwn(result, item.result))
548
563
  result[item.result] += 1; return result; }
549
564
  export function render(workspace, now, ids, held) {
550
565
  const selected = ids === undefined
551
- ? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || (isReport(note) && note.attributes.notion === undefined))
566
+ ? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || isReport(note))
552
567
  : ids.map((id) => workspace.get(id)).filter(Boolean);
553
568
  const items = [];
554
569
  const errors = [];
@@ -557,17 +572,21 @@ export function render(workspace, now, ids, held) {
557
572
  const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
558
573
  if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
559
574
  continue;
560
- // A published report is never rendered again (0043).
561
- if (isReport(note) && note.attributes.notion !== undefined)
562
- continue;
563
575
  // An attached source has no Inbox row: its notes go on the parent's (0042).
564
576
  if (note.type === "source" && note.attributes.attached_to !== undefined)
565
577
  continue;
578
+ // A decided Review row leaves Notion 30 days after its decision, once (0045).
579
+ if (note.type === "proposal" && isRecord(note.attributes.notion_published) && note.attributes.notion_published["@trashed"] !== undefined)
580
+ continue;
566
581
  const databaseId = databaseIdFor(workspace, database);
567
582
  if (databaseId === "") {
568
583
  warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
569
584
  continue;
570
585
  }
586
+ if (note.type === "proposal" && trashDue(note, now)) {
587
+ items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: String(note.attributes.notion), action: "trash", properties: {}, body: null, sections: null, pending_relations: [], published: { "@trashed": formatTimestamp(now) } });
588
+ continue;
589
+ }
571
590
  if (note.type === "meeting" && typeof note.attributes.notion !== "string") {
572
591
  errors.push({ id: note.id, reason: "MEETING_NOT_PUBLISHED" });
573
592
  continue;
@@ -586,7 +605,7 @@ export function render(workspace, now, ids, held) {
586
605
  const properties = {};
587
606
  const pending = [];
588
607
  const heldProperties = held?.get(note.id) ?? new Set();
589
- const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
608
+ const isCreate = note.attributes.notion === undefined && capturePage === undefined;
590
609
  for (const [name, value] of Object.entries(rendered)) {
591
610
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
592
611
  continue;
@@ -598,6 +617,21 @@ export function render(workspace, now, ids, held) {
598
617
  if (content.hash !== undefined)
599
618
  rendered["@content"] = content.hash;
600
619
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
620
+ // A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
621
+ let reportDue = false;
622
+ if (isReport(note)) {
623
+ const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
624
+ const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
625
+ if (!isCreate && contentChanged)
626
+ properties.Refresh = { type: "checkbox", value: false };
627
+ if (!isCreate && !contentChanged)
628
+ content.body = null;
629
+ if (fixes > stored) {
630
+ properties["What to fix"] = { type: "rich_text", value: null };
631
+ reportDue = true;
632
+ }
633
+ rendered["@fixes"] = Math.max(fixes, stored);
634
+ }
601
635
  // Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
602
636
  const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
603
637
  if (full?.sections !== undefined)
@@ -607,7 +641,7 @@ export function render(workspace, now, ids, held) {
607
641
  if (full !== undefined && (full.sections !== undefined || full.clear))
608
642
  rendered["@full_text"] = full.record;
609
643
  const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
610
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
644
+ if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow && !reportDue)
611
645
  continue;
612
646
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
613
647
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -616,6 +650,16 @@ export function render(workspace, now, ids, held) {
616
650
  items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
617
651
  return { items, errors, warnings };
618
652
  }
653
+ const TRASH_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
654
+ /** A published Review row whose proposal was applied or closed more than 30 days before `now` (0045). */
655
+ function trashDue(note, now) {
656
+ const a = note.attributes;
657
+ if (typeof a.notion !== "string" || (a.status !== "applied" && a.status !== "closed"))
658
+ return false;
659
+ const decided = isRecord(a.decision) && typeof a.decision.decided_at === "string" ? a.decision.decided_at : a.updated;
660
+ const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
661
+ return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
662
+ }
619
663
  function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
620
664
  return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
621
665
  function renderedBrainValues(note, workspace) {
@@ -627,7 +671,9 @@ function renderedBrainValues(note, workspace) {
627
671
  const addLink = a.kind === "add_link";
628
672
  const after = addLink
629
673
  ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
630
- : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
674
+ : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : a.kind === "update_body" ? bodyChange(a, workspace)?.join("\n") ?? null : undefined;
675
+ // A body diff can only be shown while the target's body is the one proposed against; then S keeps it (0045).
676
+ const proposed = staleBody(a, workspace) && isRecord(a.notion_published) && Object.hasOwn(a.notion_published, "Proposed") ? a.notion_published.Proposed : undefined;
631
677
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
632
678
  // A Review notes row names its source: the meeting for minutes, else the source (0041).
633
679
  const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
@@ -635,16 +681,16 @@ function renderedBrainValues(note, workspace) {
635
681
  const page = (source ?? target)?.attributes.notion;
636
682
  const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
637
683
  Object.assign(values, {
638
- Name: a.title, Kind: select(a.kind), Target: targetText,
639
- "Target Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
684
+ Name: a.title, Kind: select(a.kind), About: targetText,
685
+ "About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
640
686
  "Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
641
- Before: reviewText(proposalBefore(a, workspace)), After: reviewText(after ?? proposalAfter(a, workspace)),
687
+ Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
642
688
  Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
643
689
  });
644
690
  return values;
645
691
  }
646
692
  if (isReport(note)) {
647
- Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : [], "Brain Status": "Processed", "Brain ID": note.id });
693
+ Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : Array.isArray(a.asked_in) ? a.asked_in : [], "Brain Status": "Processed", "Brain ID": note.id });
648
694
  return values;
649
695
  }
650
696
  if (note.type === "meeting") {
@@ -664,10 +710,18 @@ function renderedBrainValues(note, workspace) {
664
710
  Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Decided In": relationPages(a.decided_in === undefined ? [] : [a.decided_in], workspace), Supersedes: relationPages((Array.isArray(a.relations) ? a.relations.filter(isRecord).filter((x) => x.type === "supersedes").map((x) => x.target) : []), workspace) });
665
711
  if (note.type === "question")
666
712
  Object.assign(values, { Kind: select(a.kind), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
667
- // An answered question's Status goes out once as Answered; it stays yours after that (0041).
668
- if (note.type === "question" && a.answer !== undefined && (!isRecord(a.notion_published) || a.notion_published.Status !== "Answered"))
669
- values.Status = "Answered";
670
- if (note.type === "concept")
713
+ // Status goes out once as Open while you never set one, and once as Answered after an approved answer; it stays yours otherwise (0041, 0045).
714
+ if (note.type === "question") {
715
+ const published = isRecord(a.notion_published) ? a.notion_published : {};
716
+ const yours = isRecord(a.notion_fields) && a.notion_fields.status !== undefined;
717
+ if (a.answer !== undefined) {
718
+ if (published.Status !== "Answered")
719
+ values.Status = "Answered";
720
+ }
721
+ else if (published.Status === undefined && !yours)
722
+ values.Status = "Open";
723
+ }
724
+ if (note.type === "concept" || note.type === "reference")
671
725
  Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
672
726
  return values;
673
727
  }
@@ -682,17 +736,17 @@ function projectAreas(projects, workspace) {
682
736
  /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
683
737
  function producedBy(note, workspace) {
684
738
  const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
685
- return workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
739
+ return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
686
740
  }
687
741
  function sourceValues(note, workspace) {
688
742
  const produced = producedBy(note, workspace);
689
743
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
690
744
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
691
745
  const areas = [...new Set([...produced.map((candidate) => candidate.attributes.area).filter((id) => typeof id === "string"), ...projects.flatMap((id) => { const area = workspace.get(id)?.attributes.area; return typeof area === "string" ? [area] : []; })])].sort(compareCodePoints);
692
- return { Name: note.attributes.title, Kind: select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Processed", "Brain ID": note.id };
746
+ return { Name: note.attributes.title, Kind: select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), ...(databaseIdFor(workspace, "references") === "" ? {} : { References: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Processed", "Brain ID": note.id };
693
747
  }
694
748
  function notionProperty(name, value, note, workspace, pending) {
695
- const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Attendees"].includes(name);
749
+ const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "References", "Attendees"].includes(name);
696
750
  if (relation) {
697
751
  const sourceField = { Area: "area", Goal: "goal", Client: "client", People: "people", Org: "org", Project: "projects", "Decided In": "decided_in", "Raised In": "raised_in", Decisions: "decisions", Questions: "questions", Attendees: "attendees" };
698
752
  const raw = name === "Area" && (note.type === "decision" || note.type === "question") ? projectAreas(note.attributes.projects, workspace) : name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.type === "source" ? sourceRelationIds(note, name, workspace) : note.attributes[sourceField[name] ?? ""];
@@ -710,7 +764,7 @@ function notionProperty(name, value, note, workspace, pending) {
710
764
  }
711
765
  if (name === "Ask")
712
766
  return { type: "relation", value: Array.isArray(value) ? value : [] };
713
- if (name === "Target Page")
767
+ if (name === "About Page")
714
768
  return { type: "url", value };
715
769
  if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
716
770
  return { type: "select", value };
@@ -726,6 +780,8 @@ function sourceRelationIds(note, name, workspace) {
726
780
  return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
727
781
  if (name === "Concepts")
728
782
  return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
783
+ if (name === "References")
784
+ return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
729
785
  if (name === "People")
730
786
  return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
731
787
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
@@ -759,7 +815,7 @@ function renderedContent(note, workspace) {
759
815
  const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
760
816
  return { body, sections: null, hash: hash(body) };
761
817
  }
762
- if (note.type === "decision" || note.type === "question" || note.type === "concept") {
818
+ if (note.type === "decision" || note.type === "question" || note.type === "concept" || note.type === "reference") {
763
819
  const body = notionMarkdown(note.body, note, workspace);
764
820
  return { body, sections: null, hash: hash(body) };
765
821
  }
@@ -799,14 +855,34 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
799
855
  function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
800
856
  function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
801
857
  function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
802
- /** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
858
+ /** The lettered lines of a Review notes row: its changes, then its notes, then its links (cli §5.14.4, 0045). */
803
859
  function reviewNotesAfter(attributes, workspace) {
860
+ const changes = (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).map((change) => {
861
+ const target = String(noteTitle(change.target, workspace));
862
+ if (change.kind === "update_fact")
863
+ return `⚠ ${fieldLabel(String(change.field))} on ${target}: ${change.before === undefined ? "(new)" : String(noteTitle(change.before, workspace))} → ${String(noteTitle(change.after, workspace))}`;
864
+ if (change.kind === "update_body")
865
+ return `⚠ ${target}: ${bodyChange(change, workspace)?.length ?? 0} lines changed`;
866
+ return `Add to ${target}: ${String(change.content ?? "").split("\n")[0].slice(0, 80)}`;
867
+ });
804
868
  const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
805
869
  const note = workspace.get(String(id));
806
870
  return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
807
871
  });
808
872
  const links = (Array.isArray(attributes.links) ? attributes.links.filter(isRecord) : []).map((link) => `${fieldLabel(String(link.field))} on ${String(noteTitle(link.target, workspace))}: ${String(noteTitle(link.value, workspace))}`);
809
- return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
873
+ return [...changes, ...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
874
+ }
875
+ /** The changed lines an update_body proposes against its target's body (cli §5.19), or undefined once that body changed. */
876
+ function bodyChange(change, workspace) {
877
+ const target = typeof change.target === "string" ? workspace.get(change.target) : undefined;
878
+ if (target === undefined || bodyHash(target.body) !== change.before_hash)
879
+ return undefined;
880
+ return lineDiff(target.body, bodyOf(String(change.content ?? "")));
881
+ }
882
+ /** Whether a proposal holds an update_body whose target's body is no longer the one it was proposed against. */
883
+ function staleBody(attributes, workspace) {
884
+ const bodies = attributes.kind === "update_body" ? [attributes] : (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).filter((change) => change.kind === "update_body");
885
+ return bodies.some((change) => bodyChange(change, workspace) === undefined);
810
886
  }
811
887
  /** a, b, … z, then aa, ab, … */
812
888
  export function letters(index) {
@@ -837,7 +913,7 @@ function digestBody(note) {
837
913
  return [line, typeof a.summary === "string" ? a.summary : undefined, typeof a.digest === "string" ? a.digest.replace(/\n+$/, "") : undefined].filter((part) => part !== undefined).join("\n\n");
838
914
  }
839
915
  function proposalBefore(attributes, workspace) {
840
- if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
916
+ if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer" || attributes.kind === "update_body")
841
917
  return null;
842
918
  if (attributes.kind === "supersede")
843
919
  return truncate(noteTitle(attributes.supersedes, workspace));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-brain",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -156,6 +156,9 @@
156
156
  },
157
157
  "concepts": {
158
158
  "$ref": "#/$defs/dbId"
159
+ },
160
+ "references": {
161
+ "$ref": "#/$defs/dbId"
159
162
  }
160
163
  }
161
164
  },
@@ -26,6 +26,7 @@
26
26
  "decision",
27
27
  "question",
28
28
  "concept",
29
+ "reference",
29
30
  "source",
30
31
  "view",
31
32
  "memory"
@@ -152,6 +153,18 @@
152
153
  "$ref": "#/$defs/concept"
153
154
  }
154
155
  },
156
+ {
157
+ "if": {
158
+ "properties": {
159
+ "type": {
160
+ "const": "reference"
161
+ }
162
+ }
163
+ },
164
+ "then": {
165
+ "$ref": "#/$defs/reference"
166
+ }
167
+ },
155
168
  {
156
169
  "if": {
157
170
  "properties": {
@@ -196,7 +209,7 @@
196
209
  },
197
210
  "id": {
198
211
  "type": "string",
199
- "pattern": "^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
212
+ "pattern": "^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
200
213
  },
201
214
  "areaId": {
202
215
  "type": "string",
@@ -795,6 +808,24 @@
795
808
  }
796
809
  ]
797
810
  },
811
+ "reference": {
812
+ "description": "What the user kept from a document (decision 0045, spec/schema.md §4 reference).",
813
+ "allOf": [
814
+ {
815
+ "$ref": "#/$defs/knowledge"
816
+ },
817
+ {
818
+ "required": [
819
+ "sources"
820
+ ],
821
+ "properties": {
822
+ "sources": {
823
+ "minItems": 1
824
+ }
825
+ }
826
+ }
827
+ ]
828
+ },
798
829
  "source": {
799
830
  "required": [
800
831
  "source_key",
@@ -850,6 +881,13 @@
850
881
  "attached_to": {
851
882
  "$ref": "#/$defs/sourceId"
852
883
  },
884
+ "replaces": {
885
+ "$ref": "#/$defs/sourceId"
886
+ },
887
+ "keep": {
888
+ "type": "string",
889
+ "minLength": 1
890
+ },
853
891
  "summary": {
854
892
  "type": "string",
855
893
  "minLength": 1,
@@ -1098,6 +1136,45 @@
1098
1136
  ]
1099
1137
  }
1100
1138
  }
1139
+ },
1140
+ {
1141
+ "description": "keep only on a source that is read for its content (0045).",
1142
+ "if": {
1143
+ "anyOf": [
1144
+ {
1145
+ "required": [
1146
+ "attached_to"
1147
+ ]
1148
+ },
1149
+ {
1150
+ "required": [
1151
+ "kind"
1152
+ ],
1153
+ "properties": {
1154
+ "kind": {
1155
+ "enum": [
1156
+ "minutes",
1157
+ "capture"
1158
+ ]
1159
+ }
1160
+ }
1161
+ },
1162
+ {
1163
+ "not": {
1164
+ "required": [
1165
+ "kind"
1166
+ ]
1167
+ }
1168
+ }
1169
+ ]
1170
+ },
1171
+ "then": {
1172
+ "not": {
1173
+ "required": [
1174
+ "keep"
1175
+ ]
1176
+ }
1177
+ }
1101
1178
  }
1102
1179
  ]
1103
1180
  },
@@ -1133,7 +1210,48 @@
1133
1210
  "minLength": 1
1134
1211
  },
1135
1212
  "asked_in": {
1136
- "$ref": "#/$defs/notionPageId"
1213
+ "oneOf": [
1214
+ {
1215
+ "$ref": "#/$defs/notionPageId"
1216
+ },
1217
+ {
1218
+ "type": "array",
1219
+ "minItems": 1,
1220
+ "uniqueItems": true,
1221
+ "items": {
1222
+ "$ref": "#/$defs/notionPageId"
1223
+ }
1224
+ }
1225
+ ]
1226
+ },
1227
+ "fixes": {
1228
+ "type": "array",
1229
+ "items": {
1230
+ "type": "object",
1231
+ "required": [
1232
+ "text",
1233
+ "received",
1234
+ "proposals"
1235
+ ],
1236
+ "additionalProperties": false,
1237
+ "properties": {
1238
+ "text": {
1239
+ "type": "string",
1240
+ "minLength": 1
1241
+ },
1242
+ "received": {
1243
+ "$ref": "#/$defs/timestamp"
1244
+ },
1245
+ "proposals": {
1246
+ "type": "array",
1247
+ "uniqueItems": true,
1248
+ "items": {
1249
+ "type": "string",
1250
+ "pattern": "^prp-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1137
1255
  },
1138
1256
  "generated_from": {
1139
1257
  "type": "array",
@@ -1215,6 +1333,11 @@
1215
1333
  "asked_in"
1216
1334
  ]
1217
1335
  },
1336
+ {
1337
+ "required": [
1338
+ "fixes"
1339
+ ]
1340
+ },
1218
1341
  {
1219
1342
  "required": [
1220
1343
  "notion"
@@ -1274,6 +1397,11 @@
1274
1397
  "asked_in"
1275
1398
  ]
1276
1399
  },
1400
+ {
1401
+ "required": [
1402
+ "fixes"
1403
+ ]
1404
+ },
1277
1405
  {
1278
1406
  "required": [
1279
1407
  "notion"