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
@@ -11,8 +11,10 @@ const SECTIONS = [
11
11
  ["question", "Questions"],
12
12
  ["meeting", "Meetings"],
13
13
  ["concept", "Concepts"],
14
+ ["reference", "References"],
14
15
  ];
15
16
  const NON_KNOWLEDGE = new Set(["source", "view", "proposal"]);
17
+ const TYPES = [...SECTIONS.map(([type]) => type), "source", "view", "proposal", "memory"];
16
18
  /** Computed inverses exposed as backlinks (spec/schema.md §6). */
17
19
  const RELATION_INVERSES = {
18
20
  about: "mentioned_in",
@@ -45,7 +47,8 @@ export function buildIndex(allNotes) {
45
47
  byId.set(note.id, note);
46
48
  notes.push(note);
47
49
  }
48
- const counts = {};
50
+ // Every type has a count, zero included (spec/cli.md §5.8).
51
+ const counts = Object.fromEntries(TYPES.map((type) => [type, 0]));
49
52
  for (const note of notes)
50
53
  counts[note.type] = (counts[note.type] ?? 0) + 1;
51
54
  return {
@@ -114,6 +117,8 @@ function typeFields(note, byId) {
114
117
  return [["kind", scalar(a.kind)], ["status", notionField("status")]];
115
118
  case "meeting":
116
119
  return [["date", scalar(a.date)], ["project", refs(a.projects)]];
120
+ case "reference":
121
+ return [["area", ref(a.area)], ["project", refs(a.projects)]];
117
122
  default:
118
123
  return [];
119
124
  }
package/dist/lib/lint.js CHANGED
@@ -8,12 +8,12 @@ import { rangesProblem, splitParts } from "./parts.js";
8
8
  import { sourceFolder } from "./paths.js";
9
9
  import { describeErrors, loadSchemas } from "./schemas.js";
10
10
  import { deriveView, generatedBlockRange } from "./views.js";
11
- const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
11
+ const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
12
12
  /** Frontmatter keys whose values are references (spec/schema.md §3–§11); `relations` and `timeline` are handled by shape. */
13
13
  const REFERENCE_KEYS = new Set([
14
14
  "area", "goal", "client", "org", "projects", "people", "attendees", "decided_in", "raised_in", "deciders",
15
15
  "source", "sources", "decisions", "questions", "answer", "target", "supersedes", "merge_into", "project",
16
- "generated_from", "facts", "notes", "answer_note", "links", "attached_to",
16
+ "generated_from", "facts", "notes", "answer_note", "links", "attached_to", "changes", "replaces", "deferred_from",
17
17
  ]);
18
18
  /** Where each kind of view must live (spec/schema.md §2, 0041, 0043). */
19
19
  const VIEW_FOLDERS = {
@@ -32,12 +32,13 @@ const FOLDERS = {
32
32
  decision: /^knowledge\/decisions\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
33
33
  question: /^knowledge\/questions\/[^/]+\.md$/,
34
34
  concept: /^knowledge\/concepts\/[^/]+\.md$/,
35
+ reference: /^knowledge\/references\/[^/]+\.md$/,
35
36
  source: /^sources\/(notion|files|text)\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
36
37
  memory: /^memories\/\d{4}\/\d{2}\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
37
38
  proposal: /^_proposals\/[^/]+\.md$/,
38
39
  };
39
- const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
40
- const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept"]);
40
+ const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
41
+ const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept", "reference"]);
41
42
  const FRESHNESS_WORDS = /\b(currently|now|at the moment|this week|recently|soon)\b/i;
42
43
  const HAS_DATE = /\b\d{4}-\d{2}(-\d{2})?\b/;
43
44
  const MEMORY_REACH = { general: null, area: "areas", project: "projects", stack: "stacks" };
@@ -75,6 +76,12 @@ export function lintBrain(input) {
75
76
  for (const id of new Set(list(note.attributes.notes).map(String)))
76
77
  reviewedBy.set(id, [...(reviewedBy.get(id) ?? []), note.id]);
77
78
  }
79
+ // Every source replacing each source id (rule source-replaces, 0045).
80
+ const replacedBy = new Map();
81
+ for (const note of input.notes) {
82
+ if (note.type === "source" && typeof note.attributes.replaces === "string")
83
+ replacedBy.set(note.attributes.replaces, [...(replacedBy.get(note.attributes.replaces) ?? []), note.id]);
84
+ }
78
85
  const onDisk = caseSensitiveExists(input.root);
79
86
  const exists = (path) => input.pendingPaths?.has(path) === true || onDisk(path);
80
87
  for (const note of input.notes) {
@@ -218,11 +225,28 @@ export function lintBrain(input) {
218
225
  const listed = byId.get(String(id));
219
226
  if (listed !== undefined && !KNOWLEDGE.has(listed.type))
220
227
  err("review-notes", lineOfKey(note, "notes"), `${String(id)} is a ${listed.type}, not a knowledge note`);
221
- const others = (reviewedBy.get(String(id)) ?? []).filter((other) => other !== note.id);
228
+ // A deferred row lists again the notes the row it came from excluded (0045).
229
+ const others = (reviewedBy.get(String(id)) ?? []).filter((other) => other !== note.id && !deferredPair(note, byId.get(other), String(id)));
222
230
  if (others.length > 0)
223
231
  err("review-notes", lineOfKey(note, "notes"), `${String(id)} is also listed by ${others.join(", ")}`);
224
232
  }
225
233
  }
234
+ if (note.type === "proposal") {
235
+ const bodies = [...(a.kind === "update_body" ? [a.target] : []), ...list(a.changes).map(record).filter((change) => change.kind === "update_body").map((change) => change.target)];
236
+ for (const target of bodies) {
237
+ const reference = typeof target === "string" ? byId.get(target) : undefined;
238
+ if (reference !== undefined && reference.type !== "reference")
239
+ err("update-body", lineOfKey(note, a.kind === "update_body" ? "target" : "changes"), `update_body targets ${reference.id}, a ${reference.type}; only a reference has its body replaced (0045)`);
240
+ }
241
+ }
242
+ if (note.type === "source" && typeof a.replaces === "string") {
243
+ const replaced = byId.get(a.replaces);
244
+ if (replaced !== undefined && replaced.type !== "source")
245
+ err("source-replaces", lineOfKey(note, "replaces"), `replaces ${a.replaces}, a ${replaced.type}, not a source`);
246
+ const others = (replacedBy.get(a.replaces) ?? []).filter((other) => other !== note.id);
247
+ if (others.length > 0)
248
+ err("source-replaces", lineOfKey(note, "replaces"), `${a.replaces} is also replaced by ${others.join(", ")}`);
249
+ }
226
250
  if (note.type === "source" && Array.isArray(a.notion_parts)) {
227
251
  const problem = rangesProblem(a.notion_parts, splitParts(note.body).parts.length);
228
252
  if (problem !== null)
@@ -253,10 +277,10 @@ function proposalStatusProblem(a) {
253
277
  const expected = {
254
278
  pending: [undefined],
255
279
  approved: ["approve"],
256
- edited: ["edit"],
280
+ edited: ["fix", "edit"],
257
281
  rejected: ["reject"],
258
- applied: ["approve", "edit"],
259
- closed: stale ? ["approve", "edit"] : ["reject"],
282
+ applied: ["approve", "fix", "edit"],
283
+ closed: stale ? ["approve", "fix", "edit"] : ["reject"],
260
284
  };
261
285
  const allowed = expected[String(status)];
262
286
  if (allowed === undefined)
@@ -266,6 +290,13 @@ function proposalStatusProblem(a) {
266
290
  }
267
291
  return null;
268
292
  }
293
+ /** A deferred row and the row whose excluded note it took over (spec/schema.md §15, 0045). */
294
+ function deferredPair(note, other, id) {
295
+ if (other === undefined)
296
+ return false;
297
+ const excluded = (proposal) => list(record(proposal.attributes.decision).exclude).some((entry) => record(entry).note === id);
298
+ return (note.attributes.deferred_from === other.id && excluded(other)) || (other.attributes.deferred_from === note.id && excluded(note));
299
+ }
269
300
  function collectIds(value, out = []) {
270
301
  if (typeof value === "string") {
271
302
  if (ID.test(value))
@@ -7,7 +7,7 @@ import { rangesProblem, splitParts } from "./parts.js";
7
7
  import { newNotePath } from "./paths.js";
8
8
  import { formatDate, formatTimestamp } from "./time.js";
9
9
  const ITEM_KEYS = ["type", "id", "title", "file_date", "frontmatter", "body"];
10
- const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
10
+ const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
11
11
  const NEW_NOTE_TYPES = new Set([...KNOWLEDGE_TYPES, "source"]);
12
12
  const FORBIDDEN_KEYS = ["id", "type", "title", "created", "updated", "lifecycle"];
13
13
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -100,6 +100,24 @@ export function stageNotes(workspace, items, pointerOf, options) {
100
100
  if (problem !== undefined)
101
101
  parents.push({ pointer: `${pointerOf(index)}/frontmatter/attached_to`, message: problem });
102
102
  });
103
+ // replaces names a source, existing or earlier in the batch, that no other source replaces (spec/cli.md §5.5, 0045).
104
+ const replaced = new Map();
105
+ for (const note of workspace.notes)
106
+ if (note.type === "source" && typeof note.attributes.replaces === "string")
107
+ replaced.set(note.attributes.replaces, note.id);
108
+ items.forEach((item, index) => {
109
+ const earlier = item.type === "source" ? item.frontmatter.replaces : undefined;
110
+ if (earlier === undefined)
111
+ return;
112
+ const type = workspace.get(String(earlier))?.type ?? items.slice(0, index).find((candidate) => candidate.id === earlier)?.type;
113
+ const problem = type === undefined ? `${String(earlier)} is not an existing source or one earlier in the batch`
114
+ : type !== "source" ? `${String(earlier)} is a ${type}, not a source`
115
+ : replaced.has(String(earlier)) ? `${String(earlier)} is already replaced by ${replaced.get(String(earlier))}`
116
+ : undefined;
117
+ if (problem !== undefined)
118
+ parents.push({ pointer: `${pointerOf(index)}/frontmatter/replaces`, message: problem });
119
+ replaced.set(String(earlier), item.id ?? `/notes/${index}`);
120
+ });
103
121
  // A repo file names a linked repo of brain.json (0042).
104
122
  const repoNames = new Set(Array.isArray(workspace.config.repos) ? workspace.config.repos.filter(isRecord).map((repo) => repo.name) : []);
105
123
  items.forEach((item, index) => {
@@ -120,7 +138,8 @@ export function stageNotes(workspace, items, pointerOf, options) {
120
138
  if (item.type === "source") {
121
139
  const hash = `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
122
140
  const sourceKey = attributes.source_key;
123
- const duplicateByKey = typeof sourceKey === "string" && (sourceKey.startsWith("file:") || attributes.kind === "capture");
141
+ // A new version (replaces) is compared by its text only, so a corrected input can be read again (0045).
142
+ const duplicateByKey = attributes.replaces === undefined && typeof sourceKey === "string" && (sourceKey.startsWith("file:") || attributes.kind === "capture");
124
143
  const duplicate = workspace.notes.find((note) => note.type === "source" && (note.attributes.content_hash === hash || (duplicateByKey && note.attributes.source_key === sourceKey)));
125
144
  if (duplicate !== undefined) {
126
145
  result.skipped.push({ input_index: index, reason: "DUPLICATE_SOURCE", existing: duplicate.id });
package/dist/lib/paths.js CHANGED
@@ -49,6 +49,7 @@ function layout(type, slug, date, sourceKey) {
49
49
  case "org": return file(`knowledge/orgs/${slug}.md`);
50
50
  case "question": return file(`knowledge/questions/${slug}.md`);
51
51
  case "concept": return file(`knowledge/concepts/${slug}.md`);
52
+ case "reference": return file(`knowledge/references/${slug}.md`);
52
53
  case "meeting": return file(`knowledge/meetings/${date}-${slug}.md`);
53
54
  case "decision": return file(`knowledge/decisions/${date}-${slug}.md`);
54
55
  case "source": return file(`sources/${sourceFolder(sourceKey)}/${date}-${slug}.md`);
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { isDeepStrictEqual } from "node:util";
2
3
  import { BrainError } from "./errors.js";
3
4
  import { serializeNewFile } from "./frontmatter.js";
@@ -8,13 +9,15 @@ import { compareCodePoints } from "./notes.js";
8
9
  import { newNotePath } from "./paths.js";
9
10
  import { loadSchemas } from "./schemas.js";
10
11
  import { formatDate } from "./time.js";
11
- export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item", "review_notes", "answer"];
12
+ export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "update_body", "add_link", "supersede", "contradiction", "merge", "new_item", "review_notes", "answer"];
12
13
  export const PROPOSAL_STATUSES = ["pending", "approved", "rejected", "edited", "applied", "closed"];
13
14
  /** Input keys of `proposal new`: the schema §11 fields the CLI does not set (spec/cli.md §5.6). */
14
15
  const INPUT_KEYS = ["title", "kind", "target", "field", "notes", "answer", "answer_note", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
15
16
  const ID = /^[a-z]{2,4}-[0-9a-hjkmnp-tv-z]{8}$/;
16
- const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
17
+ const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
17
18
  const ADD_LINK_FIELDS = new Set(["aliases", "tags", "projects", "people", "attendees", "deciders"]);
19
+ /** Kinds a review_notes takes in as `changes`, in the order they are stored (spec/schema.md §11.3, 0045). */
20
+ export const CHANGE_KINDS = ["update_fact", "update_body", "add_to_note"];
18
21
  /** Fields that make two pending proposals the same change (dedupe, spec/cli.md §5.6). */
19
22
  const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into", "answer", "answer_note"];
20
23
  /**
@@ -40,7 +43,7 @@ export function stageProposals(workspace, items, now) {
40
43
  if (item.kind === "new_item")
41
44
  checkNewItem(workspace, item.note, `/proposals/${index}/note`, today);
42
45
  });
43
- // One review_notes per source; it takes in that source's add_link items (spec/cli.md §5.6, 0043).
46
+ // One review_notes per source; it takes in that source's add_link items and changes (spec/cli.md §5.6, 0043, 0045).
44
47
  const reviews = new Map();
45
48
  list.forEach((item, index) => {
46
49
  if (item.kind !== "review_notes")
@@ -49,7 +52,7 @@ export function stageProposals(workspace, items, now) {
49
52
  if (reviews.has(source))
50
53
  assertNoProblems([{ pointer: `/proposals/${index}/source`, message: `a second review_notes for ${source} in one input` }]);
51
54
  checkReviewNotes(workspace, item, `/proposals/${index}`);
52
- reviews.set(source, { index, item, links: [] });
55
+ reviews.set(source, { index, item, links: [], changes: [] });
53
56
  });
54
57
  const start = workspace.checkpoint();
55
58
  try {
@@ -62,7 +65,12 @@ export function stageProposals(workspace, items, now) {
62
65
  skipped.push({ input_index: index, reason: "ALREADY_ANSWERED" });
63
66
  return;
64
67
  }
65
- const review = item.kind === "add_link" ? reviews.get(String(fields.source)) : undefined;
68
+ if (item.kind === "update_body" && bodyOf(String(fields.content)) === workspace.get(String(fields.target)).body) {
69
+ skipped.push({ input_index: index, reason: "NO_CHANGE" });
70
+ return;
71
+ }
72
+ const folds = item.kind === "add_link" || CHANGE_KINDS.includes(String(item.kind));
73
+ const review = folds ? reviews.get(String(fields.source)) : undefined;
66
74
  if (item.kind === "add_link" && listField(workspace.get(String(fields.target)), String(fields.field)).some((value) => isDeepStrictEqual(value, fields.after))) {
67
75
  skipped.push({ input_index: index, reason: "ALREADY_LINKED" });
68
76
  return;
@@ -72,11 +80,20 @@ export function stageProposals(workspace, items, now) {
72
80
  ? (note.attributes.kind === "review_notes"
73
81
  ? review !== undefined && reviewLinks(note.attributes).some((link) => link.target === fields.target && link.field === fields.field && isDeepStrictEqual(link.value, fields.after))
74
82
  : note.attributes.target === fields.target && proposalLinks(note.attributes).some((link) => link.field === fields.field && isDeepStrictEqual(link.value, fields.after)))
75
- : DEDUPE_KEYS.every((key) => isDeepStrictEqual(note.attributes[key], fields[key]))));
83
+ : DEDUPE_KEYS.every((key) => isDeepStrictEqual(note.attributes[key], fields[key]))
84
+ || (CHANGE_KINDS.includes(String(item.kind)) && note.attributes.kind === "review_notes" && listOf(note.attributes.changes).some((change) => sameChange(change, fields)))));
76
85
  if (duplicate !== undefined) {
77
86
  skipped.push({ input_index: index, reason: "DUPLICATE_PENDING", existing: duplicate.id });
78
87
  return;
79
88
  }
89
+ if (review !== undefined && CHANGE_KINDS.includes(String(item.kind))) {
90
+ if (review.changes.some(({ change }) => sameChange(change, fields))) {
91
+ skipped.push({ input_index: index, reason: "DUPLICATE_PENDING" });
92
+ return;
93
+ }
94
+ review.changes.push({ index, change: changeEntry(fields) });
95
+ return;
96
+ }
80
97
  if (item.kind === "add_link") {
81
98
  const key = JSON.stringify([fields.target, fields.source, fields.field, fields.after]);
82
99
  if (linkKeys.has(key)) {
@@ -111,20 +128,24 @@ export function stageProposals(workspace, items, now) {
111
128
  }
112
129
  for (const review of reviews.values()) {
113
130
  const notes = review.item.notes;
114
- if (notes.length === 0 && review.links.length === 0) {
131
+ if (notes.length === 0 && review.links.length === 0 && review.changes.length === 0) {
115
132
  skipped.push({ input_index: review.index, reason: "NOTHING_TO_REVIEW" });
116
133
  continue;
117
134
  }
118
135
  const source = String(review.item.source);
136
+ // Changes are stored in render order: every update_fact, then update_body, then add_to_note (spec/schema.md §11.3).
137
+ const changes = [...review.changes].sort((a, b) => CHANGE_KINDS.indexOf(String(a.change.kind)) - CHANGE_KINDS.indexOf(String(b.change.kind)) || a.index - b.index);
119
138
  const fields = {
120
139
  kind: "review_notes",
121
- title: review.item.title ?? reviewTitle(workspace, source, notes.length, review.links.length),
140
+ title: review.item.title ?? reviewTitle(workspace, source, changes.length, notes.length, review.links.length),
122
141
  notes,
123
142
  links: review.links.map(({ fields }) => ({ target: fields.target, field: fields.field, value: fields.after })),
124
- why: review.item.why ?? `New notes and links from ${sourceTitle(workspace, source)}`,
143
+ ...(changes.length === 0 ? {} : { changes: changes.map(({ change }) => change) }),
144
+ why: review.item.why ?? `What came in from ${sourceTitle(workspace, source)}`,
125
145
  source,
126
146
  };
127
- const proposal = createProposal(workspace, fields, [review.index, ...review.links.map(({ index }) => index)].sort((a, b) => a - b), today, `/proposals/${review.index}`, pointers, created);
147
+ const held = [review.index, ...review.links.map(({ index }) => index), ...changes.map(({ index }) => index)].sort((a, b) => a - b);
148
+ const proposal = createProposal(workspace, fields, held, today, `/proposals/${review.index}`, pointers, created);
128
149
  for (const link of review.links)
129
150
  createdLinks.set(JSON.stringify([link.fields.target, link.fields.source, link.fields.field, link.fields.after]), proposal.id);
130
151
  }
@@ -148,6 +169,11 @@ function createProposal(workspace, fields, inputIndexes, today, pointer, pointer
148
169
  created.push(proposal);
149
170
  return proposal;
150
171
  }
172
+ /** Write a new pending proposal with these fields (apply's deferred row, spec/schema.md §11.3). Not validated. */
173
+ export function writeProposal(workspace, fields, today) {
174
+ const { id, path } = createProposal(workspace, fields, [], today, "", new Map(), []);
175
+ return { id, path };
176
+ }
151
177
  /** The `{target, field, value}` links of a review_notes proposal. */
152
178
  function reviewLinks(attributes) {
153
179
  return Array.isArray(attributes.links) ? attributes.links.filter(isRecord).map((link) => ({ target: link.target, field: link.field, value: link.value })) : [];
@@ -165,17 +191,38 @@ function reviewedIds(workspace) {
165
191
  return listed;
166
192
  }
167
193
  /** The meeting's title for a meeting id, else the source's (spec/cli.md §5.6). */
168
- function sourceTitle(workspace, source) {
194
+ export function sourceTitle(workspace, source) {
169
195
  return String(workspace.get(source)?.attributes.title ?? source);
170
196
  }
171
- function reviewTitle(workspace, source, notes, links) {
197
+ /** `From <source title>: 1 change, 3 new notes and 2 links` (spec/cli.md §5.6, 0045); a deferred row starts `Deferred from`. */
198
+ export function reviewTitle(workspace, source, changes, notes, links, lead = "From") {
172
199
  const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
173
- const from = sourceTitle(workspace, source);
174
- const title = notes === 0
175
- ? `Accept ${count(links, "link", "links")} from ${from}`
176
- : `Accept ${count(notes, "new note", "new notes")} from ${from}${links === 0 ? "" : `, and ${count(links, "link", "links")}`}`;
200
+ const parts = [[changes, "change", "changes"], [notes, "new note", "new notes"], [links, "link", "links"]];
201
+ const counts = parts.filter(([n]) => n > 0).map(([n, one, many]) => count(n, one, many));
202
+ const joined = counts.length <= 1 ? counts.join("") : `${counts.slice(0, -1).join(", ")} and ${counts.at(-1)}`;
203
+ const title = `${lead} ${sourceTitle(workspace, source)}: ${joined}`;
177
204
  return title.length > 120 ? `${title.slice(0, 119)}…` : title;
178
205
  }
206
+ /** A change entry of a review_notes: the item's kind, target, why and that kind's fields (spec/schema.md §11). */
207
+ function changeEntry(fields) {
208
+ const keys = ["kind", "target", "why", "field", "before", "after", "from", "content", "before_hash"];
209
+ return Object.fromEntries(keys.filter((key) => fields[key] !== undefined).map((key) => [key, fields[key]]));
210
+ }
211
+ function sameChange(change, fields) {
212
+ return isRecord(change) && ["kind", "target", "field", "after", "content"].every((key) => isDeepStrictEqual(change[key], fields[key]));
213
+ }
214
+ /** A reference's body as stored, for `content`: one blank line after the frontmatter, LF endings, a final newline. */
215
+ export function bodyOf(content) {
216
+ const text = content.replace(/\r\n?/g, "\n").replace(/^\n+/, "");
217
+ return `\n${text.endsWith("\n") ? text : `${text}\n`}`;
218
+ }
219
+ /** The body hash an update_body is checked against (the source hash rule, spec/schema.md §4). */
220
+ export function bodyHash(body) {
221
+ return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
222
+ }
223
+ function listOf(value) {
224
+ return Array.isArray(value) ? value : [];
225
+ }
179
226
  /** A review_notes item's notes: existing knowledge notes, listed once here and by no other review_notes. */
180
227
  function checkReviewNotes(workspace, item, pointer) {
181
228
  const source = workspace.get(String(item.source));
@@ -248,6 +295,8 @@ function checkShape(item, pointer) {
248
295
  }
249
296
  if (item.kind === "answer" && (typeof item.answer !== "string" || item.answer.trim() === ""))
250
297
  at("answer", "must be a non-empty string");
298
+ if (item.kind === "update_body" && (typeof item.content !== "string" || item.content.trim() === ""))
299
+ at("content", "must be the whole new body");
251
300
  if (item.kind !== "answer") {
252
301
  for (const key of ["answer", "answer_note"])
253
302
  if (item[key] !== undefined)
@@ -324,6 +373,11 @@ function resolveFields(workspace, item, pointer, today) {
324
373
  fields.from = typeof meeting?.attributes.date === "string" ? meeting.attributes.date : today;
325
374
  }
326
375
  }
376
+ if (kind === "update_body") {
377
+ if (target.type !== "reference")
378
+ invalid("target", `is a ${target.type}; update_body replaces the body of a reference`);
379
+ fields.before_hash = bodyHash(target.body);
380
+ }
327
381
  if (kind === "add_link") {
328
382
  const field = typeof item.field === "string" ? item.field : "";
329
383
  if (!ADD_LINK_FIELDS.has(field))
@@ -403,7 +457,7 @@ export function listProposals(workspace, statuses, target) {
403
457
  .filter((note) => note.type === "proposal")
404
458
  .filter((note) => statuses.length === 0 || statuses.includes(String(note.attributes.status)))
405
459
  .filter((note) => target === undefined || note.attributes.target === target
406
- || (note.attributes.kind === "review_notes" && ((Array.isArray(note.attributes.notes) && note.attributes.notes.includes(target)) || reviewLinks(note.attributes).some((link) => link.target === target))))
460
+ || (note.attributes.kind === "review_notes" && ((Array.isArray(note.attributes.notes) && note.attributes.notes.includes(target)) || reviewLinks(note.attributes).some((link) => link.target === target) || listOf(note.attributes.changes).some((change) => isRecord(change) && change.target === target))))
407
461
  .map((note) => ({ ...note.attributes, path: note.path }))
408
462
  .sort((a, b) => compareCodePoints(String(a.created), String(b.created)) || compareCodePoints(String(a.id), String(b.id)));
409
463
  }
@@ -436,7 +490,7 @@ export function stageBacklog(workspace, now) {
436
490
  claimed.add(note.id);
437
491
  const fields = {
438
492
  kind: "review_notes",
439
- title: reviewTitle(workspace, provenance, notes.length, 0),
493
+ title: reviewTitle(workspace, provenance, 0, notes.length, 0),
440
494
  notes: notes.map((note) => note.id),
441
495
  links: [],
442
496
  why: `Notes from ${sourceTitle(workspace, provenance)} waiting for your review`,
package/dist/lib/query.js CHANGED
@@ -4,7 +4,7 @@ import { BrainError } from "./errors.js";
4
4
  import { compareCodePoints } from "./notes.js";
5
5
  /** V1 note types accepted by `brain query --types` (spec/cli.md §5.13). */
6
6
  export const QUERY_TYPES = [
7
- "area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "memory", "proposal",
7
+ "area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "source", "view", "memory", "proposal",
8
8
  ];
9
9
  const DEFAULT_TYPES = new Set(QUERY_TYPES.filter((type) => !["view", "proposal", "memory"].includes(type)));
10
10
  const STOPWORDS = new Set([
@@ -3,7 +3,7 @@ import { Ajv2020 } from "ajv/dist/2020.js";
3
3
  /** The JSON Schemas of spec/schemas/, the single source of truth for file shapes. */
4
4
  const SCHEMA_DIR = new URL("../../spec/schemas/", import.meta.url);
5
5
  const KNOWLEDGE_TYPES = new Set([
6
- "area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory",
6
+ "area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory",
7
7
  ]);
8
8
  let cached;
9
9
  export function loadSchemas() {