ultimate-brain 0.4.1 → 0.6.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 (39) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +35 -22
  2. package/.agents/skills/brain-catch-up/SKILL.md +51 -36
  3. package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
  4. package/.agents/skills/brain-howto/SKILL.md +1 -1
  5. package/.agents/skills/brain-howto/references/guide.md +117 -48
  6. package/.agents/skills/brain-ingest/SKILL.md +14 -6
  7. package/.agents/skills/brain-ingest/references/cli-inputs.md +31 -5
  8. package/.agents/skills/brain-ingest/references/extraction.md +36 -1
  9. package/.agents/skills/brain-ingest/references/notion.md +8 -6
  10. package/.agents/skills/brain-ingest/references/sources.md +36 -14
  11. package/.agents/skills/brain-notion-change/SKILL.md +2 -0
  12. package/.agents/skills/brain-query/SKILL.md +18 -8
  13. package/.agents/skills/brain-sync/SKILL.md +31 -12
  14. package/.agents/skills/brain-sync/references/formats.md +28 -1
  15. package/dist/cli.js +5 -4
  16. package/dist/commands/modules.js +54 -0
  17. package/dist/commands/report.js +81 -4
  18. package/dist/commands/source.js +27 -35
  19. package/dist/commands/status.js +5 -5
  20. package/dist/commands/sync.js +3 -1
  21. package/dist/commands/upgrade.js +33 -18
  22. package/dist/lib/apply.js +237 -33
  23. package/dist/lib/diff.js +31 -0
  24. package/dist/lib/errors.js +2 -0
  25. package/dist/lib/ids.js +2 -0
  26. package/dist/lib/index-builder.js +6 -1
  27. package/dist/lib/lint.js +46 -8
  28. package/dist/lib/modules.js +91 -0
  29. package/dist/lib/note-new.js +111 -18
  30. package/dist/lib/paths.js +1 -0
  31. package/dist/lib/proposals.js +74 -18
  32. package/dist/lib/query.js +1 -1
  33. package/dist/lib/schemas.js +1 -1
  34. package/dist/lib/sync.js +246 -86
  35. package/package.json +1 -1
  36. package/spec/schemas/brain.schema.json +36 -0
  37. package/spec/schemas/note.schema.json +105 -18
  38. package/templates/brain/brain.json +3 -1
  39. package/templates/brain/knowledge/references/.gitkeep +0 -0
@@ -1,20 +1,30 @@
1
1
  import { BrainError } from "../lib/errors.js";
2
- import { rangesProblem, splitParts } from "../lib/parts.js";
3
- import { hasInboxBody } from "../lib/sync.js";
4
- import { formatTimestamp } from "../lib/time.js";
2
+ import { lineDiff } from "../lib/diff.js";
5
3
  import { Workspace } from "../lib/workspace.js";
6
- /** `brain source full-text <src-id> [--parts <range>[,<range>…]]` (spec/cli.md §5.17, 0043). */
4
+ /** `brain source full-text` (spec/cli.md §5.17): retired in 0.6.0 by decision 0049. Writes nothing. */
7
5
  export const sourceFullTextCommand = {
8
6
  name: "source full-text",
9
7
  usage: "brain source full-text <src-id> [--parts <range>[,<range>…]] [--json]",
10
- summary: "Ask for a source's text on its Inbox row: all of it, or the parts given. The next render writes it once and clears the row's box.",
8
+ summary: "Retired in 0.6.0: use Keep as document on the source's Inbox row.",
11
9
  options: { parts: "string" },
12
10
  maxPositionals: 1,
13
11
  needsBrain: true,
14
- async run({ args, brain, now }) {
12
+ async run() {
13
+ throw new BrainError("RETIRED", "brain source full-text was retired in 0.6.0: use Keep as document");
14
+ },
15
+ };
16
+ /** `brain source diff <src-id>` (spec/cli.md §5.19, 0045): the lines that changed from the version it replaces. */
17
+ export const sourceDiffCommand = {
18
+ name: "source diff",
19
+ usage: "brain source diff <src-id> [--json]",
20
+ summary: "Show the lines a new version of a source changed against the version it replaces, without context lines. Read-only.",
21
+ options: {},
22
+ maxPositionals: 1,
23
+ needsBrain: true,
24
+ async run({ args, brain }) {
15
25
  const [id] = args.positionals;
16
26
  if (id === undefined)
17
- throw new BrainError("USAGE", "Name the source: brain source full-text <src-id>");
27
+ throw new BrainError("USAGE", "Name the source: brain source diff <src-id>");
18
28
  const workspace = await Workspace.open(brain);
19
29
  const note = workspace.get(id);
20
30
  if (note === undefined)
@@ -22,35 +32,17 @@ export const sourceFullTextCommand = {
22
32
  const invalid = (message) => { throw new BrainError("INPUT_INVALID", message, [{ pointer: "", message }]); };
23
33
  if (note.type !== "source")
24
34
  invalid(`${id} is a ${note.type}, not a source`);
25
- if (!hasInboxBody(note))
26
- invalid(`${id} has no Inbox body the brain writes (a minutes or capture source, or an attached file)`);
27
- const count = splitParts(note.body).parts.length;
28
- const raw = args.options.parts;
29
- const parts = raw === undefined ? undefined : raw.split(",").map((part) => part.trim());
30
- if (parts !== undefined) {
31
- if (parts.some((part) => part === ""))
32
- invalid(`--parts ${raw} has an empty item`);
33
- const problem = rangesProblem(parts, count);
34
- if (problem !== null)
35
- invalid(`--parts ${problem}`);
36
- }
37
- const requested = formatTimestamp(now);
38
- const document = workspace.edit(note);
39
- document.attributes.notion_text = "full";
40
- if (parts === undefined)
41
- delete document.attributes.notion_parts;
42
- else
43
- document.attributes.notion_parts = parts;
44
- document.attributes.notion_text_requested = requested;
45
- // System fields only: `updated` stays (spec/cli.md §3.6.6).
46
- workspace.save(note.path, document);
47
- const errors = workspace.newErrors();
48
- if (errors.length > 0)
49
- throw new BrainError("INPUT_INVALID", errors.map((issue) => `${issue.path}: ${issue.rule}: ${issue.message}`).join("; "));
50
- await workspace.commit([{ op: "full-text", id: note.id, title: String(note.attributes.title), pairs: [["parts", parts === undefined ? "all" : parts.join(",")]] }], now);
35
+ const replaces = note.attributes.replaces;
36
+ if (typeof replaces !== "string")
37
+ invalid(`${id} has no replaces: it is the first version`);
38
+ const earlier = workspace.get(replaces);
39
+ if (earlier === undefined)
40
+ throw new BrainError("UNKNOWN_ID", `No note with id ${String(replaces)}`);
41
+ const lines = lineDiff(earlier.body, note.body);
42
+ const diff = lines.join("\n");
51
43
  return {
52
- data: { id: note.id, path: note.path, parts: count, notion_text: "full", notion_parts: parts ?? null, notion_text_requested: requested },
53
- human: `${note.path}: full text${parts === undefined ? "" : ` (parts ${parts.join(", ")} of ${count})`} on the next publish\n`,
44
+ data: { id: note.id, replaces: earlier.id, removed: lines.filter((line) => line.startsWith("- ")).length, added: lines.filter((line) => line.startsWith("+ ")).length, diff },
45
+ human: diff === "" ? "no lines changed\n" : `${diff}\n`,
54
46
  };
55
47
  },
56
48
  };
@@ -6,8 +6,9 @@ import { lintBrain } from "../lib/lint.js";
6
6
  import { scanNotes } from "../lib/notes.js";
7
7
  import { describeErrors, loadSchemas } from "../lib/schemas.js";
8
8
  import { isRecord } from "../lib/input.js";
9
+ import { databaseId, modulesOf } from "../lib/modules.js";
9
10
  import { expandPath, linkedRepos, workTreeTop } from "../lib/repos.js";
10
- const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory"];
11
+ const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory", "reference"];
11
12
  const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
12
13
  /** `brain status` (spec/cli.md §5.2). */
13
14
  export const statusCommand = {
@@ -61,6 +62,7 @@ export const statusCommand = {
61
62
  notes: noteCounts,
62
63
  proposals: proposalCounts,
63
64
  notion,
65
+ modules: modulesOf(brain.config),
64
66
  settings,
65
67
  repos,
66
68
  index_stale: currentIndex !== index,
@@ -72,12 +74,10 @@ export const statusCommand = {
72
74
  };
73
75
  function readNotion(config) {
74
76
  const notion = config.notion;
75
- const databases = notion.databases;
76
77
  const configured = [];
77
78
  const missing = [];
78
- for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts"]) {
79
- const value = key === "inbox" && databases.inbox === undefined ? databases.capture_inbox : databases[key];
80
- (typeof value === "string" && value !== "" ? configured : missing).push(key);
79
+ for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts", "documents"]) {
80
+ (databaseId(config, key) !== "" ? configured : missing).push(key);
81
81
  }
82
82
  const tasks = notion.tasks;
83
83
  return { configured, missing, tasks_database: typeof tasks.database === "string" && tasks.database !== "" };
@@ -1,6 +1,7 @@
1
1
  import { readJsonInput } from "../lib/input.js";
2
2
  import { applyPlan, buildPlan, parsePlan, parseSnapshot, record, render, renderTasks } from "../lib/sync.js";
3
3
  import { Workspace } from "../lib/workspace.js";
4
+ import { modulesOf } from "../lib/modules.js";
4
5
  import { BrainError } from "../lib/errors.js";
5
6
  import { lintWarnings } from "../lib/warnings.js";
6
7
  export const syncPlanCommand = {
@@ -43,7 +44,8 @@ export const syncRenderCommand = {
43
44
  export const syncTasksCommand = {
44
45
  name: "sync tasks", usage: "brain sync tasks (--meeting <mtg-id> | --source <src-id>) --input <file|-> [--json]", summary: "Render action-item task payloads for a meeting or source.", options: { meeting: "string", source: "string", input: "string" }, maxPositionals: 0, needsBrain: true,
45
46
  async run({ args, brain }) { const meeting = args.options.meeting; const source = args.options.source; const input = args.options.input; if ((meeting === undefined) === (source === undefined) || input === undefined)
46
- throw new BrainError("USAGE", "sync tasks needs exactly one of --meeting or --source, and --input"); return { data: renderTasks(await Workspace.open(brain), meeting ?? source, await readJsonInput(input, "sync tasks")), human: "Task payloads rendered.\n" }; },
47
+ throw new BrainError("USAGE", "sync tasks needs exactly one of --meeting or --source, and --input"); if (!modulesOf(brain.config).tasks)
48
+ throw new BrainError("MODULE_OFF", "The Tasks module is off: brain modules on tasks"); return { data: renderTasks(await Workspace.open(brain), meeting ?? source, await readJsonInput(input, "sync tasks")), human: "Task payloads rendered.\n" }; },
47
49
  };
48
50
  export const syncRecordCommand = {
49
51
  name: "sync record", usage: "brain sync record --input <file|-> [--dry-run] [--json]", summary: "Record successful Notion publication results.", options: { input: "string", "dry-run": "boolean" }, maxPositionals: 0, needsBrain: true,
@@ -5,7 +5,9 @@ import { promisify } from "node:util";
5
5
  import { BrainError } from "../lib/errors.js";
6
6
  import { atomicWriteFile, ensureDirectory } from "../lib/fs-safe.js";
7
7
  import { appendLog } from "../lib/log.js";
8
+ import { isRecord } from "../lib/input.js";
8
9
  import { lintBrain } from "../lib/lint.js";
10
+ import { modulesOf, withModules } from "../lib/modules.js";
9
11
  import { scanNotes } from "../lib/notes.js";
10
12
  import { assertPlainPath, installedProduct, listShippedSkillFiles } from "../lib/skills.js";
11
13
  const exec = promisify(execFile);
@@ -50,32 +52,33 @@ export const upgradeCommand = {
50
52
  changes.push({ path: "CLAUDE.md", action: "created", content: "@AGENTS.md\n" });
51
53
  else
52
54
  unchanged += 1;
53
- // 0.3.0: Inbox is the former Capture Inbox. Keep the database id, but retire the old key.
55
+ // 0.3.0: Inbox is the former Capture Inbox; 0.6.0: Documents are the former References (0049).
56
+ // Keep each database id, but retire the old key. 0.6.0 also writes the modules block (0048).
54
57
  const brainJson = "brain.json";
55
58
  await assertPlainPath(root, brainJson);
56
59
  const brainText = await readOptional(join(root, brainJson));
57
60
  if (brainText !== null) {
58
61
  try {
59
- const config = JSON.parse(brainText);
62
+ let config = JSON.parse(brainText);
63
+ let changed = false;
60
64
  const notion = config.notion;
61
- if (notion !== null && typeof notion === "object" && !Array.isArray(notion)) {
62
- const databases = notion.databases;
63
- if (databases !== null && typeof databases === "object" && !Array.isArray(databases)) {
64
- const values = databases;
65
- if (values.capture_inbox !== undefined) {
66
- const inbox = typeof values.inbox === "string" && values.inbox !== "" ? values.inbox : values.capture_inbox;
67
- const nextDatabases = {};
68
- for (const [key, value] of Object.entries(values)) {
69
- if (key === "capture_inbox")
70
- nextDatabases.inbox = inbox;
71
- else if (key !== "inbox")
72
- nextDatabases[key] = value;
73
- }
74
- const next = { ...config, notion: { ...notion, databases: nextDatabases } };
75
- changes.push({ path: brainJson, action: "updated", content: `${JSON.stringify(next, null, 2)}\n` });
76
- }
65
+ if (isRecord(notion) && isRecord(notion.databases)) {
66
+ let databases = notion.databases;
67
+ for (const [old, key] of [["capture_inbox", "inbox"], ["references", "documents"]]) {
68
+ if (databases[old] === undefined)
69
+ continue;
70
+ databases = renameKey(databases, old, key);
71
+ changed = true;
77
72
  }
73
+ if (changed)
74
+ config = { ...config, notion: { ...notion, databases } };
75
+ }
76
+ if (isRecord(config) && !("modules" in config)) {
77
+ config = withModules(config, modulesOf(config));
78
+ changed = true;
78
79
  }
80
+ if (changed)
81
+ changes.push({ path: brainJson, action: "updated", content: `${JSON.stringify(config, null, 2)}\n` });
79
82
  }
80
83
  catch {
81
84
  changes.push({ path: brainJson, action: "skipped" });
@@ -261,3 +264,15 @@ async function gitDirty(root) {
261
264
  return false;
262
265
  return (await exec("git", ["status", "--porcelain"], { cwd: root })).stdout !== "";
263
266
  }
267
+ /** Rename `old` to `key` in place; when `key` already holds an id, just drop `old` (spec/cli.md §5.15 step 5). */
268
+ function renameKey(values, old, key) {
269
+ const value = typeof values[key] === "string" && values[key] !== "" ? values[key] : values[old];
270
+ const next = {};
271
+ for (const [name, existing] of Object.entries(values)) {
272
+ if (name === old)
273
+ next[key] = value;
274
+ else if (name !== key)
275
+ next[name] = existing;
276
+ }
277
+ return next;
278
+ }
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 }));