ultimate-brain 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/brain-apply/SKILL.md +35 -22
- package/.agents/skills/brain-catch-up/SKILL.md +28 -20
- package/.agents/skills/brain-howto/SKILL.md +1 -1
- package/.agents/skills/brain-howto/references/guide.md +67 -44
- package/.agents/skills/brain-ingest/SKILL.md +10 -6
- package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -3
- package/.agents/skills/brain-ingest/references/extraction.md +23 -1
- package/.agents/skills/brain-ingest/references/notion.md +3 -3
- package/.agents/skills/brain-ingest/references/sources.md +25 -5
- package/.agents/skills/brain-notion-change/SKILL.md +2 -0
- package/.agents/skills/brain-query/SKILL.md +17 -7
- package/.agents/skills/brain-sync/SKILL.md +13 -8
- package/.agents/skills/brain-sync/references/formats.md +1 -1
- package/dist/cli.js +3 -3
- package/dist/commands/report.js +81 -4
- package/dist/commands/source.js +34 -0
- package/dist/commands/status.js +2 -2
- package/dist/lib/apply.js +237 -33
- package/dist/lib/diff.js +31 -0
- package/dist/lib/ids.js +2 -0
- package/dist/lib/index-builder.js +6 -1
- package/dist/lib/lint.js +39 -8
- package/dist/lib/note-new.js +21 -2
- package/dist/lib/paths.js +1 -0
- package/dist/lib/proposals.js +72 -18
- package/dist/lib/query.js +1 -1
- package/dist/lib/schemas.js +1 -1
- package/dist/lib/sync.js +101 -50
- package/package.json +1 -1
- package/templates/brain/brain.json +2 -1
- package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/commands/source.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BrainError } from "../lib/errors.js";
|
|
2
2
|
import { rangesProblem, splitParts } from "../lib/parts.js";
|
|
3
|
+
import { lineDiff } from "../lib/diff.js";
|
|
3
4
|
import { hasInboxBody } from "../lib/sync.js";
|
|
4
5
|
import { formatTimestamp } from "../lib/time.js";
|
|
5
6
|
import { Workspace } from "../lib/workspace.js";
|
|
@@ -54,3 +55,36 @@ export const sourceFullTextCommand = {
|
|
|
54
55
|
};
|
|
55
56
|
},
|
|
56
57
|
};
|
|
58
|
+
/** `brain source diff <src-id>` (spec/cli.md §5.19, 0045): the lines that changed from the version it replaces. */
|
|
59
|
+
export const sourceDiffCommand = {
|
|
60
|
+
name: "source diff",
|
|
61
|
+
usage: "brain source diff <src-id> [--json]",
|
|
62
|
+
summary: "Show the lines a new version of a source changed against the version it replaces, without context lines. Read-only.",
|
|
63
|
+
options: {},
|
|
64
|
+
maxPositionals: 1,
|
|
65
|
+
needsBrain: true,
|
|
66
|
+
async run({ args, brain }) {
|
|
67
|
+
const [id] = args.positionals;
|
|
68
|
+
if (id === undefined)
|
|
69
|
+
throw new BrainError("USAGE", "Name the source: brain source diff <src-id>");
|
|
70
|
+
const workspace = await Workspace.open(brain);
|
|
71
|
+
const note = workspace.get(id);
|
|
72
|
+
if (note === undefined)
|
|
73
|
+
throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
|
|
74
|
+
const invalid = (message) => { throw new BrainError("INPUT_INVALID", message, [{ pointer: "", message }]); };
|
|
75
|
+
if (note.type !== "source")
|
|
76
|
+
invalid(`${id} is a ${note.type}, not a source`);
|
|
77
|
+
const replaces = note.attributes.replaces;
|
|
78
|
+
if (typeof replaces !== "string")
|
|
79
|
+
invalid(`${id} has no replaces: it is the first version`);
|
|
80
|
+
const earlier = workspace.get(replaces);
|
|
81
|
+
if (earlier === undefined)
|
|
82
|
+
throw new BrainError("UNKNOWN_ID", `No note with id ${String(replaces)}`);
|
|
83
|
+
const lines = lineDiff(earlier.body, note.body);
|
|
84
|
+
const diff = lines.join("\n");
|
|
85
|
+
return {
|
|
86
|
+
data: { id: note.id, replaces: earlier.id, removed: lines.filter((line) => line.startsWith("- ")).length, added: lines.filter((line) => line.startsWith("+ ")).length, diff },
|
|
87
|
+
human: diff === "" ? "no lines changed\n" : `${diff}\n`,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
package/dist/commands/status.js
CHANGED
|
@@ -7,7 +7,7 @@ import { scanNotes } from "../lib/notes.js";
|
|
|
7
7
|
import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
8
8
|
import { isRecord } from "../lib/input.js";
|
|
9
9
|
import { expandPath, linkedRepos, workTreeTop } from "../lib/repos.js";
|
|
10
|
-
const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory"];
|
|
10
|
+
const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory", "reference"];
|
|
11
11
|
const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
|
|
12
12
|
/** `brain status` (spec/cli.md §5.2). */
|
|
13
13
|
export const statusCommand = {
|
|
@@ -75,7 +75,7 @@ function readNotion(config) {
|
|
|
75
75
|
const databases = notion.databases;
|
|
76
76
|
const configured = [];
|
|
77
77
|
const missing = [];
|
|
78
|
-
for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts"]) {
|
|
78
|
+
for (const key of ["areas", "goals", "key_results", "projects", "meetings", "people", "orgs", "decisions", "questions", "review", "inbox", "concepts", "references"]) {
|
|
79
79
|
const value = key === "inbox" && databases.inbox === undefined ? databases.capture_inbox : databases[key];
|
|
80
80
|
(typeof value === "string" && value !== "" ? configured : missing).push(key);
|
|
81
81
|
}
|
package/dist/lib/apply.js
CHANGED
|
@@ -2,15 +2,16 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import { posix } from "node:path";
|
|
3
3
|
import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
|
|
4
4
|
import { compareCodePoints } from "./notes.js";
|
|
5
|
-
import { stageNewItem } from "./proposals.js";
|
|
5
|
+
import { bodyHash, bodyOf, reviewTitle, sourceTitle, stageNewItem, writeProposal } from "./proposals.js";
|
|
6
6
|
import { loadSchemas } from "./schemas.js";
|
|
7
7
|
import { formatDate, normalizeTimestamp } from "./time.js";
|
|
8
|
-
const ACTIONS = ["approve", "reject", "edit"];
|
|
9
|
-
export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note", "answer"]);
|
|
8
|
+
const ACTIONS = ["approve", "reject", "fix", "edit"];
|
|
9
|
+
export const EDITABLE = new Set(["update_fact", "contradiction", "add_to_note", "update_body", "answer"]);
|
|
10
|
+
const FIX_KEYS = ["value", "change", "note", "field", "link"];
|
|
10
11
|
/** Reference fields a merge leaves alone: the meeting side of a persisted pair (spec/schema.md §11.2). */
|
|
11
12
|
const PAIR_FIELDS = new Set(["decisions", "questions", "decided_in", "raised_in"]);
|
|
12
13
|
/** Corrections change knowledge notes only: sources are immutable and proposals are records. */
|
|
13
|
-
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
14
|
+
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
|
|
14
15
|
const REFERENCE_FIELDS = [
|
|
15
16
|
"area", "areas", "goal", "client", "org", "projects", "people", "attendees", "deciders", "source", "sources",
|
|
16
17
|
"answer", "project", "generated_from", "facts",
|
|
@@ -49,19 +50,25 @@ export function parseApplyInput(input) {
|
|
|
49
50
|
const pointer = `/decisions/${index}`;
|
|
50
51
|
if (!isRecord(item))
|
|
51
52
|
return problems.push({ pointer, message: "must be an object" });
|
|
52
|
-
problems.push(...unknownKeys(item, ["proposal", "action", "value", "note", "exclude", "decided_at", "decided_by"], pointer));
|
|
53
|
+
problems.push(...unknownKeys(item, ["proposal", "action", "value", "note", "fixes", "exclude", "decided_at", "decided_by"], pointer));
|
|
53
54
|
if (typeof item.proposal !== "string")
|
|
54
55
|
problems.push({ pointer: `${pointer}/proposal`, message: "must be a proposal id" });
|
|
55
56
|
if (!ACTIONS.includes(item.action))
|
|
56
|
-
problems.push({ pointer: `${pointer}/action`, message: "must be approve, reject or edit" });
|
|
57
|
+
problems.push({ pointer: `${pointer}/action`, message: "must be approve, reject, fix or edit" });
|
|
58
|
+
if (item.action === "fix" && (typeof item.note !== "string" || item.note === ""))
|
|
59
|
+
problems.push({ pointer: `${pointer}/note`, message: "is required for fix: the user's words" });
|
|
60
|
+
if (item.action === "fix" && (!Array.isArray(item.fixes) || item.fixes.length === 0 || item.fixes.some((entry) => !isRecord(entry) || !Object.hasOwn(entry, "value") || Object.keys(entry).some((key) => !FIX_KEYS.includes(key)))))
|
|
61
|
+
problems.push({ pointer: `${pointer}/fixes`, message: "must be a non-empty list of entries with a value (spec/schema.md §11.3)" });
|
|
62
|
+
if (item.action !== "fix" && item.fixes !== undefined)
|
|
63
|
+
problems.push({ pointer: `${pointer}/fixes`, message: "is only allowed for fix" });
|
|
57
64
|
if (item.action === "edit" && (item.value === undefined || item.value === null))
|
|
58
65
|
problems.push({ pointer: `${pointer}/value`, message: "is required for edit" });
|
|
59
66
|
if (item.action !== "edit" && item.value !== undefined)
|
|
60
67
|
problems.push({ pointer: `${pointer}/value`, message: "is only allowed for edit" });
|
|
61
68
|
if (item.note !== undefined && typeof item.note !== "string")
|
|
62
69
|
problems.push({ pointer: `${pointer}/note`, message: "must be a string" });
|
|
63
|
-
if (item.exclude !== undefined && (!Array.isArray(item.exclude) || item.exclude.some((entry) => !isRecord(entry) || !(isNoteExclude(entry) || (typeof entry.field === "string" && Object.hasOwn(entry, "value") && Object.keys(entry).every((key) => ["target", "field", "value"].includes(key)))))))
|
|
64
|
-
problems.push({ pointer: `${pointer}/exclude`, message: "must be a list of {field, value}, {target, field, value} or {
|
|
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 (
|
|
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 (
|
|
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 (
|
|
207
|
-
throw new ItemFailure("INPUT_INVALID", "exclude on a review_notes takes {note}
|
|
208
|
-
|
|
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 === "
|
|
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 :
|
|
238
|
+
const V = decision.action === "edit" ? decision.value : fixed.value;
|
|
231
239
|
if (p.kind === "review_notes") {
|
|
232
|
-
const
|
|
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
|
-
|
|
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,
|
|
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)
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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(
|
|
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 }));
|
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The changed lines of `after` against `before`, as `- ` and `+ ` lines with no context, by the
|
|
3
|
+
* longest common subsequence walk of spec/cli.md §5.19 (a tie removes first).
|
|
4
|
+
*/
|
|
5
|
+
export function lineDiff(before, after) {
|
|
6
|
+
const o = before.replace(/\r\n/g, "\n").split("\n");
|
|
7
|
+
const n = after.replace(/\r\n/g, "\n").split("\n");
|
|
8
|
+
const L = Array.from({ length: o.length + 1 }, () => new Array(n.length + 1).fill(0));
|
|
9
|
+
for (let i = o.length - 1; i >= 0; i -= 1) {
|
|
10
|
+
for (let j = n.length - 1; j >= 0; j -= 1)
|
|
11
|
+
L[i][j] = o[i] === n[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
12
|
+
}
|
|
13
|
+
const out = [];
|
|
14
|
+
let i = 0;
|
|
15
|
+
let j = 0;
|
|
16
|
+
while (i < o.length && j < n.length) {
|
|
17
|
+
if (o[i] === n[j]) {
|
|
18
|
+
i += 1;
|
|
19
|
+
j += 1;
|
|
20
|
+
}
|
|
21
|
+
else if (L[i + 1][j] >= L[i][j + 1])
|
|
22
|
+
out.push(`- ${o[i++]}`);
|
|
23
|
+
else
|
|
24
|
+
out.push(`+ ${n[j++]}`);
|
|
25
|
+
}
|
|
26
|
+
while (i < o.length)
|
|
27
|
+
out.push(`- ${o[i++]}`);
|
|
28
|
+
while (j < n.length)
|
|
29
|
+
out.push(`+ ${n[j++]}`);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
package/dist/lib/ids.js
CHANGED
|
@@ -10,6 +10,7 @@ export const ID_PREFIXES = [
|
|
|
10
10
|
"dec",
|
|
11
11
|
"que",
|
|
12
12
|
"con",
|
|
13
|
+
"ref",
|
|
13
14
|
"src",
|
|
14
15
|
"mem",
|
|
15
16
|
"prp",
|
|
@@ -44,6 +45,7 @@ export const TYPE_PREFIX = {
|
|
|
44
45
|
decision: "dec",
|
|
45
46
|
question: "que",
|
|
46
47
|
concept: "con",
|
|
48
|
+
reference: "ref",
|
|
47
49
|
source: "src",
|
|
48
50
|
memory: "mem",
|
|
49
51
|
proposal: "prp",
|
|
@@ -11,8 +11,10 @@ const SECTIONS = [
|
|
|
11
11
|
["question", "Questions"],
|
|
12
12
|
["meeting", "Meetings"],
|
|
13
13
|
["concept", "Concepts"],
|
|
14
|
+
["reference", "References"],
|
|
14
15
|
];
|
|
15
16
|
const NON_KNOWLEDGE = new Set(["source", "view", "proposal"]);
|
|
17
|
+
const TYPES = [...SECTIONS.map(([type]) => type), "source", "view", "proposal", "memory"];
|
|
16
18
|
/** Computed inverses exposed as backlinks (spec/schema.md §6). */
|
|
17
19
|
const RELATION_INVERSES = {
|
|
18
20
|
about: "mentioned_in",
|
|
@@ -45,7 +47,8 @@ export function buildIndex(allNotes) {
|
|
|
45
47
|
byId.set(note.id, note);
|
|
46
48
|
notes.push(note);
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
// Every type has a count, zero included (spec/cli.md §5.8).
|
|
51
|
+
const counts = Object.fromEntries(TYPES.map((type) => [type, 0]));
|
|
49
52
|
for (const note of notes)
|
|
50
53
|
counts[note.type] = (counts[note.type] ?? 0) + 1;
|
|
51
54
|
return {
|
|
@@ -114,6 +117,8 @@ function typeFields(note, byId) {
|
|
|
114
117
|
return [["kind", scalar(a.kind)], ["status", notionField("status")]];
|
|
115
118
|
case "meeting":
|
|
116
119
|
return [["date", scalar(a.date)], ["project", refs(a.projects)]];
|
|
120
|
+
case "reference":
|
|
121
|
+
return [["area", ref(a.area)], ["project", refs(a.projects)]];
|
|
117
122
|
default:
|
|
118
123
|
return [];
|
|
119
124
|
}
|
package/dist/lib/lint.js
CHANGED
|
@@ -8,12 +8,12 @@ import { rangesProblem, splitParts } from "./parts.js";
|
|
|
8
8
|
import { sourceFolder } from "./paths.js";
|
|
9
9
|
import { describeErrors, loadSchemas } from "./schemas.js";
|
|
10
10
|
import { deriveView, generatedBlockRange } from "./views.js";
|
|
11
|
-
const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
|
|
11
|
+
const ID = /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem|prp|vw)-[0-9abcdefghjkmnpqrstvwxyz]{8}$/;
|
|
12
12
|
/** Frontmatter keys whose values are references (spec/schema.md §3–§11); `relations` and `timeline` are handled by shape. */
|
|
13
13
|
const REFERENCE_KEYS = new Set([
|
|
14
14
|
"area", "goal", "client", "org", "projects", "people", "attendees", "decided_in", "raised_in", "deciders",
|
|
15
15
|
"source", "sources", "decisions", "questions", "answer", "target", "supersedes", "merge_into", "project",
|
|
16
|
-
"generated_from", "facts", "notes", "answer_note", "links", "attached_to",
|
|
16
|
+
"generated_from", "facts", "notes", "answer_note", "links", "attached_to", "changes", "replaces", "deferred_from",
|
|
17
17
|
]);
|
|
18
18
|
/** Where each kind of view must live (spec/schema.md §2, 0041, 0043). */
|
|
19
19
|
const VIEW_FOLDERS = {
|
|
@@ -32,12 +32,13 @@ const FOLDERS = {
|
|
|
32
32
|
decision: /^knowledge\/decisions\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
33
33
|
question: /^knowledge\/questions\/[^/]+\.md$/,
|
|
34
34
|
concept: /^knowledge\/concepts\/[^/]+\.md$/,
|
|
35
|
+
reference: /^knowledge\/references\/[^/]+\.md$/,
|
|
35
36
|
source: /^sources\/(notion|files|text)\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
36
37
|
memory: /^memories\/\d{4}\/\d{2}\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/,
|
|
37
38
|
proposal: /^_proposals\/[^/]+\.md$/,
|
|
38
39
|
};
|
|
39
|
-
const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
40
|
-
const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept"]);
|
|
40
|
+
const KNOWLEDGE = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "reference", "memory"]);
|
|
41
|
+
const UNDATED = new Set(["area", "goal", "project", "person", "org", "question", "concept", "reference"]);
|
|
41
42
|
const FRESHNESS_WORDS = /\b(currently|now|at the moment|this week|recently|soon)\b/i;
|
|
42
43
|
const HAS_DATE = /\b\d{4}-\d{2}(-\d{2})?\b/;
|
|
43
44
|
const MEMORY_REACH = { general: null, area: "areas", project: "projects", stack: "stacks" };
|
|
@@ -75,6 +76,12 @@ export function lintBrain(input) {
|
|
|
75
76
|
for (const id of new Set(list(note.attributes.notes).map(String)))
|
|
76
77
|
reviewedBy.set(id, [...(reviewedBy.get(id) ?? []), note.id]);
|
|
77
78
|
}
|
|
79
|
+
// Every source replacing each source id (rule source-replaces, 0045).
|
|
80
|
+
const replacedBy = new Map();
|
|
81
|
+
for (const note of input.notes) {
|
|
82
|
+
if (note.type === "source" && typeof note.attributes.replaces === "string")
|
|
83
|
+
replacedBy.set(note.attributes.replaces, [...(replacedBy.get(note.attributes.replaces) ?? []), note.id]);
|
|
84
|
+
}
|
|
78
85
|
const onDisk = caseSensitiveExists(input.root);
|
|
79
86
|
const exists = (path) => input.pendingPaths?.has(path) === true || onDisk(path);
|
|
80
87
|
for (const note of input.notes) {
|
|
@@ -218,11 +225,28 @@ export function lintBrain(input) {
|
|
|
218
225
|
const listed = byId.get(String(id));
|
|
219
226
|
if (listed !== undefined && !KNOWLEDGE.has(listed.type))
|
|
220
227
|
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is a ${listed.type}, not a knowledge note`);
|
|
221
|
-
|
|
228
|
+
// A deferred row lists again the notes the row it came from excluded (0045).
|
|
229
|
+
const others = (reviewedBy.get(String(id)) ?? []).filter((other) => other !== note.id && !deferredPair(note, byId.get(other), String(id)));
|
|
222
230
|
if (others.length > 0)
|
|
223
231
|
err("review-notes", lineOfKey(note, "notes"), `${String(id)} is also listed by ${others.join(", ")}`);
|
|
224
232
|
}
|
|
225
233
|
}
|
|
234
|
+
if (note.type === "proposal") {
|
|
235
|
+
const bodies = [...(a.kind === "update_body" ? [a.target] : []), ...list(a.changes).map(record).filter((change) => change.kind === "update_body").map((change) => change.target)];
|
|
236
|
+
for (const target of bodies) {
|
|
237
|
+
const reference = typeof target === "string" ? byId.get(target) : undefined;
|
|
238
|
+
if (reference !== undefined && reference.type !== "reference")
|
|
239
|
+
err("update-body", lineOfKey(note, a.kind === "update_body" ? "target" : "changes"), `update_body targets ${reference.id}, a ${reference.type}; only a reference has its body replaced (0045)`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (note.type === "source" && typeof a.replaces === "string") {
|
|
243
|
+
const replaced = byId.get(a.replaces);
|
|
244
|
+
if (replaced !== undefined && replaced.type !== "source")
|
|
245
|
+
err("source-replaces", lineOfKey(note, "replaces"), `replaces ${a.replaces}, a ${replaced.type}, not a source`);
|
|
246
|
+
const others = (replacedBy.get(a.replaces) ?? []).filter((other) => other !== note.id);
|
|
247
|
+
if (others.length > 0)
|
|
248
|
+
err("source-replaces", lineOfKey(note, "replaces"), `${a.replaces} is also replaced by ${others.join(", ")}`);
|
|
249
|
+
}
|
|
226
250
|
if (note.type === "source" && Array.isArray(a.notion_parts)) {
|
|
227
251
|
const problem = rangesProblem(a.notion_parts, splitParts(note.body).parts.length);
|
|
228
252
|
if (problem !== null)
|
|
@@ -253,10 +277,10 @@ function proposalStatusProblem(a) {
|
|
|
253
277
|
const expected = {
|
|
254
278
|
pending: [undefined],
|
|
255
279
|
approved: ["approve"],
|
|
256
|
-
edited: ["edit"],
|
|
280
|
+
edited: ["fix", "edit"],
|
|
257
281
|
rejected: ["reject"],
|
|
258
|
-
applied: ["approve", "edit"],
|
|
259
|
-
closed: stale ? ["approve", "edit"] : ["reject"],
|
|
282
|
+
applied: ["approve", "fix", "edit"],
|
|
283
|
+
closed: stale ? ["approve", "fix", "edit"] : ["reject"],
|
|
260
284
|
};
|
|
261
285
|
const allowed = expected[String(status)];
|
|
262
286
|
if (allowed === undefined)
|
|
@@ -266,6 +290,13 @@ function proposalStatusProblem(a) {
|
|
|
266
290
|
}
|
|
267
291
|
return null;
|
|
268
292
|
}
|
|
293
|
+
/** A deferred row and the row whose excluded note it took over (spec/schema.md §15, 0045). */
|
|
294
|
+
function deferredPair(note, other, id) {
|
|
295
|
+
if (other === undefined)
|
|
296
|
+
return false;
|
|
297
|
+
const excluded = (proposal) => list(record(proposal.attributes.decision).exclude).some((entry) => record(entry).note === id);
|
|
298
|
+
return (note.attributes.deferred_from === other.id && excluded(other)) || (other.attributes.deferred_from === note.id && excluded(note));
|
|
299
|
+
}
|
|
269
300
|
function collectIds(value, out = []) {
|
|
270
301
|
if (typeof value === "string") {
|
|
271
302
|
if (ID.test(value))
|