ultimate-brain 0.4.1 → 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.
@@ -1,5 +1,6 @@
1
1
  import { BrainError } from "../lib/errors.js";
2
2
  import { rangesProblem, splitParts } from "../lib/parts.js";
3
+ import { lineDiff } from "../lib/diff.js";
3
4
  import { hasInboxBody } from "../lib/sync.js";
4
5
  import { formatTimestamp } from "../lib/time.js";
5
6
  import { Workspace } from "../lib/workspace.js";
@@ -54,3 +55,36 @@ export const sourceFullTextCommand = {
54
55
  };
55
56
  },
56
57
  };
58
+ /** `brain source diff <src-id>` (spec/cli.md §5.19, 0045): the lines that changed from the version it replaces. */
59
+ export const sourceDiffCommand = {
60
+ name: "source diff",
61
+ usage: "brain source diff <src-id> [--json]",
62
+ summary: "Show the lines a new version of a source changed against the version it replaces, without context lines. Read-only.",
63
+ options: {},
64
+ maxPositionals: 1,
65
+ needsBrain: true,
66
+ async run({ args, brain }) {
67
+ const [id] = args.positionals;
68
+ if (id === undefined)
69
+ throw new BrainError("USAGE", "Name the source: brain source diff <src-id>");
70
+ const workspace = await Workspace.open(brain);
71
+ const note = workspace.get(id);
72
+ if (note === undefined)
73
+ throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
74
+ const invalid = (message) => { throw new BrainError("INPUT_INVALID", message, [{ pointer: "", message }]); };
75
+ if (note.type !== "source")
76
+ invalid(`${id} is a ${note.type}, not a source`);
77
+ const replaces = note.attributes.replaces;
78
+ if (typeof replaces !== "string")
79
+ invalid(`${id} has no replaces: it is the first version`);
80
+ const earlier = workspace.get(replaces);
81
+ if (earlier === undefined)
82
+ throw new BrainError("UNKNOWN_ID", `No note with id ${String(replaces)}`);
83
+ const lines = lineDiff(earlier.body, note.body);
84
+ const diff = lines.join("\n");
85
+ return {
86
+ data: { id: note.id, replaces: earlier.id, removed: lines.filter((line) => line.startsWith("- ")).length, added: lines.filter((line) => line.startsWith("+ ")).length, diff },
87
+ human: diff === "" ? "no lines changed\n" : `${diff}\n`,
88
+ };
89
+ },
90
+ };
@@ -7,7 +7,7 @@ import { scanNotes } from "../lib/notes.js";
7
7
  import { describeErrors, loadSchemas } from "../lib/schemas.js";
8
8
  import { isRecord } from "../lib/input.js";
9
9
  import { expandPath, linkedRepos, workTreeTop } from "../lib/repos.js";
10
- const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory"];
10
+ const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory", "reference"];
11
11
  const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
12
12
  /** `brain status` (spec/cli.md §5.2). */
13
13
  export const statusCommand = {
@@ -75,7 +75,7 @@ function readNotion(config) {
75
75
  const databases = notion.databases;
76
76
  const configured = [];
77
77
  const missing = [];
78
- for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts"]) {
78
+ for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts", "references"]) {
79
79
  const value = key === "inbox" && databases.inbox === undefined ? databases.capture_inbox : databases[key];
80
80
  (typeof value === "string" && value !== "" ? configured : missing).push(key);
81
81
  }
package/dist/lib/apply.js CHANGED
@@ -2,15 +2,16 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { posix } from "node:path";
3
3
  import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
4
4
  import { compareCodePoints } from "./notes.js";
5
- import { stageNewItem } from "./proposals.js";
5
+ import { bodyHash, bodyOf, reviewTitle, sourceTitle, stageNewItem, writeProposal } from "./proposals.js";
6
6
  import { loadSchemas } from "./schemas.js";
7
7
  import { formatDate, normalizeTimestamp } from "./time.js";
8
- const ACTIONS = ["approve", "reject", "edit"];
9
- export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note", "answer"]);
8
+ const ACTIONS = ["approve", "reject", "fix", "edit"];
9
+ export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note", "update_body", "answer"]);
10
+ const FIX_KEYS = ["value", "change", "note", "field", "link"];
10
11
  /** Reference fields a merge leaves alone: the meeting side of a persisted pair (spec/schema.md §11.2). */
11
12
  const PAIR_FIELDS = new Set(["decisions", "questions", "decided_in", "raised_in"]);
12
13
  /** Corrections change knowledge notes only: sources are immutable and proposals are records. */
13
- const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
14
+ const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
14
15
  const REFERENCE_FIELDS = [
15
16
  "area", "areas", "goal", "client", "org", "projects", "people", "attendees", "deciders", "source", "sources",
16
17
  "answer", "project", "generated_from", "facts",
@@ -49,19 +50,25 @@ export function parseApplyInput(input) {
49
50
  const pointer = `/decisions/${index}`;
50
51
  if (!isRecord(item))
51
52
  return problems.push({ pointer, message: "must be an object" });
52
- problems.push(...unknownKeys(item, ["proposal", "action", "value", "note", "exclude", "decided_at", "decided_by"], pointer));
53
+ problems.push(...unknownKeys(item, ["proposal", "action", "value", "note", "fixes", "exclude", "decided_at", "decided_by"], pointer));
53
54
  if (typeof item.proposal !== "string")
54
55
  problems.push({ pointer: `${pointer}/proposal`, message: "must be a proposal id" });
55
56
  if (!ACTIONS.includes(item.action))
56
- problems.push({ pointer: `${pointer}/action`, message: "must be approve, reject or edit" });
57
+ problems.push({ pointer: `${pointer}/action`, message: "must be approve, reject, fix or edit" });
58
+ if (item.action === "fix" && (typeof item.note !== "string" || item.note === ""))
59
+ problems.push({ pointer: `${pointer}/note`, message: "is required for fix: the user's words" });
60
+ if (item.action === "fix" && (!Array.isArray(item.fixes) || item.fixes.length === 0 || item.fixes.some((entry) => !isRecord(entry) || !Object.hasOwn(entry, "value") || Object.keys(entry).some((key) => !FIX_KEYS.includes(key)))))
61
+ problems.push({ pointer: `${pointer}/fixes`, message: "must be a non-empty list of entries with a value (spec/schema.md §11.3)" });
62
+ if (item.action !== "fix" && item.fixes !== undefined)
63
+ problems.push({ pointer: `${pointer}/fixes`, message: "is only allowed for fix" });
57
64
  if (item.action === "edit" && (item.value === undefined || item.value === null))
58
65
  problems.push({ pointer: `${pointer}/value`, message: "is required for edit" });
59
66
  if (item.action !== "edit" && item.value !== undefined)
60
67
  problems.push({ pointer: `${pointer}/value`, message: "is only allowed for edit" });
61
68
  if (item.note !== undefined && typeof item.note !== "string")
62
69
  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) || !(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" });
70
+ if (item.exclude !== undefined && (!Array.isArray(item.exclude) || item.exclude.some((entry) => !isRecord(entry) || !(isNoteExclude(entry) || isChangeExclude(entry) || (typeof entry.field === "string" && Object.hasOwn(entry, "value") && Object.keys(entry).every((key) => ["target", "field", "value"].includes(key)))))))
71
+ problems.push({ pointer: `${pointer}/exclude`, message: "must be a list of {field, value}, {target, field, value}, {note} or {change} entries" });
65
72
  if (!isTimestamp(item.decided_at))
66
73
  problems.push({ pointer: `${pointer}/decided_at`, message: "must be an ISO 8601 UTC timestamp" });
67
74
  if (item.decided_by !== "notion" && item.decided_by !== "chat")
@@ -178,10 +185,10 @@ function applyDecision(workspace, input, today, recordOnly) {
178
185
  const p = document.attributes;
179
186
  if (p.status === "applied" || p.status === "closed")
180
187
  return { result: "skipped", reason: "ALREADY_TERMINAL" };
181
- const incoming = { action: input.action, ...(input.value === undefined ? {} : { value: input.value }), ...(input.note === undefined ? {} : { note: input.note }), ...(input.exclude === undefined ? {} : { exclude: input.exclude }), decided_at: input.decided_at, decided_by: input.decided_by };
188
+ const incoming = { action: input.action, ...(input.value === undefined ? {} : { value: input.value }), ...(input.note === undefined ? {} : { note: input.note }), ...(input.fixes === undefined ? {} : { fixes: input.fixes }), ...(input.exclude === undefined ? {} : { exclude: input.exclude }), decided_at: input.decided_at, decided_by: input.decided_by };
182
189
  const recorded = isRecord(p.decision) ? p.decision : undefined;
183
190
  let decision = incoming;
184
- if (recorded !== undefined && (recorded.action !== incoming.action || !isDeepStrictEqual(recorded.value, incoming.value) || !isDeepStrictEqual(recorded.exclude, incoming.exclude))) {
191
+ if (recorded !== undefined && (recorded.action !== incoming.action || !isDeepStrictEqual(recorded.value, incoming.value) || !isDeepStrictEqual(recorded.fixes, incoming.fixes) || !isDeepStrictEqual(recorded.exclude, incoming.exclude))) {
185
192
  // You changed your mind in Notion before it was applied: the later decision wins.
186
193
  if (Date.parse(recorded.decided_at) >= Date.parse(incoming.decided_at))
187
194
  decision = recorded;
@@ -190,30 +197,31 @@ function applyDecision(workspace, input, today, recordOnly) {
190
197
  decision = recorded;
191
198
  }
192
199
  if (decision.action === "edit" && !EDITABLE.has(String(p.kind))) {
193
- throw new ItemFailure("EDIT_NOT_ALLOWED", `edit is not allowed on a ${String(p.kind)} proposal (spec/schema.md §11.1)`);
200
+ throw new ItemFailure("EDIT_NOT_ALLOWED", `edit is not allowed on a ${String(p.kind)} proposal (spec/schema.md §11.1); use fix`);
194
201
  }
202
+ const exclude = decision.exclude ?? [];
195
203
  if (decision.exclude !== undefined) {
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");
204
+ if ((decision.action !== "approve" && decision.action !== "fix") || (p.kind !== "add_link" && p.kind !== "review_notes"))
205
+ throw new ItemFailure("INPUT_INVALID", "exclude is only allowed on an approved or fixed add_link or review_notes proposal");
198
206
  if (p.kind === "add_link") {
199
- if (decision.exclude.some((excluded) => excluded.target !== undefined || isNoteExclude(excluded)))
207
+ if (exclude.some((excluded) => excluded.target !== undefined || isNoteExclude(excluded) || isChangeExclude(excluded)))
200
208
  throw new ItemFailure("INPUT_INVALID", "exclude on an add_link takes {field, value} entries");
201
209
  const links = proposalLinks(p);
202
- if (decision.exclude.some((excluded) => !links.some((link) => link.field === excluded.field && isDeepStrictEqual(link.value, excluded.value))))
210
+ if (exclude.some((excluded) => !links.some((link) => link.field === excluded.field && isDeepStrictEqual(link.value, excluded.value))))
203
211
  throw new ItemFailure("EXCLUDE_UNKNOWN", "exclude contains a link that is not in the proposal");
204
212
  }
205
213
  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))))
214
+ if (exclude.some((excluded) => !isNoteExclude(excluded) && !isChangeExclude(excluded) && excluded.target === undefined))
215
+ throw new ItemFailure("INPUT_INVALID", "exclude on a review_notes takes {note}, {target, field, value} and {change} entries");
216
+ if (exclude.some((excluded) => !inReview(p, excluded)))
211
217
  throw new ItemFailure("EXCLUDE_UNKNOWN", "exclude contains an entry that is not in the proposal");
212
218
  }
213
219
  }
220
+ // A fix corrects the proposal before it is applied; the file keeps what was proposed (spec/schema.md §11.3).
221
+ const fixed = decision.action === "fix" ? corrected(workspace, p, decision.fixes ?? [], exclude) : { q: p, noteFixes: new Map() };
214
222
  const action = decision.action === incoming.action ? undefined : decision.action;
215
223
  p.decision = decision;
216
- p.status = decision.action === "approve" ? "approved" : decision.action === "edit" ? "edited" : "rejected";
224
+ p.status = decision.action === "approve" ? "approved" : decision.action === "reject" ? "rejected" : "edited";
217
225
  p.updated = today;
218
226
  if (recordOnly) {
219
227
  workspace.save(proposal.path, document);
@@ -223,18 +231,21 @@ function applyDecision(workspace, input, today, recordOnly) {
223
231
  p.status = "closed";
224
232
  workspace.save(proposal.path, document);
225
233
  // 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;
234
+ const notes = p.kind === "review_notes" ? reviewNotes(workspace, p, "reject", [], today, "", new Map()).notes : undefined;
227
235
  return { result: "closed", action, ...(notes === undefined ? {} : { notes }) };
228
236
  }
229
237
  const D = decision.decided_at.slice(0, 10);
230
- const V = decision.action === "edit" ? decision.value : undefined;
238
+ const V = decision.action === "edit" ? decision.value : fixed.value;
231
239
  if (p.kind === "review_notes") {
232
- const notes = reviewNotes(workspace, p, "approve", decision.exclude ?? [], today);
240
+ const applied = reviewNotes(workspace, fixed.q, "approve", exclude, today, D, fixed.noteFixes);
241
+ if (applied.dropped.length > 0)
242
+ p.dropped = applied.dropped;
233
243
  p.status = "applied";
234
244
  workspace.save(proposal.path, document);
235
- return { result: "applied", action, notes };
245
+ const deferred = exclude.length === 0 ? undefined : defer(workspace, proposal.id, p, exclude, today);
246
+ return { result: "applied", action, notes: applied.notes, changes: applied.changes, ...(deferred === undefined ? {} : { deferred }) };
236
247
  }
237
- const stale = applyKind(workspace, p, proposal.path, V, D, today, (decision.exclude ?? []));
248
+ const stale = applyKind(workspace, fixed.q, proposal.path, V, D, today, exclude);
238
249
  if (stale !== null) {
239
250
  p.status = "closed";
240
251
  p.closed_reason = "stale";
@@ -245,6 +256,158 @@ function applyDecision(workspace, input, today, recordOnly) {
245
256
  workspace.save(proposal.path, document);
246
257
  return { result: "applied", action };
247
258
  }
259
+ /** Whether an exclude or fix entry names an item of a review_notes (spec/schema.md §11.3). */
260
+ function inReview(p, entry) {
261
+ if (entry.change !== undefined)
262
+ return Number.isInteger(entry.change) && entry.change >= 0 && entry.change < list(p.changes).length;
263
+ if (entry.note !== undefined)
264
+ return list(p.notes).includes(entry.note);
265
+ return reviewLinks(p).some((link) => sameLink(link, entry));
266
+ }
267
+ /**
268
+ * The proposal as `fixes` correct it (spec/schema.md §11.3): a copy of its fields, the value V for
269
+ * single-value kinds, and the fields to set on listed notes. Throws FIX_UNKNOWN for an entry that names
270
+ * no item, INVALID_RESULT for a value of the wrong shape, INPUT_INVALID for an item fixed and excluded.
271
+ */
272
+ function corrected(workspace, p, fixes, exclude) {
273
+ const q = structuredClone(p);
274
+ const noteFixes = new Map();
275
+ const kind = String(p.kind);
276
+ const unknown = (entry) => { throw new ItemFailure("FIX_UNKNOWN", `fixes entry ${JSON.stringify(entry)} names no item of this ${kind} proposal`); };
277
+ const shape = (message) => { throw new ItemFailure("INVALID_RESULT", message); };
278
+ const only = (keys) => (entry) => Object.keys(entry).every((key) => keys.includes(key));
279
+ const single = () => {
280
+ const entry = fixes[0];
281
+ if (!only(["value"])(entry))
282
+ unknown(entry);
283
+ if (fixes.length !== 1)
284
+ shape(`a fix on a ${kind} takes exactly one {value}`);
285
+ return entry;
286
+ };
287
+ if (kind === "update_fact" || kind === "contradiction") {
288
+ const { value } = single();
289
+ if (typeof value !== "string" && typeof value !== "number")
290
+ shape("a fact value is a string or a number");
291
+ return { q, value, noteFixes };
292
+ }
293
+ if (kind === "add_to_note" || kind === "update_body" || kind === "answer") {
294
+ const { value } = single();
295
+ if (typeof value !== "string" || value.trim() === "")
296
+ shape(`a fix on a ${kind} is a non-empty text`);
297
+ return { q, value, noteFixes };
298
+ }
299
+ if (kind === "supersede" || kind === "merge") {
300
+ const { value } = single();
301
+ const key = kind === "supersede" ? "supersedes" : "merge_into";
302
+ const note = typeof value === "string" ? workspace.get(value) : undefined;
303
+ const target = workspace.get(String(p.target));
304
+ if (note === undefined || note.id === p.target || note.type !== target?.type)
305
+ shape(`${String(value)} must be another existing ${target?.type ?? "note"}`);
306
+ if (kind === "supersede" && list(note.attributes.relations).some((relation) => record(relation).type === "superseded_by"))
307
+ shape(`${note.id} is already superseded`);
308
+ q[key] = note.id;
309
+ return { q, noteFixes };
310
+ }
311
+ if (kind === "new_item") {
312
+ const note = record(q.note);
313
+ const frontmatter = record(note.frontmatter);
314
+ for (const entry of fixes) {
315
+ if (!only(["field", "value"])(entry) || typeof entry.field !== "string" || (entry.field !== "title" && !Object.hasOwn(frontmatter, entry.field)))
316
+ unknown(entry);
317
+ if (entry.field === "title")
318
+ note.title = entry.value;
319
+ else
320
+ frontmatter[entry.field] = entry.value;
321
+ }
322
+ return { q, noteFixes };
323
+ }
324
+ if (kind === "add_link") {
325
+ const links = list(q.links).map(record);
326
+ for (const entry of fixes) {
327
+ const link = record(entry.link);
328
+ const at = only(["link", "value"])(entry) ? links.findIndex((candidate) => candidate.field === link.field && isDeepStrictEqual(candidate.value, link.value)) : -1;
329
+ if (at === -1)
330
+ unknown(entry);
331
+ if (exclude.some((excluded) => excluded.field === link.field && isDeepStrictEqual(excluded.value, link.value)))
332
+ throw new ItemFailure("INPUT_INVALID", "an item can't be both fixed and excluded");
333
+ links[at] = { ...links[at], value: entry.value };
334
+ }
335
+ q.links = links;
336
+ return { q, noteFixes };
337
+ }
338
+ if (kind === "review_notes") {
339
+ const changes = list(q.changes).map(record);
340
+ const links = list(q.links).map(record);
341
+ for (const entry of fixes) {
342
+ const excluded = (item) => exclude.some((other) => isDeepStrictEqual(other, item));
343
+ if (entry.change !== undefined) {
344
+ if (!only(["change", "value"])(entry) || !inReview(p, { change: entry.change }))
345
+ unknown(entry);
346
+ if (excluded({ change: entry.change }))
347
+ throw new ItemFailure("INPUT_INVALID", "an item can't be both fixed and excluded");
348
+ const change = changes[entry.change];
349
+ if (change.kind === "update_fact") {
350
+ if (typeof entry.value !== "string" && typeof entry.value !== "number")
351
+ shape("a fact value is a string or a number");
352
+ change.after = entry.value;
353
+ }
354
+ else {
355
+ if (typeof entry.value !== "string" || entry.value.trim() === "")
356
+ shape(`a fix on an ${String(change.kind)} is a non-empty text`);
357
+ change.content = entry.value;
358
+ }
359
+ }
360
+ else if (entry.note !== undefined) {
361
+ if (!only(["note", "field", "value"])(entry) || !inReview(p, { note: entry.note }))
362
+ unknown(entry);
363
+ if (excluded({ note: entry.note }))
364
+ throw new ItemFailure("INPUT_INVALID", "an item can't be both fixed and excluded");
365
+ const field = entry.field;
366
+ if (typeof field !== "string" || !(NOTE_FIX_FIELDS.includes(field) || /^facts\.[a-z][a-z0-9_]*$/.test(field)))
367
+ shape(`${String(field)} is not a field a fix may set on a note`);
368
+ noteFixes.set(String(entry.note), [...(noteFixes.get(String(entry.note)) ?? []), { field, value: entry.value }]);
369
+ }
370
+ else if (entry.link !== undefined) {
371
+ const link = record(entry.link);
372
+ const at = only(["link", "value"])(entry) ? links.findIndex((candidate) => sameLink({ target: candidate.target, field: candidate.field, value: candidate.value }, link)) : -1;
373
+ if (at === -1)
374
+ unknown(entry);
375
+ if (excluded(link))
376
+ throw new ItemFailure("INPUT_INVALID", "an item can't be both fixed and excluded");
377
+ links[at] = { ...links[at], value: entry.value };
378
+ }
379
+ else {
380
+ unknown(entry);
381
+ }
382
+ }
383
+ q.changes = changes;
384
+ q.links = links;
385
+ return { q, noteFixes };
386
+ }
387
+ throw new ItemFailure("INVALID_RESULT", `Unknown proposal kind ${kind}`);
388
+ }
389
+ /** Fields a fix may set on a note listed by a review_notes: those a correction may set, and projects (spec/schema.md §11.3). */
390
+ const NOTE_FIX_FIELDS = ["title", "summary", "why", "area", "goal", "client", "org", "people", "projects"];
391
+ /**
392
+ * Write the excluded items of an applied review_notes as one new pending review_notes (spec/schema.md
393
+ * §11.3): the same source, `deferred_from` = this proposal, each item as stored, in its original order.
394
+ */
395
+ function defer(workspace, id, p, exclude, today) {
396
+ const notes = list(p.notes).filter((note) => exclude.some((entry) => isNoteExclude(entry) && entry.note === note));
397
+ const links = reviewLinks(p).filter((link) => exclude.some((entry) => sameLink(link, entry)));
398
+ const changes = list(p.changes).filter((_change, index) => exclude.some((entry) => entry.change === index));
399
+ const source = String(p.source);
400
+ const fields = {
401
+ kind: "review_notes",
402
+ title: reviewTitle(workspace, source, changes.length, notes.length, links.length, "Deferred from"),
403
+ notes, links,
404
+ ...(changes.length === 0 ? {} : { changes }),
405
+ deferred_from: id,
406
+ why: p.why,
407
+ source,
408
+ };
409
+ return writeProposal(workspace, fields, today).id;
410
+ }
248
411
  /** Apply a proposal's kind to its targets. Returns a message when the proposal is stale. */
249
412
  function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
250
413
  const kind = String(p.kind);
@@ -263,6 +426,8 @@ function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
263
426
  return `facts.${field} is ${shown(current)}, proposal expected ${shown(p.before)}`;
264
427
  }
265
428
  }
429
+ if (kind === "update_body" && bodyHash(target.body) !== p.before_hash)
430
+ return `${target.id}'s body changed after the proposal was written`;
266
431
  switch (kind) {
267
432
  case "update_fact":
268
433
  setFact(a, field, V ?? p.after, String(p.from), D, String(p.source));
@@ -280,6 +445,10 @@ function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
280
445
  a.sources = union(list(a.sources), [p.source]);
281
446
  break;
282
447
  }
448
+ case "update_body":
449
+ document.body = bodyOf(String(V ?? p.content));
450
+ a.sources = union(list(a.sources), [p.source]);
451
+ break;
283
452
  case "add_link":
284
453
  for (const link of proposalLinks(p))
285
454
  if (!exclude.some((item) => item.field === link.field && isDeepStrictEqual(item.value, link.value)) && !list(a[String(link.field)]).some((value) => isDeepStrictEqual(value, link.value)))
@@ -308,23 +477,55 @@ function applyKind(workspace, p, proposalPath, V, D, today, exclude = []) {
308
477
  return null;
309
478
  }
310
479
  /**
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`.
480
+ * Apply or reject a review_notes proposal (spec/schema.md §11.2–11.3). Approve applies, in order, the
481
+ * changes not excluded (a stale one is dropped alone), marks the listed draft notes `reviewed` with
482
+ * their fixes, and applies the links; reject archives the draft notes. Excluded entries stay as they
483
+ * are; a note no longer `draft` is left alone and reported `unchanged`.
314
484
  */
315
- function reviewNotes(workspace, p, action, exclude, today) {
485
+ function reviewNotes(workspace, p, action, exclude, today, D, noteFixes) {
486
+ const changes = [];
487
+ const dropped = [];
488
+ if (action === "approve") {
489
+ list(p.changes).map(record).forEach((change, index) => {
490
+ const entry = { index, kind: String(change.kind), target: String(change.target) };
491
+ if (exclude.some((excluded) => excluded.change === index)) {
492
+ changes.push({ ...entry, result: "deferred" });
493
+ return;
494
+ }
495
+ const stale = applyKind(workspace, { ...change, source: p.source }, "", undefined, D, today);
496
+ if (stale === null)
497
+ changes.push({ ...entry, result: "applied" });
498
+ else {
499
+ changes.push({ ...entry, result: "dropped", reason: "STALE_PROPOSAL" });
500
+ dropped.push({ change: index, reason: "stale" });
501
+ }
502
+ });
503
+ }
316
504
  const results = [];
317
505
  for (const id of list(p.notes).map(String)) {
318
506
  const note = existing(workspace, id);
319
- const lifecycle = String(note.attributes.lifecycle);
320
507
  const excluded = exclude.some((entry) => isNoteExclude(entry) && entry.note === id);
508
+ const fixes = action === "approve" ? noteFixes.get(id) ?? [] : [];
509
+ if (fixes.length > 0) {
510
+ const document = workspace.edit(note);
511
+ for (const { field, value } of fixes) {
512
+ const fact = /^facts\.(.+)$/.exec(String(field))?.[1];
513
+ if (fact !== undefined)
514
+ setFact(document.attributes, fact, value, D, D, String(p.source));
515
+ else
516
+ document.attributes[String(field)] = value;
517
+ }
518
+ document.attributes.updated = today;
519
+ workspace.save(note.path, document);
520
+ }
521
+ const lifecycle = String(workspace.get(id).attributes.lifecycle);
321
522
  if (lifecycle !== "draft")
322
523
  results.push({ id, result: "unchanged", lifecycle });
323
524
  else if (excluded)
324
525
  results.push({ id, result: "kept_draft", lifecycle });
325
526
  else {
326
527
  const next = action === "approve" ? "reviewed" : "archived";
327
- const document = workspace.edit(note);
528
+ const document = workspace.edit(workspace.get(id));
328
529
  document.attributes.lifecycle = next;
329
530
  document.attributes.updated = today;
330
531
  workspace.save(note.path, document);
@@ -345,7 +546,7 @@ function reviewNotes(workspace, p, action, exclude, today) {
345
546
  workspace.save(target.path, document);
346
547
  }
347
548
  }
348
- return results;
549
+ return { notes: results, changes, dropped };
349
550
  }
350
551
  function reviewLinks(proposal) {
351
552
  return list(proposal.links).filter(isRecord).map((link) => ({ target: link.target, field: link.field, value: link.value }));
@@ -356,6 +557,9 @@ function sameLink(link, entry) {
356
557
  function isNoteExclude(entry) {
357
558
  return typeof entry.note === "string" && Object.keys(entry).length === 1;
358
559
  }
560
+ function isChangeExclude(entry) {
561
+ return Number.isInteger(entry.change) && entry.change >= 0 && Object.keys(entry).length === 1;
562
+ }
359
563
  function proposalLinks(proposal) {
360
564
  if (Array.isArray(proposal.links))
361
565
  return proposal.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The changed lines of `after` against `before`, as `- ` and `+ ` lines with no context, by the
3
+ * longest common subsequence walk of spec/cli.md §5.19 (a tie removes first).
4
+ */
5
+ export function lineDiff(before, after) {
6
+ const o = before.replace(/\r\n/g, "\n").split("\n");
7
+ const n = after.replace(/\r\n/g, "\n").split("\n");
8
+ const L = Array.from({ length: o.length + 1 }, () => new Array(n.length + 1).fill(0));
9
+ for (let i = o.length - 1; i >= 0; i -= 1) {
10
+ for (let j = n.length - 1; j >= 0; j -= 1)
11
+ L[i][j] = o[i] === n[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
12
+ }
13
+ const out = [];
14
+ let i = 0;
15
+ let j = 0;
16
+ while (i < o.length && j < n.length) {
17
+ if (o[i] === n[j]) {
18
+ i += 1;
19
+ j += 1;
20
+ }
21
+ else if (L[i + 1][j] >= L[i][j + 1])
22
+ out.push(`- ${o[i++]}`);
23
+ else
24
+ out.push(`+ ${n[j++]}`);
25
+ }
26
+ while (i < o.length)
27
+ out.push(`- ${o[i++]}`);
28
+ while (j < n.length)
29
+ out.push(`+ ${n[j++]}`);
30
+ return out;
31
+ }
package/dist/lib/ids.js CHANGED
@@ -10,6 +10,7 @@ export const ID_PREFIXES = [
10
10
  "dec",
11
11
  "que",
12
12
  "con",
13
+ "ref",
13
14
  "src",
14
15
  "mem",
15
16
  "prp",
@@ -44,6 +45,7 @@ export const TYPE_PREFIX = {
44
45
  decision: "dec",
45
46
  question: "que",
46
47
  concept: "con",
48
+ reference: "ref",
47
49
  source: "src",
48
50
  memory: "mem",
49
51
  proposal: "prp",
@@ -11,8 +11,10 @@ const SECTIONS = [
11
11
  ["question", "Questions"],
12
12
  ["meeting", "Meetings"],
13
13
  ["concept", "Concepts"],
14
+ ["reference", "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))