ultimate-brain 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,15 +8,15 @@ import { compareCodePoints } from "./notes.js";
8
8
  import { newNotePath } from "./paths.js";
9
9
  import { loadSchemas } from "./schemas.js";
10
10
  import { formatDate } from "./time.js";
11
- export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item"];
11
+ export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item", "review_notes", "answer"];
12
12
  export const PROPOSAL_STATUSES = ["pending", "approved", "rejected", "edited", "applied", "closed"];
13
13
  /** Input keys of `proposal new`: the schema §11 fields the CLI does not set (spec/cli.md §5.6). */
14
- const INPUT_KEYS = ["title", "kind", "target", "field", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
14
+ const INPUT_KEYS = ["title", "kind", "target", "field", "notes", "answer", "answer_note", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
15
15
  const ID = /^[a-z]{2,4}-[0-9a-hjkmnp-tv-z]{8}$/;
16
16
  const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
17
17
  const ADD_LINK_FIELDS = new Set(["aliases", "tags", "projects", "people", "attendees", "deciders"]);
18
18
  /** Fields that make two pending proposals the same change (dedupe, spec/cli.md §5.6). */
19
- const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into"];
19
+ const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into", "answer", "answer_note"];
20
20
  /**
21
21
  * Stage proposals for `proposal new` (spec/cli.md §5.6) and validate them. Throws INPUT_INVALID,
22
22
  * UNKNOWN_ID or BEFORE_MISMATCH for the whole batch; nothing is staged in that case.
@@ -40,18 +40,38 @@ export function stageProposals(workspace, items, now) {
40
40
  if (item.kind === "new_item")
41
41
  checkNewItem(workspace, item.note, `/proposals/${index}/note`, today);
42
42
  });
43
+ // One review_notes per source; it takes in that source's add_link items (spec/cli.md §5.6, 0043).
44
+ const reviews = new Map();
45
+ list.forEach((item, index) => {
46
+ if (item.kind !== "review_notes")
47
+ return;
48
+ const source = String(item.source);
49
+ if (reviews.has(source))
50
+ assertNoProblems([{ pointer: `/proposals/${index}/source`, message: `a second review_notes for ${source} in one input` }]);
51
+ checkReviewNotes(workspace, item, `/proposals/${index}`);
52
+ reviews.set(source, { index, item, links: [] });
53
+ });
43
54
  const start = workspace.checkpoint();
44
55
  try {
45
56
  list.forEach((item, index) => {
46
57
  const pointer = `/proposals/${index}`;
58
+ if (item.kind === "review_notes")
59
+ return;
47
60
  const fields = resolveFields(workspace, item, pointer, today);
61
+ if (item.kind === "answer" && workspace.get(String(fields.target)).attributes.answer !== undefined) {
62
+ skipped.push({ input_index: index, reason: "ALREADY_ANSWERED" });
63
+ return;
64
+ }
65
+ const review = item.kind === "add_link" ? reviews.get(String(fields.source)) : undefined;
48
66
  if (item.kind === "add_link" && listField(workspace.get(String(fields.target)), String(fields.field)).some((value) => isDeepStrictEqual(value, fields.after))) {
49
67
  skipped.push({ input_index: index, reason: "ALREADY_LINKED" });
50
68
  return;
51
69
  }
52
70
  const duplicate = workspace.notes.find((note) => note.type === "proposal" && note.attributes.status === "pending" &&
53
71
  (item.kind === "add_link"
54
- ? note.attributes.target === fields.target && proposalLinks(note.attributes).some((link) => link.field === fields.field && isDeepStrictEqual(link.value, fields.after))
72
+ ? (note.attributes.kind === "review_notes"
73
+ ? review !== undefined && reviewLinks(note.attributes).some((link) => link.target === fields.target && link.field === fields.field && isDeepStrictEqual(link.value, fields.after))
74
+ : note.attributes.target === fields.target && proposalLinks(note.attributes).some((link) => link.field === fields.field && isDeepStrictEqual(link.value, fields.after)))
55
75
  : DEDUPE_KEYS.every((key) => isDeepStrictEqual(note.attributes[key], fields[key]))));
56
76
  if (duplicate !== undefined) {
57
77
  skipped.push({ input_index: index, reason: "DUPLICATE_PENDING", existing: duplicate.id });
@@ -64,7 +84,10 @@ export function stageProposals(workspace, items, now) {
64
84
  return;
65
85
  }
66
86
  linkKeys.set(key, index);
67
- links.push({ item, index, fields });
87
+ if (review !== undefined)
88
+ review.links.push({ index, fields });
89
+ else
90
+ links.push({ item, index, fields });
68
91
  return;
69
92
  }
70
93
  createProposal(workspace, fields, [index], today, pointer, pointers, created);
@@ -86,6 +109,25 @@ export function stageProposals(workspace, items, now) {
86
109
  for (const link of group)
87
110
  createdLinks.set(JSON.stringify([link.fields.target, link.fields.source, link.fields.field, link.fields.after]), proposal.id);
88
111
  }
112
+ for (const review of reviews.values()) {
113
+ const notes = review.item.notes;
114
+ if (notes.length === 0 && review.links.length === 0) {
115
+ skipped.push({ input_index: review.index, reason: "NOTHING_TO_REVIEW" });
116
+ continue;
117
+ }
118
+ const source = String(review.item.source);
119
+ const fields = {
120
+ kind: "review_notes",
121
+ title: review.item.title ?? reviewTitle(workspace, source, notes.length, review.links.length),
122
+ notes,
123
+ 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)}`,
125
+ source,
126
+ };
127
+ const proposal = createProposal(workspace, fields, [review.index, ...review.links.map(({ index }) => index)].sort((a, b) => a - b), today, `/proposals/${review.index}`, pointers, created);
128
+ for (const link of review.links)
129
+ createdLinks.set(JSON.stringify([link.fields.target, link.fields.source, link.fields.field, link.fields.after]), proposal.id);
130
+ }
89
131
  for (const duplicate of duplicateLinks)
90
132
  skipped.push({ input_index: duplicate.input_index, reason: "DUPLICATE_PENDING", existing: createdLinks.get(duplicate.key) });
91
133
  assertStagedValid(workspace, pointers, "/proposals");
@@ -106,6 +148,60 @@ function createProposal(workspace, fields, inputIndexes, today, pointer, pointer
106
148
  created.push(proposal);
107
149
  return proposal;
108
150
  }
151
+ /** The `{target, field, value}` links of a review_notes proposal. */
152
+ function reviewLinks(attributes) {
153
+ return Array.isArray(attributes.links) ? attributes.links.filter(isRecord).map((link) => ({ target: link.target, field: link.field, value: link.value })) : [];
154
+ }
155
+ /** Ids listed by every review_notes proposal, any status (spec/cli.md §5.6). */
156
+ function reviewedIds(workspace) {
157
+ const listed = new Map();
158
+ for (const note of workspace.notes) {
159
+ if (note.type !== "proposal" || note.attributes.kind !== "review_notes" || !Array.isArray(note.attributes.notes))
160
+ continue;
161
+ for (const id of note.attributes.notes)
162
+ if (typeof id === "string" && !listed.has(id))
163
+ listed.set(id, note.id);
164
+ }
165
+ return listed;
166
+ }
167
+ /** The meeting's title for a meeting id, else the source's (spec/cli.md §5.6). */
168
+ function sourceTitle(workspace, source) {
169
+ return String(workspace.get(source)?.attributes.title ?? source);
170
+ }
171
+ function reviewTitle(workspace, source, notes, links) {
172
+ 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")}`}`;
177
+ return title.length > 120 ? `${title.slice(0, 119)}…` : title;
178
+ }
179
+ /** A review_notes item's notes: existing knowledge notes, listed once here and by no other review_notes. */
180
+ function checkReviewNotes(workspace, item, pointer) {
181
+ const source = workspace.get(String(item.source));
182
+ if (source === undefined)
183
+ throw new BrainError("UNKNOWN_ID", `${pointer}/source: no note with id ${String(item.source)}`, [{ pointer: `${pointer}/source`, message: "does not exist" }]);
184
+ if (source.type !== "meeting" && source.type !== "source")
185
+ assertNoProblems([{ pointer: `${pointer}/source`, message: "must be a meeting or a source" }]);
186
+ const listed = reviewedIds(workspace);
187
+ const seen = new Set();
188
+ const problems = [];
189
+ item.notes.forEach((id, index) => {
190
+ const at = `${pointer}/notes/${index}`;
191
+ const note = typeof id === "string" ? workspace.get(id) : undefined;
192
+ if (note === undefined)
193
+ problems.push({ pointer: at, message: `no note with id ${String(id)}` });
194
+ else if (!KNOWLEDGE_TYPES.has(note.type))
195
+ problems.push({ pointer: at, message: `is a ${note.type}, not a knowledge note` });
196
+ else if (seen.has(note.id))
197
+ problems.push({ pointer: at, message: "is listed twice" });
198
+ else if (listed.has(note.id))
199
+ problems.push({ pointer: at, message: `is already listed by ${listed.get(note.id)}` });
200
+ if (typeof id === "string")
201
+ seen.add(id);
202
+ });
203
+ assertNoProblems(problems);
204
+ }
109
205
  function proposalLinks(attributes) {
110
206
  if (Array.isArray(attributes.links))
111
207
  return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
@@ -133,11 +229,34 @@ function checkShape(item, pointer) {
133
229
  const at = (key, message) => problems.push({ pointer: `${pointer}/${key}`, message });
134
230
  if (!PROPOSAL_KINDS.includes(item.kind))
135
231
  at("kind", `must be one of ${PROPOSAL_KINDS.join(", ")}`);
136
- for (const key of ["title", "why", "source"]) {
232
+ for (const key of item.kind === "review_notes" ? ["source"] : ["title", "why", "source"]) {
137
233
  if (typeof item[key] !== "string" || item[key] === "")
138
234
  at(key, "must be a non-empty string");
139
235
  }
140
- if (item.kind === "new_item") {
236
+ for (const key of item.kind === "review_notes" ? ["title", "why"] : []) {
237
+ if (item[key] !== undefined && (typeof item[key] !== "string" || item[key] === ""))
238
+ at(key, "must be a non-empty string");
239
+ }
240
+ if (item.kind === "review_notes") {
241
+ if (item.target !== undefined)
242
+ at("target", "a review_notes has no target");
243
+ if (!Array.isArray(item.notes))
244
+ at("notes", "must be a list of note ids");
245
+ }
246
+ else if (item.notes !== undefined) {
247
+ at("notes", "is only allowed on review_notes");
248
+ }
249
+ if (item.kind === "answer" && (typeof item.answer !== "string" || item.answer.trim() === ""))
250
+ at("answer", "must be a non-empty string");
251
+ if (item.kind !== "answer") {
252
+ for (const key of ["answer", "answer_note"])
253
+ if (item[key] !== undefined)
254
+ at(key, "is only allowed on answer");
255
+ }
256
+ if (item.kind === "review_notes") {
257
+ // Checked against the brain in checkReviewNotes.
258
+ }
259
+ else if (item.kind === "new_item") {
141
260
  if (item.target !== undefined)
142
261
  at("target", "a new_item has no target");
143
262
  problems.push(...checkNoteInput(item.note, `${pointer}/note`, false));
@@ -177,6 +296,15 @@ function resolveFields(workspace, item, pointer, today) {
177
296
  const target = existing("target");
178
297
  if (!KNOWLEDGE_TYPES.has(target.type))
179
298
  invalid("target", `is a ${target.type}; proposals change knowledge notes only`);
299
+ if (kind === "answer") {
300
+ if (target.type !== "question")
301
+ invalid("target", `is a ${target.type}; an answer targets a question`);
302
+ if (item.answer_note !== undefined) {
303
+ const note = existing("answer_note");
304
+ if (!KNOWLEDGE_TYPES.has(note.type))
305
+ invalid("answer_note", `is a ${note.type}, not a knowledge note`);
306
+ }
307
+ }
180
308
  if (kind === "update_fact" || kind === "contradiction") {
181
309
  if (typeof item.field !== "string")
182
310
  invalid("field", "is required for this kind");
@@ -274,7 +402,54 @@ export function listProposals(workspace, statuses, target) {
274
402
  return workspace.notes
275
403
  .filter((note) => note.type === "proposal")
276
404
  .filter((note) => statuses.length === 0 || statuses.includes(String(note.attributes.status)))
277
- .filter((note) => target === undefined || note.attributes.target === target)
405
+ .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))))
278
407
  .map((note) => ({ ...note.attributes, path: note.path }))
279
408
  .sort((a, b) => compareCodePoints(String(a.created), String(b.created)) || compareCodePoints(String(a.id), String(b.id)));
280
409
  }
410
+ /**
411
+ * `proposal backlog` (spec/cli.md §5.6, 0041): one review_notes per source over the `draft`
412
+ * notes nobody has accepted yet, for a brain made before 0.4.0. A second run writes nothing.
413
+ */
414
+ export function stageBacklog(workspace, now) {
415
+ const today = formatDate(now);
416
+ const listed = reviewedIds(workspace);
417
+ const claimed = new Set();
418
+ const created = [];
419
+ const pointers = new Map();
420
+ const sources = workspace.notes
421
+ .filter((note) => note.type === "source")
422
+ .sort((a, b) => compareCodePoints(String(a.attributes.captured ?? ""), String(b.attributes.captured ?? "")) || compareCodePoints(a.id, b.id));
423
+ const start = workspace.checkpoint();
424
+ try {
425
+ for (const source of sources) {
426
+ const minutes = (source.attributes.kind ?? "minutes") === "minutes";
427
+ const meeting = minutes ? workspace.notes.find((note) => note.type === "meeting" && note.attributes.source === source.id) : undefined;
428
+ const provenance = meeting?.id ?? source.id;
429
+ const notes = workspace.notes
430
+ .filter((note) => KNOWLEDGE_TYPES.has(note.type) && note.attributes.lifecycle === "draft" && !listed.has(note.id) && !claimed.has(note.id)
431
+ && (note.id === meeting?.id || (Array.isArray(note.attributes.sources) && note.attributes.sources.includes(provenance))))
432
+ .sort((a, b) => compareCodePoints(String(a.attributes.created), String(b.attributes.created)) || compareCodePoints(a.id, b.id));
433
+ if (notes.length === 0)
434
+ continue;
435
+ for (const note of notes)
436
+ claimed.add(note.id);
437
+ const fields = {
438
+ kind: "review_notes",
439
+ title: reviewTitle(workspace, provenance, notes.length, 0),
440
+ notes: notes.map((note) => note.id),
441
+ links: [],
442
+ why: `Notes from ${sourceTitle(workspace, provenance)} waiting for your review`,
443
+ source: provenance,
444
+ };
445
+ const { input_indexes: _unused, ...proposal } = createProposal(workspace, fields, [], today, "", pointers, []);
446
+ created.push(proposal);
447
+ }
448
+ assertStagedValid(workspace, pointers, "");
449
+ }
450
+ catch (error) {
451
+ workspace.restore(start);
452
+ throw error;
453
+ }
454
+ return created;
455
+ }
@@ -0,0 +1,129 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { BrainError } from "./errors.js";
8
+ import { isRecord } from "./input.js";
9
+ import { formatTimestamp } from "./time.js";
10
+ const exec = promisify(execFile);
11
+ /** The `repos` list of a brain.json, `[]` when absent. */
12
+ export function linkedRepos(config) {
13
+ return Array.isArray(config.repos) ? config.repos.filter(isRecord) : [];
14
+ }
15
+ /** The repo named `name`, or REPO_NOT_FOUND. */
16
+ export function findRepo(config, name) {
17
+ const repo = linkedRepos(config).find((entry) => entry.name === name);
18
+ if (repo === undefined)
19
+ throw new BrainError("REPO_NOT_FOUND", `No linked repo named ${name} in brain.json repos`);
20
+ return repo;
21
+ }
22
+ /** A stored path with `~/` expanded. */
23
+ export function expandPath(path, home = homedir()) {
24
+ return path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
25
+ }
26
+ async function git(path, args, input) {
27
+ const child = exec("git", ["-C", path, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
28
+ if (input !== undefined)
29
+ child.child.stdin?.end(input);
30
+ return (await child).stdout;
31
+ }
32
+ async function gitBytes(path, args) {
33
+ const { stdout } = await exec("git", ["-C", path, ...args], { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 });
34
+ return stdout;
35
+ }
36
+ /** The real path of the top of the working tree at `path`, or null when it isn't one. */
37
+ export async function workTreeTop(path) {
38
+ try {
39
+ const top = (await git(path, ["rev-parse", "--show-toplevel"])).trim();
40
+ return await realpath(top);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** The repo's working tree, checked: REPO_NOT_FOUND when `path` isn't the top of one. */
47
+ export async function openRepo(repo, home) {
48
+ const path = expandPath(repo.path, home);
49
+ const top = await workTreeTop(path);
50
+ let real = null;
51
+ try {
52
+ real = await realpath(path);
53
+ }
54
+ catch { /* missing */ }
55
+ if (top === null || real !== top)
56
+ throw new BrainError("REPO_NOT_FOUND", `${repo.path} is not the top of a Git working tree`);
57
+ return top;
58
+ }
59
+ /** The full id of `rev` as a commit, or REPO_COMMIT_UNKNOWN. */
60
+ export async function resolveCommit(path, rev) {
61
+ try {
62
+ return (await git(path, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`])).trim();
63
+ }
64
+ catch {
65
+ throw new BrainError("REPO_COMMIT_UNKNOWN", `${rev} is not a commit in this repo`);
66
+ }
67
+ }
68
+ /** REPO_COMMIT_UNKNOWN unless `ancestor` is an ancestor of (or equal to) `commit`. */
69
+ export async function assertAncestor(path, ancestor, commit) {
70
+ try {
71
+ await git(path, ["merge-base", "--is-ancestor", ancestor, commit]);
72
+ }
73
+ catch {
74
+ throw new BrainError("REPO_COMMIT_UNKNOWN", `${ancestor} is not an ancestor of ${commit}`);
75
+ }
76
+ }
77
+ const pathspecs = (patterns) => ["--", ...patterns.map((pattern) => `:(glob)${pattern}`)];
78
+ /**
79
+ * The files matching the patterns that changed from `from` (`""`: none) to `to` (spec/cli.md §5.16):
80
+ * added or modified ones with the last commit in the range that touched them, oldest first, and
81
+ * deleted ones apart. No rename detection.
82
+ */
83
+ export async function repoChanges(path, patterns, from, to) {
84
+ const changed = new Map();
85
+ const deleted = [];
86
+ // A first ingest compares with the empty tree, so every matching file is added.
87
+ const base = from === "" ? (await git(path, ["hash-object", "-t", "tree", "--stdin"], "")).trim() : from;
88
+ const fields = (await git(path, ["diff", "--name-status", "-z", "--no-renames", "--diff-filter=AMDT", base, to, ...pathspecs(patterns)])).split("\0").filter(Boolean);
89
+ for (let i = 0; i + 1 < fields.length; i += 2) {
90
+ const status = fields[i][0];
91
+ const file = fields[i + 1];
92
+ if (status === "D")
93
+ deleted.push(file);
94
+ else
95
+ changed.set(file, status === "A" ? "added" : "modified");
96
+ }
97
+ // The newest commit in the range that touched each file: the first one `git log` names.
98
+ const last = new Map();
99
+ const range = from === "" ? [to] : [`${from}..${to}`];
100
+ const log = await git(path, ["log", "-z", "--no-renames", "--format=%x01%H %ct", "--name-only", ...range, ...pathspecs(patterns)]);
101
+ for (const block of log.split("\x01").filter(Boolean)) {
102
+ const [head, ...names] = block.split("\0");
103
+ const [header, firstName] = head.split("\n");
104
+ const [commit, time] = header.split(" ");
105
+ for (const name of [firstName, ...names].map((name) => name?.replace(/^\n/, "")).filter((name) => name !== undefined && name !== "")) {
106
+ if (!last.has(name))
107
+ last.set(name, { commit, time: Number(time) });
108
+ }
109
+ }
110
+ const files = [];
111
+ for (const [file, status] of changed) {
112
+ const bytes = await gitBytes(path, ["cat-file", "blob", `${to}:${file}`]);
113
+ const touched = last.get(file);
114
+ if (touched === undefined)
115
+ throw new Error(`git log names no commit for ${file}`);
116
+ files.push({ path: file, status, commit: touched.commit, committed_at: formatTimestamp(new Date(touched.time * 1000)), sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length });
117
+ }
118
+ files.sort((a, b) => a.committed_at.localeCompare(b.committed_at) || (last.get(a.path).time - last.get(b.path).time) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
119
+ deleted.sort();
120
+ return { files, deleted };
121
+ }
122
+ /** Whether the working tree has uncommitted changes (tracked or untracked). */
123
+ export async function isDirty(path) {
124
+ return (await git(path, ["status", "--porcelain"])).trim() !== "";
125
+ }
126
+ /** The repo's HEAD commit, or REPO_COMMIT_UNKNOWN for a repo with no commits. */
127
+ export async function headCommit(path) {
128
+ return resolveCommit(path, "HEAD");
129
+ }