ultimate-brain 0.0.0-stage → 0.2.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 (83) hide show
  1. package/.agents/skills/README.md +3 -0
  2. package/.agents/skills/brain-apply/SKILL.md +80 -0
  3. package/.agents/skills/brain-connect-notion/SKILL.md +55 -0
  4. package/.agents/skills/brain-howto/SKILL.md +36 -0
  5. package/.agents/skills/brain-howto/references/guide.md +176 -0
  6. package/.agents/skills/brain-ingest/SKILL.md +105 -0
  7. package/.agents/skills/brain-ingest/references/cli-inputs.md +129 -0
  8. package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
  9. package/.agents/skills/brain-ingest/references/extraction.md +118 -0
  10. package/.agents/skills/brain-ingest/references/notion.md +65 -0
  11. package/.agents/skills/brain-ingest/references/sources.md +70 -0
  12. package/.agents/skills/brain-notion-change/SKILL.md +57 -0
  13. package/.agents/skills/brain-query/SKILL.md +62 -0
  14. package/.agents/skills/brain-sync/SKILL.md +66 -0
  15. package/.agents/skills/brain-sync/references/formats.md +53 -0
  16. package/LICENSE +21 -0
  17. package/README.md +40 -2
  18. package/bin/brain.js +5 -0
  19. package/dist/cli.js +83 -0
  20. package/dist/commands/apply.js +34 -0
  21. package/dist/commands/config.js +96 -0
  22. package/dist/commands/id.js +32 -0
  23. package/dist/commands/index.js +58 -0
  24. package/dist/commands/init.js +233 -0
  25. package/dist/commands/link.js +285 -0
  26. package/dist/commands/lint.js +47 -0
  27. package/dist/commands/log.js +48 -0
  28. package/dist/commands/note.js +36 -0
  29. package/dist/commands/proposal.js +57 -0
  30. package/dist/commands/query.js +38 -0
  31. package/dist/commands/status.js +98 -0
  32. package/dist/commands/sync.js +53 -0
  33. package/dist/commands/upgrade.js +143 -0
  34. package/dist/commands/views.js +111 -0
  35. package/dist/lib/apply.js +524 -0
  36. package/dist/lib/args.js +63 -0
  37. package/dist/lib/brain-path.js +91 -0
  38. package/dist/lib/brain.js +36 -0
  39. package/dist/lib/command.js +61 -0
  40. package/dist/lib/errors.js +33 -0
  41. package/dist/lib/frontmatter.js +171 -0
  42. package/dist/lib/fs-safe.js +140 -0
  43. package/dist/lib/ids.js +51 -0
  44. package/dist/lib/index-builder.js +216 -0
  45. package/dist/lib/input.js +45 -0
  46. package/dist/lib/lint.js +340 -0
  47. package/dist/lib/log.js +44 -0
  48. package/dist/lib/note-new.js +121 -0
  49. package/dist/lib/notes.js +95 -0
  50. package/dist/lib/paths.js +59 -0
  51. package/dist/lib/proposals.js +229 -0
  52. package/dist/lib/query.js +163 -0
  53. package/dist/lib/schemas.js +51 -0
  54. package/dist/lib/skills.js +60 -0
  55. package/dist/lib/sync.js +760 -0
  56. package/dist/lib/time.js +21 -0
  57. package/dist/lib/views.js +139 -0
  58. package/dist/lib/warnings.js +9 -0
  59. package/dist/lib/workspace.js +183 -0
  60. package/package.json +45 -4
  61. package/spec/schemas/brain.schema.json +200 -0
  62. package/spec/schemas/note.schema.json +1102 -0
  63. package/spec/schemas/proposal.schema.json +559 -0
  64. package/templates/README.md +44 -0
  65. package/templates/brain/AGENTS.md +55 -0
  66. package/templates/brain/CLAUDE.md +1 -0
  67. package/templates/brain/_proposals/.gitkeep +0 -0
  68. package/templates/brain/archive/goals/.gitkeep +0 -0
  69. package/templates/brain/archive/projects/.gitkeep +0 -0
  70. package/templates/brain/areas/.gitkeep +0 -0
  71. package/templates/brain/brain.json +35 -0
  72. package/templates/brain/gitignore +6 -0
  73. package/templates/brain/goals/.gitkeep +0 -0
  74. package/templates/brain/index.md +3 -0
  75. package/templates/brain/knowledge/concepts/.gitkeep +0 -0
  76. package/templates/brain/knowledge/decisions/.gitkeep +0 -0
  77. package/templates/brain/knowledge/meetings/.gitkeep +0 -0
  78. package/templates/brain/knowledge/orgs/.gitkeep +0 -0
  79. package/templates/brain/knowledge/people/.gitkeep +0 -0
  80. package/templates/brain/knowledge/questions/.gitkeep +0 -0
  81. package/templates/brain/memories/.gitkeep +0 -0
  82. package/templates/brain/projects/.gitkeep +0 -0
  83. package/templates/brain/sources/notion/.gitkeep +0 -0
@@ -0,0 +1,524 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { posix } from "node:path";
3
+ import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
4
+ import { compareCodePoints } from "./notes.js";
5
+ import { stageNewItem } from "./proposals.js";
6
+ import { loadSchemas } from "./schemas.js";
7
+ import { formatDate } from "./time.js";
8
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
9
+ const ACTIONS = ["approve", "reject", "edit"];
10
+ const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note"]);
11
+ /** Reference fields a merge leaves alone: the meeting side of a persisted pair (spec/schema.md §11.2). */
12
+ const PAIR_FIELDS = new Set(["decisions", "questions", "decided_in", "raised_in"]);
13
+ /** Corrections change knowledge notes only: sources are immutable and proposals are records. */
14
+ const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
15
+ const REFERENCE_FIELDS = [
16
+ "area", "areas", "goal", "client", "org", "projects", "people", "attendees", "deciders", "source", "sources",
17
+ "answer", "project", "generated_from", "facts",
18
+ ];
19
+ /** Check the input shape (spec/cli.md §5.7); any problem fails the whole command. */
20
+ export function parseApplyInput(input) {
21
+ if (!isRecord(input))
22
+ assertNoProblems([{ pointer: "", message: "must be an object" }]);
23
+ const record = input;
24
+ const problems = unknownKeys(record, ["corrections", "decisions"], "");
25
+ const corrections = record.corrections ?? [];
26
+ const decisions = record.decisions ?? [];
27
+ if (!Array.isArray(corrections))
28
+ problems.push({ pointer: "/corrections", message: "must be a list" });
29
+ if (!Array.isArray(decisions))
30
+ problems.push({ pointer: "/decisions", message: "must be a list" });
31
+ assertNoProblems(problems);
32
+ if (corrections.length === 0 && decisions.length === 0) {
33
+ assertNoProblems([{ pointer: "", message: "needs at least one correction or decision" }]);
34
+ }
35
+ corrections.forEach((item, index) => {
36
+ const pointer = `/corrections/${index}`;
37
+ if (!isRecord(item))
38
+ return problems.push({ pointer, message: "must be an object" });
39
+ problems.push(...unknownKeys(item, ["target", "field", "value", "observed_at"], pointer));
40
+ if (typeof item.target !== "string")
41
+ problems.push({ pointer: `${pointer}/target`, message: "must be a note id" });
42
+ if (typeof item.field !== "string")
43
+ problems.push({ pointer: `${pointer}/field`, message: "must be a string" });
44
+ if (!Object.hasOwn(item, "value"))
45
+ problems.push({ pointer: `${pointer}/value`, message: "is required (null removes a reference)" });
46
+ if (!isTimestamp(item.observed_at))
47
+ problems.push({ pointer: `${pointer}/observed_at`, message: "must be an ISO 8601 UTC timestamp" });
48
+ });
49
+ decisions.forEach((item, index) => {
50
+ const pointer = `/decisions/${index}`;
51
+ if (!isRecord(item))
52
+ return problems.push({ pointer, message: "must be an object" });
53
+ problems.push(...unknownKeys(item, ["proposal", "action", "value", "decided_at", "decided_by"], pointer));
54
+ if (typeof item.proposal !== "string")
55
+ problems.push({ pointer: `${pointer}/proposal`, message: "must be a proposal id" });
56
+ if (!ACTIONS.includes(item.action))
57
+ problems.push({ pointer: `${pointer}/action`, message: "must be approve, reject or edit" });
58
+ if (item.action === "edit" && (item.value === undefined || item.value === null))
59
+ problems.push({ pointer: `${pointer}/value`, message: "is required for edit" });
60
+ if (item.action !== "edit" && item.value !== undefined)
61
+ problems.push({ pointer: `${pointer}/value`, message: "is only allowed for edit" });
62
+ if (!isTimestamp(item.decided_at))
63
+ problems.push({ pointer: `${pointer}/decided_at`, message: "must be an ISO 8601 UTC timestamp" });
64
+ if (item.decided_by !== "notion" && item.decided_by !== "chat")
65
+ problems.push({ pointer: `${pointer}/decided_by`, message: "must be notion or chat" });
66
+ });
67
+ assertNoProblems(problems);
68
+ return { corrections: corrections, decisions: decisions };
69
+ }
70
+ /**
71
+ * Apply corrections, then decisions, each in input order (spec/cli.md §5.7, spec/schema.md
72
+ * §11.2). Each item is staged and validated on its own: a failed item leaves the workspace as it
73
+ * was, and later items see the results of earlier ones. Nothing is written; the caller commits.
74
+ */
75
+ export function applyAll(workspace, input, options) {
76
+ const today = formatDate(options.now);
77
+ const result = {
78
+ corrections: [],
79
+ decisions: [],
80
+ summary: { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 },
81
+ log: [],
82
+ };
83
+ for (const correction of input.corrections) {
84
+ const item = runItem(workspace, () => applyCorrection(workspace, correction, today));
85
+ result.corrections.push({ target: correction.target, field: correction.field, ...item.outcome });
86
+ if (item.outcome.result === "applied") {
87
+ result.summary.corrected += 1;
88
+ result.log.push({ op: "correct", id: correction.target, title: titleOf(workspace, correction.target), pairs: [["field", correction.field]] });
89
+ }
90
+ else {
91
+ result.summary[item.outcome.result] += 1;
92
+ }
93
+ }
94
+ const decisions = input.decisions ?? recordedDecisions(workspace);
95
+ for (const decision of decisions) {
96
+ const proposal = workspace.get(decision.proposal);
97
+ const kind = proposal?.type === "proposal" ? String(proposal.attributes.kind) : undefined;
98
+ const item = runItem(workspace, () => applyDecision(workspace, decision, today, options.recordOnly));
99
+ result.decisions.push({ proposal: decision.proposal, ...(kind === undefined ? {} : { kind }), action: item.action ?? decision.action, ...item.outcome });
100
+ result.summary[item.outcome.result] += 1;
101
+ if (item.outcome.result === "applied" || item.outcome.result === "closed") {
102
+ result.log.push({ op: "apply", id: decision.proposal, title: titleOf(workspace, decision.proposal), pairs: [["result", item.outcome.result]] });
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+ class ItemFailure extends Error {
108
+ reason;
109
+ constructor(reason, message) {
110
+ super(message);
111
+ this.reason = reason;
112
+ }
113
+ }
114
+ /** Run one item atomically: on failure or a result that adds lint errors, restore the workspace. */
115
+ function runItem(workspace, run) {
116
+ const start = workspace.checkpoint();
117
+ let outcome;
118
+ try {
119
+ outcome = run();
120
+ }
121
+ catch (error) {
122
+ workspace.restore(start);
123
+ if (error instanceof ItemFailure)
124
+ return { outcome: { result: "failed", reason: error.reason, message: error.message, changed: [] } };
125
+ // Anything else (a new_item whose note cannot be written, say) fails this item only (spec/cli.md §3.6.3).
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ return { outcome: { result: "failed", reason: "INVALID_RESULT", message, changed: [] } };
128
+ }
129
+ const changed = workspace.changedSince(start);
130
+ const errors = workspace.newErrors();
131
+ if (errors.length > 0) {
132
+ workspace.restore(start);
133
+ const message = errors.map((issue) => `${issue.path}: ${issue.rule}: ${issue.message}`).join("; ");
134
+ return { outcome: { result: "failed", reason: "INVALID_RESULT", message, changed: [] } };
135
+ }
136
+ const { action, ...rest } = outcome;
137
+ return { outcome: { ...rest, changed }, action };
138
+ }
139
+ function applyCorrection(workspace, correction, today) {
140
+ const note = existing(workspace, correction.target);
141
+ const document = workspace.edit(note);
142
+ const attributes = document.attributes;
143
+ const factKey = /^facts\.([a-z][a-z0-9_]*)$/.exec(correction.field)?.[1];
144
+ const known = loadSchemas().knownKeys(note.type);
145
+ const correctable = KNOWLEDGE_TYPES.has(note.type) && factKey !== undefined
146
+ ? known?.has("facts") === true
147
+ : KNOWLEDGE_TYPES.has(note.type) && ["title", "summary", "why", "area", "goal", "client", "org", "people"].includes(correction.field) && known?.has(correction.field) === true;
148
+ if (!correctable)
149
+ throw new ItemFailure("FIELD_NOT_CORRECTABLE", `${correction.field} cannot be corrected on a ${note.type}`);
150
+ const current = factKey === undefined ? attributes[correction.field] : record(attributes.facts)[factKey];
151
+ if (isDeepStrictEqual(current ?? null, correction.value))
152
+ return { result: "skipped", reason: "NO_CHANGE" };
153
+ if (factKey !== undefined) {
154
+ const date = correction.observed_at.slice(0, 10);
155
+ setFact(attributes, factKey, correction.value, date, date, "notion-edit");
156
+ }
157
+ else if (correction.value === null) {
158
+ delete attributes[correction.field];
159
+ }
160
+ else {
161
+ attributes[correction.field] = correction.value;
162
+ }
163
+ attributes.updated = today;
164
+ workspace.save(note.path, document);
165
+ return { result: "applied" };
166
+ }
167
+ function applyDecision(workspace, input, today, recordOnly) {
168
+ const proposal = workspace.get(input.proposal);
169
+ if (proposal === undefined || proposal.type !== "proposal")
170
+ throw new ItemFailure("UNKNOWN_ID", `No proposal with id ${input.proposal}`);
171
+ const document = workspace.edit(proposal);
172
+ const p = document.attributes;
173
+ if (p.status === "applied" || p.status === "closed")
174
+ return { result: "skipped", reason: "ALREADY_TERMINAL" };
175
+ const incoming = { action: input.action, ...(input.value === undefined ? {} : { value: input.value }), decided_at: input.decided_at, decided_by: input.decided_by };
176
+ const recorded = isRecord(p.decision) ? p.decision : undefined;
177
+ let decision = incoming;
178
+ if (recorded !== undefined && (recorded.action !== incoming.action || !isDeepStrictEqual(recorded.value, incoming.value))) {
179
+ // You changed your mind in Notion before it was applied: the later decision wins.
180
+ if (Date.parse(recorded.decided_at) >= Date.parse(incoming.decided_at))
181
+ decision = recorded;
182
+ }
183
+ else if (recorded !== undefined) {
184
+ decision = recorded;
185
+ }
186
+ if (decision.action === "edit" && !EDITABLE.has(String(p.kind))) {
187
+ throw new ItemFailure("EDIT_NOT_ALLOWED", `edit is not allowed on a ${String(p.kind)} proposal (spec/schema.md §11.1)`);
188
+ }
189
+ const action = decision.action === incoming.action ? undefined : decision.action;
190
+ p.decision = decision;
191
+ p.status = decision.action === "approve" ? "approved" : decision.action === "edit" ? "edited" : "rejected";
192
+ p.updated = today;
193
+ if (recordOnly) {
194
+ workspace.save(proposal.path, document);
195
+ return { result: "recorded", action };
196
+ }
197
+ if (decision.action === "reject") {
198
+ p.status = "closed";
199
+ workspace.save(proposal.path, document);
200
+ return { result: "closed", action };
201
+ }
202
+ const D = decision.decided_at.slice(0, 10);
203
+ const V = decision.action === "edit" ? decision.value : undefined;
204
+ const stale = applyKind(workspace, p, proposal.path, V, D, today);
205
+ if (stale !== null) {
206
+ p.status = "closed";
207
+ p.closed_reason = "stale";
208
+ workspace.save(proposal.path, document);
209
+ return { result: "closed", reason: "STALE_PROPOSAL", message: stale, action };
210
+ }
211
+ p.status = "applied";
212
+ workspace.save(proposal.path, document);
213
+ return { result: "applied", action };
214
+ }
215
+ /** Apply a proposal's kind to its targets. Returns a message when the proposal is stale. */
216
+ function applyKind(workspace, p, proposalPath, V, D, today) {
217
+ const kind = String(p.kind);
218
+ if (kind === "new_item") {
219
+ stageNewItem(workspace, p.note, proposalPath, new Date(`${today}T00:00:00Z`));
220
+ return null;
221
+ }
222
+ const target = existing(workspace, String(p.target));
223
+ const document = workspace.edit(target);
224
+ const a = document.attributes;
225
+ const field = String(p.field);
226
+ if (kind === "update_fact" || kind === "contradiction") {
227
+ const current = record(a.facts)[field];
228
+ if (!isDeepStrictEqual(current, p.before)) {
229
+ const shown = (value) => (value === undefined ? "absent" : JSON.stringify(value));
230
+ return `facts.${field} is ${shown(current)}, proposal expected ${shown(p.before)}`;
231
+ }
232
+ }
233
+ switch (kind) {
234
+ case "update_fact":
235
+ setFact(a, field, V ?? p.after, String(p.from), D, String(p.source));
236
+ break;
237
+ case "contradiction":
238
+ if (V === undefined)
239
+ contest(a, field, p.after, String(p.from), D, String(p.source));
240
+ else
241
+ resolveContradiction(a, field, V, String(p.from), D, String(p.source));
242
+ break;
243
+ case "add_to_note": {
244
+ const content = String(V ?? p.content).replace(/\n+$/, "");
245
+ const head = document.body.replace(/\n+$/, "");
246
+ document.body = `${head === "" ? "" : `${head}\n\n`}${content}\nSource: ${String(p.source)}\n`;
247
+ a.sources = union(list(a.sources), [p.source]);
248
+ break;
249
+ }
250
+ case "add_link":
251
+ if (!list(a[field]).some((value) => isDeepStrictEqual(value, p.after)))
252
+ a[field] = [...list(a[field]), p.after];
253
+ break;
254
+ case "supersede": {
255
+ const older = existing(workspace, String(p.supersedes));
256
+ const olderDocument = workspace.edit(older);
257
+ supersedePair(a, target.id, olderDocument.attributes, older.id);
258
+ olderDocument.attributes.updated = today;
259
+ workspace.save(older.path, olderDocument);
260
+ break;
261
+ }
262
+ case "merge":
263
+ merge(workspace, target, existing(workspace, String(p.merge_into)), today);
264
+ return null;
265
+ default:
266
+ throw new ItemFailure("INVALID_RESULT", `Unknown proposal kind ${kind}`);
267
+ }
268
+ a.updated = today;
269
+ workspace.save(target.path, document);
270
+ return null;
271
+ }
272
+ /** update_fact (and a facts.<key> correction): retire accepted entries, append the new value. */
273
+ function setFact(a, field, value, from, learned, source) {
274
+ a.timeline = [
275
+ ...list(a.timeline).map((entry) => isRecord(entry) && entry.field === field && entry.assessment === "accepted" ? entryWith(entry, { until: from, assessment: "superseded" }) : entry),
276
+ timelineEntry(field, value, from, learned, source, "accepted"),
277
+ ];
278
+ a.facts = { ...record(a.facts), [field]: value };
279
+ }
280
+ /** contradiction approved: keep both claims as contested and drop the current fact. */
281
+ function contest(a, field, value, from, learned, source) {
282
+ a.timeline = [
283
+ ...list(a.timeline).map((entry) => isRecord(entry) && entry.field === field && entry.assessment === "accepted" ? entryWith(entry, { assessment: "contested" }) : entry),
284
+ timelineEntry(field, value, from, learned, source, "contested"),
285
+ ];
286
+ const facts = { ...record(a.facts) };
287
+ delete facts[field];
288
+ if (Object.keys(facts).length === 0)
289
+ delete a.facts;
290
+ else
291
+ a.facts = facts;
292
+ }
293
+ /** contradiction edited: V is the accepted truth (spec/schema.md §11.2). */
294
+ function resolveContradiction(a, field, value, from, learned, source) {
295
+ const timeline = list(a.timeline);
296
+ const live = timeline
297
+ .map((entry, index) => ({ entry, index }))
298
+ .filter(({ entry }) => isRecord(entry) && entry.field === field && (entry.assessment === "accepted" || entry.assessment === "contested"));
299
+ const matches = live.filter(({ entry }) => isDeepStrictEqual(record(entry).value, value));
300
+ const keep = matches.reduce((latest, candidate) => latest === undefined || String(record(candidate.entry).learned) >= String(record(latest.entry).learned) ? candidate : latest, undefined);
301
+ const next = [...timeline];
302
+ for (const { entry, index } of live) {
303
+ next[index] = index === keep?.index ? entryWith(record(entry), { assessment: "accepted" }) : entryWith(record(entry), { until: from, assessment: "superseded" });
304
+ }
305
+ if (keep === undefined)
306
+ next.push(timelineEntry(field, value, from, learned, source, "accepted"));
307
+ a.timeline = next;
308
+ a.facts = { ...record(a.facts), [field]: value };
309
+ }
310
+ /** Write both sides of a supersession; the older note is archived (spec/schema.md §11.2). */
311
+ function supersedePair(newer, newerId, older, olderId) {
312
+ newer.relations = unionRelations(list(newer.relations), [{ type: "supersedes", target: olderId }]);
313
+ older.relations = unionRelations(list(older.relations), [{ type: "superseded_by", target: newerId }]);
314
+ older.lifecycle = "archived";
315
+ if (newer.type === "decision" && older.type === "decision") {
316
+ older.assessment = "superseded";
317
+ older.valid_until = newer.valid_from ?? newer.date;
318
+ }
319
+ }
320
+ /** merge X (the duplicate) into Y (spec/schema.md §11.2). */
321
+ function merge(workspace, x, y, today) {
322
+ // 1. References, in every note except X, Y, sources and proposals.
323
+ for (const note of workspace.notes) {
324
+ if (note.id === x.id || note.id === y.id || note.type === "source" || note.type === "proposal")
325
+ continue;
326
+ const document = workspace.edit(note);
327
+ const before = JSON.stringify([document.attributes, document.body]);
328
+ rewriteReferences(document.attributes, x.id, y.id);
329
+ document.body = rewriteLinks(document.body, note.path, x.path, y.path);
330
+ if (JSON.stringify([document.attributes, document.body]) !== before) {
331
+ document.attributes.updated = today;
332
+ workspace.save(note.path, document);
333
+ }
334
+ }
335
+ // 2. Into Y.
336
+ const xDocument = workspace.edit(workspace.get(x.id));
337
+ const yDocument = workspace.edit(workspace.get(y.id));
338
+ const xa = xDocument.attributes;
339
+ const ya = yDocument.attributes;
340
+ const unionInto = (key, extra = []) => {
341
+ const merged = union(list(ya[key]), [...list(xa[key]), ...extra]);
342
+ if (merged.length > 0)
343
+ ya[key] = merged;
344
+ };
345
+ unionInto("aliases", [xa.title]);
346
+ unionInto("tags");
347
+ unionInto("sources");
348
+ unionInto("projects");
349
+ const carried = list(xa.relations).filter((relation) => {
350
+ const { type, target } = record(relation);
351
+ return type !== "supersedes" && type !== "superseded_by" && target !== x.id && target !== y.id;
352
+ });
353
+ if (carried.length > 0)
354
+ ya.relations = unionRelations(list(ya.relations), carried);
355
+ const yFacts = record(ya.facts);
356
+ const timeline = [...list(ya.timeline)];
357
+ for (const entry of list(xa.timeline)) {
358
+ if (timeline.some((other) => isDeepStrictEqual(other, entry)))
359
+ continue;
360
+ const { field, value, assessment } = record(entry);
361
+ const conflicting = Object.hasOwn(yFacts, String(field)) && assessment === "accepted" && !isDeepStrictEqual(value, yFacts[String(field)]);
362
+ timeline.push(conflicting ? entryWith(record(entry), { until: today, assessment: "superseded" }) : entry);
363
+ }
364
+ if (timeline.length > 0)
365
+ ya.timeline = timeline;
366
+ const facts = { ...yFacts };
367
+ for (const [key, value] of Object.entries(record(xa.facts)))
368
+ if (!Object.hasOwn(facts, key))
369
+ facts[key] = value;
370
+ if (Object.keys(facts).length > 0)
371
+ ya.facts = facts;
372
+ const yBody = yDocument.body.replace(/\n+$/, "");
373
+ const xBody = xDocument.body.replace(/^\n+|\n+$/g, "");
374
+ yDocument.body = `${yBody === "" ? "" : `${yBody}\n\n`}## Merged from ${String(xa.title)}\n${xBody === "" ? "" : `\n${xBody}\n`}`;
375
+ // 3. The pair: X archived and superseded by Y.
376
+ supersedePair(ya, y.id, xa, x.id);
377
+ xa.updated = today;
378
+ ya.updated = today;
379
+ workspace.save(x.path, xDocument);
380
+ workspace.save(y.path, yDocument);
381
+ }
382
+ function rewriteReferences(a, from, to) {
383
+ const swap = (value) => (value === from ? to : value);
384
+ for (const key of REFERENCE_FIELDS) {
385
+ if (PAIR_FIELDS.has(key) || a[key] === undefined)
386
+ continue;
387
+ const value = a[key];
388
+ if (Array.isArray(value))
389
+ a[key] = union([], value.map(swap));
390
+ else if (isRecord(value))
391
+ a[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, swap(v)]));
392
+ else
393
+ a[key] = swap(value);
394
+ }
395
+ if (Array.isArray(a.relations)) {
396
+ a.relations = unionRelations([], a.relations.map((relation) => {
397
+ const r = record(relation);
398
+ return r.type === "supersedes" || r.type === "superseded_by" ? relation : { ...r, target: swap(r.target) };
399
+ }));
400
+ }
401
+ if (Array.isArray(a.timeline)) {
402
+ a.timeline = a.timeline.map((entry) => {
403
+ const e = record(entry);
404
+ return e.value === from || e.source === from ? { ...e, value: swap(e.value), source: swap(e.source) } : entry;
405
+ });
406
+ }
407
+ }
408
+ /** Point relative Markdown links to X's file at Y's file instead, outside fenced code blocks. */
409
+ function rewriteLinks(body, notePath, xPath, yPath) {
410
+ const dir = posix.dirname(notePath);
411
+ const rewriteTarget = (raw) => {
412
+ const target = raw.startsWith("<") ? raw.slice(1, -1) : raw;
413
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#"))
414
+ return raw;
415
+ const suffixAt = target.search(/[?#]/);
416
+ const path = suffixAt === -1 ? target : target.slice(0, suffixAt);
417
+ const suffix = suffixAt === -1 ? "" : target.slice(suffixAt);
418
+ let decoded = path;
419
+ try {
420
+ decoded = decodeURIComponent(path);
421
+ }
422
+ catch {
423
+ // keep as written
424
+ }
425
+ if (posix.normalize(posix.join(dir, decoded)) !== xPath)
426
+ return raw;
427
+ const replaced = `${posix.relative(dir, yPath)}${suffix}`;
428
+ return raw.startsWith("<") ? `<${replaced}>` : replaced;
429
+ };
430
+ const rewriteProse = (prose) => {
431
+ const spans = [];
432
+ const masked = prose.replace(/(`+)((?:(?!\r?\n[ \t]*\r?\n)[\s\S])*?)\1/g, (span) => {
433
+ const marker = `\u0000${spans.length}\u0000`;
434
+ spans.push(span);
435
+ return marker;
436
+ });
437
+ return masked
438
+ .replace(/\]\(\s*(<[^>]*>|[^)\s]+)/g, (match, raw) => match.replace(raw, () => rewriteTarget(raw)))
439
+ .replace(/^(\s{0,3}\[(?!\^)[^\]\n]+\]:\s*)(<[^>]*>|[^\s]+)/gm, (match, prefix, raw) => `${prefix}${rewriteTarget(raw)}`)
440
+ .replace(/<([^<>\n]+)>/g, (match, target) => rewriteTarget(`<${target}>`))
441
+ .replace(/\u0000(\d+)\u0000/g, (_marker, index) => spans[Number(index)]);
442
+ };
443
+ const lines = body.split(/(?<=\n)/);
444
+ const output = [];
445
+ let prose = [];
446
+ let fence;
447
+ const flush = () => {
448
+ if (prose.length > 0)
449
+ output.push(rewriteProse(prose.join("")));
450
+ prose = [];
451
+ };
452
+ for (const line of lines) {
453
+ if (fence === undefined) {
454
+ const open = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
455
+ if (open !== undefined) {
456
+ flush();
457
+ fence = { character: open[0], length: open.length };
458
+ output.push(line);
459
+ }
460
+ else
461
+ prose.push(line);
462
+ }
463
+ else {
464
+ output.push(line);
465
+ const close = new RegExp(`^\\s{0,3}${fence.character}{${fence.length},}\\s*(?:\\n)?$`);
466
+ if (close.test(line))
467
+ fence = undefined;
468
+ }
469
+ }
470
+ flush();
471
+ return output.join("");
472
+ }
473
+ /** Proposals holding a recorded decision that is not applied yet, oldest first. */
474
+ function recordedDecisions(workspace) {
475
+ return workspace.notes
476
+ .filter((note) => note.type === "proposal" && ["approved", "edited", "rejected"].includes(String(note.attributes.status)) && isRecord(note.attributes.decision))
477
+ .sort((a, b) => compareCodePoints(String(a.attributes.created), String(b.attributes.created)) || compareCodePoints(a.id, b.id))
478
+ .map((note) => ({ proposal: note.id, ...note.attributes.decision }));
479
+ }
480
+ function existing(workspace, id) {
481
+ const note = workspace.get(id);
482
+ if (note === undefined)
483
+ throw new ItemFailure("UNKNOWN_ID", `No note with id ${id}`);
484
+ return note;
485
+ }
486
+ function titleOf(workspace, id) {
487
+ const title = workspace.get(id)?.attributes.title;
488
+ return typeof title === "string" ? title : "";
489
+ }
490
+ /** A timeline entry with its keys in the spec/schema.md §5 order. */
491
+ function timelineEntry(field, value, from, learned, source, assessment) {
492
+ return { field, value, from, learned, source, assessment };
493
+ }
494
+ const ENTRY_ORDER = ["field", "value", "from", "until", "learned", "source", "assessment", "note"];
495
+ function entryWith(entry, changes) {
496
+ const merged = { ...entry, ...changes };
497
+ const rank = (key) => (ENTRY_ORDER.includes(key) ? ENTRY_ORDER.indexOf(key) : ENTRY_ORDER.length);
498
+ return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => rank(a) - rank(b)));
499
+ }
500
+ function union(base, extra) {
501
+ const out = [];
502
+ for (const value of [...base, ...extra])
503
+ if (!out.some((other) => isDeepStrictEqual(other, value)))
504
+ out.push(value);
505
+ return out;
506
+ }
507
+ function unionRelations(base, extra) {
508
+ const out = [];
509
+ for (const relation of [...base, ...extra]) {
510
+ const { type, target } = record(relation);
511
+ if (!out.some((other) => record(other).type === type && record(other).target === target))
512
+ out.push(relation);
513
+ }
514
+ return out;
515
+ }
516
+ function list(value) {
517
+ return Array.isArray(value) ? value : [];
518
+ }
519
+ function record(value) {
520
+ return isRecord(value) ? value : {};
521
+ }
522
+ function isTimestamp(value) {
523
+ return typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value));
524
+ }
@@ -0,0 +1,63 @@
1
+ import { BrainError } from "./errors.js";
2
+ /** Global options accepted by every command (spec/cli.md §3.1). */
3
+ export const GLOBAL_OPTIONS = {
4
+ brain: "string",
5
+ json: "boolean",
6
+ now: "string",
7
+ help: "boolean",
8
+ };
9
+ /**
10
+ * Parse `--name value`, `--name=value`, boolean flags and `-h` anywhere in argv.
11
+ * Unknown options are a usage error; a repeated string option keeps the last value.
12
+ */
13
+ export function parseArgs(argv, spec) {
14
+ const all = { ...GLOBAL_OPTIONS, ...spec };
15
+ const options = {};
16
+ const positionals = [];
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const arg = argv[index];
19
+ if (arg === "-h") {
20
+ options.help = true;
21
+ continue;
22
+ }
23
+ if (arg === "--") {
24
+ positionals.push(...argv.slice(index + 1));
25
+ break;
26
+ }
27
+ if (arg === "-" || !arg.startsWith("-")) {
28
+ positionals.push(arg);
29
+ continue;
30
+ }
31
+ if (!arg.startsWith("--"))
32
+ throw new BrainError("USAGE", `Unknown option: ${arg}`);
33
+ const equals = arg.indexOf("=");
34
+ const name = arg.slice(2, equals === -1 ? undefined : equals);
35
+ const kind = all[name];
36
+ if (kind === undefined)
37
+ throw new BrainError("USAGE", `Unknown option: --${name}`);
38
+ if (kind === "boolean") {
39
+ if (equals !== -1)
40
+ throw new BrainError("USAGE", `Option --${name} takes no value`);
41
+ options[name] = true;
42
+ continue;
43
+ }
44
+ let value;
45
+ if (equals !== -1) {
46
+ value = arg.slice(equals + 1);
47
+ }
48
+ else {
49
+ value = argv[index + 1];
50
+ index += 1;
51
+ }
52
+ if (value === undefined || value === "")
53
+ throw new BrainError("USAGE", `Option --${name} needs a value`);
54
+ if (kind === "list") {
55
+ const existing = options[name];
56
+ options[name] = [...(Array.isArray(existing) ? existing : []), value];
57
+ }
58
+ else {
59
+ options[name] = value;
60
+ }
61
+ }
62
+ return { options, positionals };
63
+ }
@@ -0,0 +1,91 @@
1
+ import { constants } from "node:fs";
2
+ import { access, readFile, realpath, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { BrainError } from "./errors.js";
6
+ /** Resolve and validate the brain directory using the documented precedence. */
7
+ export async function resolveBrainDirectory(options = {}) {
8
+ const env = options.env ?? process.env;
9
+ const homeDirectory = options.homeDirectory ?? homedir();
10
+ const higherPriority = firstNonEmpty(options.brainFlag, env.BRAIN_DIR);
11
+ const configured = higherPriority === undefined ? await readConfiguredBrain(homeDirectory) : undefined;
12
+ const candidate = higherPriority ?? configured?.brain;
13
+ if (candidate === undefined) {
14
+ throw new BrainError("BRAIN_NOT_CONFIGURED", "Brain directory is not configured. Pass --brain, set BRAIN_DIR, or set brain in ~/.config/ultimate-brain/config.json.");
15
+ }
16
+ const expanded = expandHome(candidate, homeDirectory);
17
+ // A relative path in the config file is relative to that file's folder (spec/cli.md §4).
18
+ const absolute = configured === undefined ? resolve(expanded) : resolve(configured.folder, expanded);
19
+ let directory;
20
+ try {
21
+ const metadata = await stat(absolute);
22
+ if (!metadata.isDirectory()) {
23
+ throw new BrainError("BRAIN_NOT_FOUND", `Brain path is not a directory: ${absolute}`);
24
+ }
25
+ directory = await realpath(absolute);
26
+ }
27
+ catch (error) {
28
+ if (error instanceof BrainError)
29
+ throw error;
30
+ throw new BrainError("BRAIN_NOT_FOUND", `Brain directory does not exist or cannot be read: ${absolute}`);
31
+ }
32
+ if (await isProductRepository(directory)) {
33
+ throw new BrainError("BRAIN_IS_PRODUCT_REPO", `Refusing to use the Ultimate Brain product repository as a brain: ${directory}`);
34
+ }
35
+ return directory;
36
+ }
37
+ async function readConfiguredBrain(homeDirectory) {
38
+ const configPath = join(homeDirectory, ".config", "ultimate-brain", "config.json");
39
+ try {
40
+ const raw = await readFile(configPath, "utf8");
41
+ const parsed = JSON.parse(raw);
42
+ if (!isRecord(parsed)) {
43
+ throw new Error("configuration must be a JSON object");
44
+ }
45
+ const config = parsed;
46
+ const extra = Object.keys(parsed).filter((key) => key !== "brain");
47
+ if (extra.length > 0) {
48
+ throw new Error(`unknown key "${extra[0]}"; the only key is "brain"`);
49
+ }
50
+ if (typeof config.brain !== "string" || config.brain.trim() === "") {
51
+ throw new Error('the "brain" value must be a non-empty string');
52
+ }
53
+ return { brain: config.brain, folder: dirname(configPath) };
54
+ }
55
+ catch (error) {
56
+ if (isNodeError(error) && error.code === "ENOENT") {
57
+ return undefined;
58
+ }
59
+ const detail = error instanceof Error ? error.message : String(error);
60
+ throw new BrainError("BRAIN_CONFIG_INVALID", `Cannot read brain configuration at ${configPath}: ${detail}`);
61
+ }
62
+ }
63
+ export async function isProductRepository(directory) {
64
+ const markers = [join(directory, "spec", "cli.md"), join(directory, "src", "cli.ts")];
65
+ const found = await Promise.all(markers.map(async (marker) => {
66
+ try {
67
+ await access(marker, constants.F_OK);
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }));
74
+ return found.every(Boolean);
75
+ }
76
+ function firstNonEmpty(...values) {
77
+ return values.find((value) => value !== undefined && value.trim() !== "")?.trim();
78
+ }
79
+ function expandHome(path, homeDirectory) {
80
+ if (path === "~")
81
+ return homeDirectory;
82
+ if (path.startsWith("~/"))
83
+ return join(homeDirectory, path.slice(2));
84
+ return path;
85
+ }
86
+ function isRecord(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+ function isNodeError(error) {
90
+ return error instanceof Error && "code" in error;
91
+ }