ultimate-brain 0.6.1 → 0.7.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 +2 -2
- package/.agents/skills/brain-catch-up/SKILL.md +57 -4
- package/.agents/skills/brain-howto/SKILL.md +1 -1
- package/.agents/skills/brain-howto/references/guide.md +16 -0
- package/.agents/skills/brain-ingest/references/sources.md +1 -1
- package/.agents/skills/brain-sync/SKILL.md +1 -1
- package/dist/cli.js +2 -0
- package/dist/commands/schedule.js +282 -0
- package/dist/commands/status.js +9 -2
- package/dist/commands/sync.js +31 -6
- package/dist/lib/errors.js +2 -0
- package/dist/lib/lint.js +9 -0
- package/dist/lib/log.js +49 -0
- package/dist/lib/runs.js +70 -0
- package/dist/lib/sync.js +62 -6
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +13 -0
- package/templates/brain/AGENTS.md +1 -1
|
@@ -13,7 +13,7 @@ Run every CLI command as `brain <command> --brain <brain> --json`. Exit 1 means
|
|
|
13
13
|
|
|
14
14
|
- **Chat:** the user wants to decide here ("what needs my decision?", "approve the budget change"). Use this, or when Notion is not configured.
|
|
15
15
|
- **Notion:** the user decided in the Notion Review queue ("apply my reviews"), and `brain status` shows `review` among `notion.configured`.
|
|
16
|
-
- **Unattended** (a scheduled
|
|
16
|
+
- **Unattended** (a scheduled catch-up, nobody in the chat): Notion mode, applying only what the user decided word for word: Approve or Reject with an empty What to fix. A decision with text in What to fix (a Fix, or "all except b") stays in its row for an attended run, because turning words into changes is judgment nobody would check (decisions 0003, 0051). `--record-only` is for a run that must not apply anything at all.
|
|
17
17
|
|
|
18
18
|
## Chat mode
|
|
19
19
|
|
|
@@ -56,7 +56,7 @@ Run every CLI command as `brain <command> --brain <brain> --json`. Exit 1 means
|
|
|
56
56
|
- `decisions`: what will be applied;
|
|
57
57
|
- `conflicts`: `NOTE_TO_READ` is an Approve or Reject with text in What to fix, and `FIX_TO_READ` a Fix with its text: read each as "Reading What to fix" below says. `FIX_WITHOUT_TEXT` is a Fix with What to fix empty: the user says what's wrong in Notion (or here);
|
|
58
58
|
- `ignored`: e.g. `ALREADY_TERMINAL`. Nothing to do.
|
|
59
|
-
3. **Apply.** Run `brain sync apply --input <plan file>`, adding `--record-only`
|
|
59
|
+
3. **Apply.** Run `brain sync apply --input <plan file>`, adding `--record-only` only for a run that must not apply anything (an unattended catch-up adds `--skip-conflicted` instead, see brain-catch-up). Then apply the decisions you resolved from What to fix with `brain apply --input <file>` (unattended: leave `NOTE_TO_READ` and `FIX_TO_READ` rows for an attended run).
|
|
60
60
|
4. Continue with **Finish** below.
|
|
61
61
|
|
|
62
62
|
## Reading What to fix
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: brain-catch-up
|
|
3
|
-
description: Do everything that is waiting in one run. Pulls the user's Notion edits, applies the reviews they decided (Fix included), ingests reviewed meetings, Inbox rows that are Ready, their attachments, the drop folder and linked repos, reads again the inputs the user corrected, keeps the documents asked for (whole or in part) and promotes the notes asked for, writes and refreshes the reports asked for, publishes and pushes, asking every question once at the start and then finishing without stopping. Skips the steps of modules that are off. Use when the user says "catch up", "what's next", "do what's waiting"
|
|
3
|
+
description: Do everything that is waiting in one run. Pulls the user's Notion edits, applies the reviews they decided (Fix included), ingests reviewed meetings, Inbox rows that are Ready, their attachments, the drop folder and linked repos, reads again the inputs the user corrected, keeps the documents asked for (whole or in part) and promotes the notes asked for, writes and refreshes the reports asked for, publishes and pushes, asking every question once at the start and then finishing without stopping. Skips the steps of modules that are off. Also runs with nobody there ("catch up unattended", the prompt a schedule runs) and sets that schedule ("run catch-up every morning"). Use when the user says "catch up", "what's next", "do what's waiting", "process everything", "catch up unattended" or asks to run catch-up on a schedule.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Catch up
|
|
7
7
|
|
|
8
|
-
One run for everything waiting (decision 0037). It has two halves: a **read-only pass** that finds the work and every question it raises, then **one batch of questions**, then a run that finishes without asking again. Anything that becomes blocked during the run is **skipped** and listed in the report; the rest goes on.
|
|
8
|
+
One run for everything waiting (decision 0037). It has two halves: a **read-only pass** that finds the work and every question it raises, then **one batch of questions**, then a run that finishes without asking again. Anything that becomes blocked during the run is **skipped** and listed in the report; the rest goes on. Started as "catch up unattended", it asks nothing at all: follow "Unattended" at the end, which says what changes in each step.
|
|
9
9
|
|
|
10
10
|
This skill orders the work; the other skills hold the steps. Read each one before running its part: brain-sync (pull and publish), brain-apply (decisions), brain-ingest with its references (meetings, sources, attachments, the drop folder, linked repos, Documents), brain-query (reports). Their rules apply unchanged, including the trust rules in the brain's AGENTS.md.
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ Run every CLI command as `brain <command> --brain <brain> --json`. If `brain` is
|
|
|
19
19
|
|
|
20
20
|
This step changes no knowledge: no note, proposal, `brain.json`, commit, Notion page or drop-folder file. Two writes are allowed because they change nothing the user owns: `brain index` may regenerate a stale `index.md` (generated, rebuilt again later in the run), and the working files (`snapshot.json`, `plan.json`, conversions) go in a temporary folder outside the brain.
|
|
21
21
|
|
|
22
|
-
1. **Status.** Run `brain status`; run `brain index` if `index_stale`. Note `settings` (absent means `publish: ask`, `push: auto`), `modules`, and which databases are configured. Each Notion part below needs its database configured; skip the ones that aren't and name them once on the report's Needs you line.
|
|
22
|
+
1. **Status.** Run `brain status`; run `brain index` if `index_stale`. Note `settings` (absent means `publish: ask`, `push: auto`, `unattended.max_inputs: 10`), and `unattended` (the scheduled runs since the last attended catch-up), `modules`, and which databases are configured. Each Notion part below needs its database configured; skip the ones that aren't and name them once on the report's Needs you line.
|
|
23
23
|
2. **Pull plan.** Brain-sync "Pull", steps 2–4, with the snapshot and plan in the temporary folder. Read **every** configured database of a module that is on (brain-sync step 2 lists them, Goals, Key Results and Documents included; never Meetings), all pages of each, plus the Inbox rows brain-sync step 2 names. Run `sync plan` once and save `plan.json`. It holds the imports, your corrections, the decided Review rows, the conflicts, `document_requests` and `promote_requests` (*Documents*), and `refresh_requests` and `report_fixes` (*Reports*).
|
|
24
24
|
3. **What to fix on Review rows.** For each `NOTE_TO_READ` and `FIX_TO_READ` conflict, read What to fix against its proposal and decide what it means (brain-apply, "Reading What to fix"). Write down the decision you would apply ("a: launch 2027-03"), or the question it raises. A `FIX_WITHOUT_TEXT` row goes in the batch as a question: "Acme launch review: Fix, but What to fix is empty: what's wrong?"
|
|
25
25
|
4. **Meetings** *(Notion)*. Brain-ingest steps 2 and 3, up to the duplicate check: the Reviewed meetings, oldest first, and each one's pre-check blockers. A meeting the brain already ingested and that is Reviewed again is a **re-read** (decision 0045): the user fixed its minutes; say so in the batch ("Acme sync, read again: only what changed"). Minutes files are ingested only when the user names them, never looked for.
|
|
@@ -38,7 +38,7 @@ The pass is done when every input that is waiting has been found and each of its
|
|
|
38
38
|
|
|
39
39
|
## 2. One batch of questions
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
When `unattended.catch_up` in `brain status` isn't null, open with the scheduled runs since the last attended one, as one line: "Since last time: 4 scheduled runs, 6 inputs in, 1 skipped, 1 Fix waiting, 1 failed: Notion login". Say "Backlog growing: run more often or raise the budget" too when `unattended.backlog` is true. Then ask once, in one message, numbered and grouped by input, each with the answer you will use if the user says "go" (the **default**):
|
|
42
42
|
- sync conflicts (`BOTH_CHANGED`, `MISSING_REQUIRED`, `UNRESOLVED_RELATION`, …) and your corrections, as brain-sync step 5 shows them;
|
|
43
43
|
- each What to fix you read, as "Acme sync: 'all except b' → approve the rest, b waits in a row of its own" or "Acme sync: Fix 'a: March' → launch date 2027-03";
|
|
44
44
|
- each meeting's and input's pre-check blockers;
|
|
@@ -92,3 +92,56 @@ For each input, publishing waits for step 4 below. **Skipped, not asked:** an in
|
|
|
92
92
|
```
|
|
93
93
|
Without Notion, the Next line is deciding in chat ("what needs my decision"), and "connect the brain to Notion" goes on the Needs you line.
|
|
94
94
|
Below the three lines: **What came in** (each re-read with what changed; each Document with what it keeps) and **What changed**, then the **Fixes** you read, repeated back (brain-apply "Finish" step 5), then the **reports written or refreshed**, each with its row, then **Check** (each skipped input with its reason, each attachment that couldn't be read, the notes kept as drafts or left unchanged by a Review notes row, stale proposals, assumptions), as brain-ingest step 12 and brain-apply "Finish" step 5 define them, uncapped. Offer the `settings.publish` switch here when AGENTS.md says to.
|
|
95
|
+
|
|
96
|
+
## Unattended: "catch up unattended"
|
|
97
|
+
|
|
98
|
+
The prompt a schedule runs (see "Run on a schedule"), or the user by hand. Nobody is there to ask, so every question of step 2 takes its default or skips its input, and the run goes on (decision 0051). It carries out what the user already decided word for word and interprets no text of theirs, because turning their words into changes is judgment nobody would check. It may quote a What to fix as written (in a report, to say what waits); it never works out what it means or acts on it. The 5-minute bar is for attended runs; a budget of inputs replaces it.
|
|
99
|
+
|
|
100
|
+
Do the read-only pass (step 1), but never interpret a What to fix: skip step 3, and in step 9 only note that a `report_fixes` item exists, without working out the proposals its text would become (its report and its refresh then wait, below). Then:
|
|
101
|
+
|
|
102
|
+
1. **Nothing waiting and nothing failed: stop.** No commit, no Notion write, no log line, no report. A schedule that runs twice a day must leave no noise.
|
|
103
|
+
2. **Take each question's answer from this table** instead of asking:
|
|
104
|
+
|
|
105
|
+
| The batch would ask about | Unattended |
|
|
106
|
+
|---|---|
|
|
107
|
+
| A sync conflict | **Skip the whole row**: nothing from it is applied or published (`--skip-conflicted`); named under Needs you |
|
|
108
|
+
| What to fix on a Review row, or on a report (`NOTE_TO_READ`, `FIX_TO_READ`, `FIX_WITHOUT_TEXT`, `report_fixes`) | **Skip** it: it waits for an attended run, and a report with a fix waiting isn't refreshed meanwhile |
|
|
109
|
+
| A meeting or Inbox row with a pre-check blocker, a likely new version, or a long attachment ("which parts matter?") | **Skip** it whole: the meeting stays Reviewed, the row Ready, a file stays in the queue |
|
|
110
|
+
| A capture that repeats a source the brain has | **Default**: skipped, with "Skipped: same as <title>" on its row when the run publishes |
|
|
111
|
+
| The drop folder: not set, or a long file | **Skip** the folder, or the long file. An unset folder is a line on the report ("Drop folder not set: say where it is"), never work and never a reason for a Report row: a brain that doesn't use one would otherwise report it on every run |
|
|
112
|
+
| The drop folder's other files, attachments, and a linked repo's files | **Default**: all of them, none kept as a document; a repo with more than 10 files is taken within the budget, and `repo record` waits until every listed file is in |
|
|
113
|
+
| Pending proposals in chat, and the identities question | **Skip**: left pending; asked at the next attended catch-up |
|
|
114
|
+
| *(Reports)* An Ask row | **Default**: a new report |
|
|
115
|
+
| *(Reports)* A Report row to refresh (Refresh asked, or a report whose fixes are all decided) | **Default**: refreshed, unless it has a What to fix waiting |
|
|
116
|
+
| "Publish to Notion at the end?" | **Default** from `settings.publish`: `auto` publishes, `ask` publishes nothing |
|
|
117
|
+
|
|
118
|
+
3. **Drop what belongs to a conflicted page, then apply only what was decided word for word.** First remove from `plan.json` every `document_requests`, `promote_requests`, `refresh_requests` and `report_fixes` item whose `page_id` is the `page_id` of a conflict: that row is skipped whole, so it makes no Document, promotion or refresh, and its box stays ticked for the next attended run. Then `sync apply --input plan.json --skip-conflicted`, without `--record-only`:
|
|
119
|
+
|
|
120
|
+
| In the plan | Unattended |
|
|
121
|
+
|---|---|
|
|
122
|
+
| A Review row set to Approve or Reject with What to fix empty | **Applied** |
|
|
123
|
+
| A property the user changed in Notion (Key Facts, Role, Name, Summary, Why, Deadline, Area, Goal, Client, Org, People), Lifecycle, Status, Priority, Dates and the other fields theirs | **Applied** |
|
|
124
|
+
| A row the user created in Notion (area, goal, project, person, org) | **Applied**: an addition |
|
|
125
|
+
| Approve or Reject with What to fix, a Fix, a Fix with no text, a What to fix on a report | **Waits**: counted, and named ("1 Fix waiting for you") |
|
|
126
|
+
| An `ignored` item (`NOT_CORRECTABLE`, `NOT_A_REPORT`, `MODULE_OFF`, …) | Nothing is done, as in an attended run, and a change the user made that the brain can't take (`NOT_CORRECTABLE`) goes under **Check** with the same line brain-sync step 5 gives, so it doesn't disappear from the report |
|
|
127
|
+
|
|
128
|
+
4. **The budget.** Take at most `settings.unattended.max_inputs` inputs (default 10), oldest first: each Reviewed meeting by its date, each Ready Inbox row (its attachments go with it) by when it was created, each drop-folder file by when it was modified, each changed repo file by its commit date. Skipped inputs don't count; failed ones do. An input the brain already has (`DUPLICATE_SOURCE`, for instance one an earlier run wrote but couldn't publish) is not an input again. The rest is `left`: name them ("4 more wait for the next run") and leave them as they are. Requests (Documents, promotions, refreshes, Ask rows, the backlog) aren't inputs.
|
|
129
|
+
5. **Run steps 3 and 4** with these changes: no chat decisions; a step that fails is logged and the run goes on with the next input; a lint error in a file this run wrote still stops the run. Publish only with `settings.publish: auto`, using `sync render --changed --plan plan.json --skip-conflicted`: a conflicted row and a report with a What to fix waiting come back in `skipped` and stay as they are. With `ask`, commit and push (per `settings.push`) and leave the publish to the next attended run.
|
|
130
|
+
6. **Report** in the usual three lines, and say the unattended facts: what is `left`, `waiting`, skipped and failed. Then:
|
|
131
|
+
- **Log it** (also when nothing was published): `brain log catch-up - mode=unattended inputs=<n> left=<n> skipped=<n> waiting=<n> failed=<n> report=<yes|no> notes_read=<n> seconds=<s>`, with `failures="<reason>; <reason>"` before `report` when a step failed (a few words each: "Notion login"). `inputs` counts the inputs taken in, failed ones included. Commit it: `log: catch up (unattended)`.
|
|
132
|
+
- **One Inbox Report row, only with Reports on, `settings.publish: auto`, and something that needs the user or failed.** Create it through the Notion tools in the publish step: Name `Catch-up <D Mon>: <n> in, <n> need you` (`, <n> failed` when one did), Kind Report, Brain Status Done, no Brain ID, the report as its body. A run that went well and needs nothing writes no row. A Notion failure can't be reported in Notion; the log line still holds it.
|
|
133
|
+
- With Reports off, or `ask`, nothing else is written: what needs the user waits where they act on it, and the next attended catch-up opens with the summary of the scheduled runs.
|
|
134
|
+
|
|
135
|
+
## Run on a schedule
|
|
136
|
+
|
|
137
|
+
When the user says "run catch-up every morning" (or "twice a day", "on weekdays"):
|
|
138
|
+
|
|
139
|
+
1. **Ask what is missing, once:** the times; the days, if not every day; and the Claude model to pin, as a full id such as `claude-sonnet-5-5`. An alias such as `sonnet` is refused, because it moves under the schedule, and the skills are tested on Claude models only. Say what a run does: it takes at most 10 inputs, oldest first (`brain config set settings.unattended.max_inputs <n>` changes it), carries out the decisions they made, and leaves anything that needs their reading for their next "catch up".
|
|
140
|
+
2. **Offer once what makes a run useful:**
|
|
141
|
+
- **Reports,** when the module is off: "Turn on Reports to get a row in the Inbox when a scheduled run needs you?" It follows notion/modules/reports.md, and only after a yes.
|
|
142
|
+
- **`settings.publish: auto`,** when it is `ask`: a scheduled run publishes nothing otherwise, and writes no Report row. Set it with `brain config set settings.publish '"auto"'` only after a yes.
|
|
143
|
+
- **The tools it may use.** A scheduled `claude -p` can't ask permission, so it uses only what the brain's `.claude/settings.json` allows, and refuses the rest (which the run logs as a failure). Offer to add to `permissions.allow`: `Bash(brain:*)`, `Bash(git:*)`, `Read`, `Edit`, `Write`, and the Notion tools. Merge into the file as it is, keeping every other key; write it only after a yes.
|
|
144
|
+
3. **Install it:** `brain schedule install catch-up --at 07:00 --days mon,tue,wed,thu,fri --model <id> --brain <brain> --json` (`--days` left out means every day; `--at` takes several times, `07:00,13:00`). On macOS it writes and loads a launchd agent, and says so; with `SCHEDULE_NOT_LOADED` in `warnings`, tell the user it is written but not loaded, and show launchctl's message. Elsewhere it writes nothing and returns cron lines: give them to the user to add with `crontab -e`. `CLAUDE_NOT_FOUND` means Claude Code isn't installed on `PATH`.
|
|
145
|
+
4. **Say how to check and stop it:** `brain schedule list` shows it; after the first run, "catch up" opens with what the scheduled runs did; a run's output is in `~/Library/Logs/ultimate-brain/`. Installing again with other times replaces it; `brain schedule remove catch-up` stops it.
|
|
146
|
+
|
|
147
|
+
**Other schedulers.** Any scheduler that runs `claude -p "catch up unattended" --model <id>` in the brain folder, signed in as the user and with their Notion connection, does the same job: an always-on agent such as Hermes can hold that command. Claude Code's cloud schedules don't qualify yet: they start from a fresh clone, with no drop folder, no linked repos and no local Notion login.
|
|
@@ -9,7 +9,7 @@ Answer how-to questions about the system from [references/guide.md](references/g
|
|
|
9
9
|
|
|
10
10
|
## Procedure
|
|
11
11
|
|
|
12
|
-
1. **Read the guide section** that matches the question. Its headings: The Spaces · The meeting flow · Deciding in Review · Correcting what came in (a meeting, a capture, a report; refreshing a report) · What you can edit · Adding things · What to say to your agent · When something looks wrong.
|
|
12
|
+
1. **Read the guide section** that matches the question. Its headings: The Spaces · The meeting flow · Deciding in Review · Correcting what came in (a meeting, a capture, a report; refreshing a report) · What you can edit · Adding things · Run catch-up on a schedule · What to say to your agent · When something looks wrong.
|
|
13
13
|
|
|
14
14
|
2. **If the question is about something that happened** ("why didn't…", "where did … go", "is my … set up"), check the live state before answering:
|
|
15
15
|
- `brain status --brain . --json` (or without `--brain` in a linked repo). It reports pending proposals, Notion databases configured or missing, stale views, and the last log line.
|
|
@@ -239,6 +239,21 @@ Brain Assistant reads; your agent in Claude Code or Codex writes. To get somethi
|
|
|
239
239
|
|
|
240
240
|
---
|
|
241
241
|
|
|
242
|
+
## Run catch-up on a schedule
|
|
243
|
+
|
|
244
|
+
Say "run catch-up every morning" (or "twice a day", "on weekdays") and the agent sets it up with you: the times, the Claude model to pin, and what a scheduled run may use. On a Mac it installs a background job; on other systems it gives you a line to add with `crontab -e`. The job runs `claude -p "catch up unattended"` in your brain folder, so your computer has to be on and Claude Code signed in at that time.
|
|
245
|
+
|
|
246
|
+
**What a scheduled run does.** It takes in what is waiting, up to 10 inputs a run, oldest first (change it with "raise the scheduled budget to 20"), and the rest waits for the next run. It carries out the decisions you already made word for word: Approve or Reject with nothing written in What to fix, and your edits in Notion. It never turns your words into changes: a **Fix**, an Approve with a note in What to fix ("all except b"), and a What to fix on a report all wait for your next "catch up", because turning words into changes is a judgment nobody would check. A meeting or capture that needs a question answered stays where it is (the meeting Reviewed, the row Ready). A run with nothing waiting writes nothing.
|
|
247
|
+
|
|
248
|
+
**Where you find out what it did.**
|
|
249
|
+
- Your next **"catch up"** opens with one line: "Since last time: 4 scheduled runs, 6 inputs in, 1 skipped, 1 Fix waiting, 1 failed: Notion login". It also warns "Backlog growing" when three runs in a row left inputs over: run it more often or raise the budget.
|
|
250
|
+
- With **Reports** on and **publish: auto**, a run that needs you or failed also adds one row to the Inbox ("Catch-up 29 Oct: 2 in, 1 needs you"). A run that went well adds nothing.
|
|
251
|
+
- With publish **ask** (the default), a scheduled run commits and pushes but publishes nothing to Notion; your next catch-up publishes. Say "publish automatically" to change that.
|
|
252
|
+
|
|
253
|
+
**Check or stop it:** "what is scheduled?" lists it, "stop the scheduled catch-up" removes it, and asking again with other times replaces it. If Claude Code's login to Notion expires, scheduled runs can't read Notion: that shows as a failure in the summary, and signing in again fixes it.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
242
257
|
## What to say to your agent
|
|
243
258
|
|
|
244
259
|
Run these in Claude Code or Codex, from your brain's folder.
|
|
@@ -246,6 +261,7 @@ Run these in Claude Code or Codex, from your brain's folder.
|
|
|
246
261
|
| You want to | Say |
|
|
247
262
|
|---|---|
|
|
248
263
|
| Do everything that's waiting: decided reviews, reviewed meetings, new captures, the drop folder | "catch up". It asks all its questions once at the start, then finishes |
|
|
264
|
+
| Have it run by itself | "run catch-up every morning" (or "twice a day", "on weekdays"); "what is scheduled?"; "stop the scheduled catch-up" |
|
|
249
265
|
| Get reviewed meetings into the brain | "ingest my reviewed meetings" |
|
|
250
266
|
| Ingest a file, web page, email or chat | "ingest this PDF: <path>", "ingest this page: <url>", "ingest this email" (then paste it) |
|
|
251
267
|
| Process quick captures | "process my inbox" |
|
|
@@ -36,7 +36,7 @@ A file attached to a Ready Inbox row or a Reviewed meeting page is ingested with
|
|
|
36
36
|
A linked repo is a read-only source: `brain.json` `repos` lists its name, path, area, file patterns and the last commit the brain ingested. Never write to it, and never run its code.
|
|
37
37
|
|
|
38
38
|
0. **Link it** when the user asks ("link the repo at ~/code/acme-api to the area Clients, files decisions/** and docs/*.md"): `brain repo add <name> --path <path> --area <area id> --pattern <glob> [--pattern …]`. `name` is short kebab-case (the folder's name, usually); the path is absolute or starts with `~/`; patterns are Git globs from the repo root (`decisions/**`, `docs/vision.md`, `CHANGELOG.md`). Ask which area when the user didn't say, and suggest the patterns when they didn't give any: the decision records and the docs that say what the product is, never code, tests or plans. Commit `brain.json`: `repo: link <name>`. The first ingest runs at the next catch-up, or now if the user asks.
|
|
39
|
-
1. **List the changes:** `brain repo changes <name>`. It returns the added and modified files matching the patterns since the recorded commit, oldest first, each with its `commit`, `sha256` and `bytes`, and the deleted ones apart. `REPO_DIRTY` means the working tree has uncommitted changes, which aren't read; say so once. With more than 10 files (a first ingest), ask which to take, default all, and whether the others are skipped for good or taken later
|
|
39
|
+
1. **List the changes:** `brain repo changes <name>`. It returns the added and modified files matching the patterns since the recorded commit, oldest first, each with its `commit`, `sha256` and `bytes`, and the deleted ones apart. `REPO_DIRTY` means the working tree has uncommitted changes, which aren't read; say so once. With more than 10 files (a first ingest), ask which to take, default all, and whether the others are skipped for good or taken later. An unattended catch-up asks nothing: it takes all of them, within its budget, oldest first, and records the commit only when every listed file is in (brain-catch-up, "Unattended").
|
|
40
40
|
2. **Read each file at its commit:** `git -C <path> show <to>:<repo_path>`, where `<to>` is the listing's `to`. A text file (`.md`, `.txt`) is its own text: converter `verbatim`. Any other file is converted as "Get the text" says.
|
|
41
41
|
3. **Ingest each file as a `document`,** oldest first, through steps 2–7 below: `source_key` `file:sha256:<sha256>`, `file_name` = the last segment of the path, `repo` = the repo's name, `repo_path` = the file's path, `commit` = the listing's `to`, `file_date` = the date of the file's `committed_at`. Its notes belong to the repo's area unless the text says which project. A changed file is a new source: what changed becomes proposals as usual; a file seen before with the same bytes is skipped as `DUPLICATE_SOURCE`.
|
|
42
42
|
4. **Record the commit** after the last file's ingest commit: `brain repo record <name> --commit <to>`, then commit `brain.json` with it (`repo: <name> at <short sha>`). If a file's ingest failed, don't record: the next run lists every file again, and the ones already in are skipped as `DUPLICATE_SOURCE`. Record only when every listed file is in, or the user skipped the rest for good: a recorded commit never lists them again. If the user wants some later, don't record: the next run lists them again with the ones already in, which skip as `DUPLICATE_SOURCE`. To take skipped files after all, `brain repo record <name> --reset` lists every matching file again.
|
|
@@ -55,7 +55,7 @@ Run CLI commands as `brain <command> --brain <brain> --json`. Database ids come
|
|
|
55
55
|
- **Ignored:** e.g. `NOT_CORRECTABLE`. "You changed a decision's Assessment in Notion; that can't be changed by editing. Use a proposal or tell me what's wrong." `MODULE_OFF` (a row of a module that is off) and `RETIRED` (a Full text box from before 0.6.0) need no line.
|
|
56
56
|
|
|
57
57
|
Corrections and Review decisions change the brain. If there are any, confirm with the user before applying, unless they already asked for exactly this sync.
|
|
58
|
-
6. **Apply.** Run `brain sync apply --input plan.json`. For an unattended
|
|
58
|
+
6. **Apply.** Run `brain sync apply --input plan.json`. For an unattended catch-up, add `--skip-conflicted`: a row with a conflict is left whole until the user answers it, and the user's other decisions and corrections are applied (0051). `--record-only` is for a run that must not apply anything at all: it skips corrections and records decisions without applying them (0003). Read each item's `result`. Report every `failed` item with its `reason`.
|
|
59
59
|
7. **Rebuild:** run `brain index`, `brain views`, then `brain lint`. If lint reports an error in a file this run changed, stop and report; don't commit.
|
|
60
60
|
8. **Commit:** `sync: <n> new, <n> corrections, <n> decisions from Notion`.
|
|
61
61
|
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { linkCommand } from "./commands/link.js";
|
|
|
15
15
|
import { upgradeCommand } from "./commands/upgrade.js";
|
|
16
16
|
import { proposalBacklogCommand, proposalListCommand, proposalNewCommand } from "./commands/proposal.js";
|
|
17
17
|
import { reportFixCommand, reportNewCommand } from "./commands/report.js";
|
|
18
|
+
import { scheduleInstallCommand, scheduleListCommand, scheduleRemoveCommand } from "./commands/schedule.js";
|
|
18
19
|
import { repoAddCommand, repoChangesCommand, repoRecordCommand } from "./commands/repo.js";
|
|
19
20
|
import { sourceDiffCommand, sourceFullTextCommand } from "./commands/source.js";
|
|
20
21
|
import { queryCommand } from "./commands/query.js";
|
|
@@ -27,6 +28,7 @@ import { installedProduct } from "./lib/skills.js";
|
|
|
27
28
|
export const COMMANDS = [
|
|
28
29
|
initCommand, statusCommand, configGetCommand, configSetCommand, idNewCommand, indexCommand, lintCommand, logCommand, noteNewCommand, proposalNewCommand, proposalListCommand, proposalBacklogCommand, sourceFullTextCommand, sourceDiffCommand, reportNewCommand, reportFixCommand, repoAddCommand, repoChangesCommand, repoRecordCommand, applyCommand,
|
|
29
30
|
syncPlanCommand, syncApplyCommand, syncRenderCommand, syncTasksCommand, syncRecordCommand, viewsCommand, linkCommand, queryCommand, upgradeCommand, modulesListCommand, modulesOnCommand, modulesOffCommand,
|
|
31
|
+
scheduleInstallCommand, scheduleListCommand, scheduleRemoveCommand,
|
|
30
32
|
];
|
|
31
33
|
export const HELP_TEXT = `Usage: brain <command> [options]
|
|
32
34
|
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { access, constants, lstat, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { BrainError } from "../lib/errors.js";
|
|
8
|
+
import { slugify } from "../lib/paths.js";
|
|
9
|
+
/** The jobs a schedule can run, each with its fixed prompt and the skill it needs (spec/cli.md §5.21, decision 0051). */
|
|
10
|
+
const JOBS = {
|
|
11
|
+
"catch-up": { prompt: "catch up unattended", skill: "brain-catch-up" },
|
|
12
|
+
reflect: { prompt: "tidy the brain unattended", skill: "brain-reflect" },
|
|
13
|
+
};
|
|
14
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
15
|
+
const DAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
16
|
+
const SYSTEM_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
17
|
+
export const scheduleInstallCommand = {
|
|
18
|
+
name: "schedule install",
|
|
19
|
+
usage: "brain schedule install <job> --at <HH:MM>[,<HH:MM>…] --model <id> [--days <day>[,<day>…]] [--dry-run] [--json]",
|
|
20
|
+
summary: "Schedule a skill to run unattended through claude -p: a launchd agent on macOS, a printed cron line elsewhere.",
|
|
21
|
+
options: { at: "string", model: "string", days: "string", "dry-run": "boolean" },
|
|
22
|
+
maxPositionals: 1,
|
|
23
|
+
needsBrain: true,
|
|
24
|
+
async run(context) {
|
|
25
|
+
const { args, brain } = context;
|
|
26
|
+
const job = readJob(args.positionals[0]);
|
|
27
|
+
const times = readTimes(args.options.at);
|
|
28
|
+
const days = readDays(args.options.days);
|
|
29
|
+
const model = args.options.model;
|
|
30
|
+
if (model === undefined || !/^claude-[a-z0-9][a-z0-9.-]*$/.test(model)) {
|
|
31
|
+
throw new BrainError("USAGE", "--model needs a full Claude model id, e.g. claude-sonnet-5-5: a schedule pins its model (decision 0051)");
|
|
32
|
+
}
|
|
33
|
+
const root = brain.root;
|
|
34
|
+
if (!(await exists(join(root, ".claude", "skills", JOBS[job].skill, "SKILL.md")))) {
|
|
35
|
+
throw new BrainError("SKILL_MISSING", `This brain has no ${JOBS[job].skill} skill for the ${job} job; run brain upgrade with a version that ships it`);
|
|
36
|
+
}
|
|
37
|
+
const env = context.environment.env ?? process.env;
|
|
38
|
+
const claude = await which("claude", env.PATH);
|
|
39
|
+
if (claude === null)
|
|
40
|
+
throw new BrainError("CLAUDE_NOT_FOUND", "No claude on PATH: install Claude Code, then schedule again");
|
|
41
|
+
const brainBin = await which("brain", env.PATH);
|
|
42
|
+
const path = unique([dirname(claude), dirname(process.execPath), ...(brainBin === null ? [] : [dirname(brainBin)]), ...SYSTEM_PATH.split(":")]).join(":");
|
|
43
|
+
const entry = { brain: root, job, times, days, model, prompt: JOBS[job].prompt };
|
|
44
|
+
const command = [claude, "-p", entry.prompt, "--model", model];
|
|
45
|
+
const label = labelFor(root, job);
|
|
46
|
+
const dryRun = args.options["dry-run"] === true;
|
|
47
|
+
const base = { job, label, times, days, model, prompt: entry.prompt, command };
|
|
48
|
+
if (platformOf(env) !== "darwin") {
|
|
49
|
+
const log = `$HOME/.ultimate-brain/logs/${label}.log`;
|
|
50
|
+
const cron = times.map((time) => {
|
|
51
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
52
|
+
const dow = days === null ? "*" : days.map((day) => DAYS.indexOf(day)).join(",");
|
|
53
|
+
return `${minute} ${hour} * * ${dow} mkdir -p "$HOME/.ultimate-brain/logs" && cd ${shellQuote(root)} && PATH=${shellQuote(path)} ${command.map(shellQuote).join(" ")} >> "${log}" 2>&1`;
|
|
54
|
+
});
|
|
55
|
+
return { data: { ...base, platform: platformOf(env), path: null, written: false, replaced: false, loaded: false, cron, dry_run: dryRun }, human: `${cron.join("\n")}\n` };
|
|
56
|
+
}
|
|
57
|
+
const agents = agentsDir(context);
|
|
58
|
+
const file = join(agents, `${label}.plist`);
|
|
59
|
+
const logs = join(home(context), "Library", "Logs", "ultimate-brain");
|
|
60
|
+
await refuseInside(root, agents);
|
|
61
|
+
await refuseInside(root, logs);
|
|
62
|
+
await refuseLink(file);
|
|
63
|
+
const replaced = await exists(file);
|
|
64
|
+
const warnings = [];
|
|
65
|
+
let loaded = false;
|
|
66
|
+
if (!dryRun) {
|
|
67
|
+
await mkdir(agents, { recursive: true });
|
|
68
|
+
if (!testing(env))
|
|
69
|
+
await mkdir(logs, { recursive: true });
|
|
70
|
+
await writeFile(file, plist(label, entry, command, path, join(logs, `${label}.log`)));
|
|
71
|
+
if (!testing(env)) {
|
|
72
|
+
await launchctl(env, ["bootout", `gui/${process.getuid?.() ?? 0}/${label}`]).catch(() => undefined);
|
|
73
|
+
try {
|
|
74
|
+
await launchctl(env, ["bootstrap", `gui/${process.getuid?.() ?? 0}`, file]);
|
|
75
|
+
loaded = true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
warnings.push({ code: "SCHEDULE_NOT_LOADED", message: `launchctl bootstrap failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
data: { ...base, platform: "darwin", path: file, written: !dryRun, replaced, loaded, cron: null, dry_run: dryRun },
|
|
84
|
+
warnings,
|
|
85
|
+
human: `${dryRun ? "Would schedule" : "Scheduled"} ${job} at ${times.join(", ")}${days === null ? "" : ` on ${days.join(", ")}`}\n`,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
export const scheduleListCommand = {
|
|
90
|
+
name: "schedule list",
|
|
91
|
+
usage: "brain schedule list [--json]",
|
|
92
|
+
summary: "List this brain's scheduled jobs.",
|
|
93
|
+
options: {},
|
|
94
|
+
maxPositionals: 0,
|
|
95
|
+
needsBrain: true,
|
|
96
|
+
async run(context) {
|
|
97
|
+
const env = context.environment.env ?? process.env;
|
|
98
|
+
if (platformOf(env) !== "darwin")
|
|
99
|
+
return { data: { schedules: [] }, warnings: [notManaged()], human: "cron lines are yours: crontab -l\n" };
|
|
100
|
+
const agents = agentsDir(context);
|
|
101
|
+
const prefix = labelFor(context.brain.root, "");
|
|
102
|
+
let files = [];
|
|
103
|
+
try {
|
|
104
|
+
files = (await readdir(agents)).filter((name) => name.startsWith(prefix) && name.endsWith(".plist")).sort();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// No LaunchAgents folder: nothing scheduled.
|
|
108
|
+
}
|
|
109
|
+
const schedules = [];
|
|
110
|
+
for (const name of files) {
|
|
111
|
+
const entry = readEntry(await readFile(join(agents, name), "utf8"));
|
|
112
|
+
if (entry === null)
|
|
113
|
+
continue;
|
|
114
|
+
schedules.push({ job: entry.job, label: name.slice(0, -".plist".length), path: join(agents, name), times: entry.times, days: entry.days, model: entry.model, prompt: entry.prompt });
|
|
115
|
+
}
|
|
116
|
+
schedules.sort((a, b) => a.job.localeCompare(b.job));
|
|
117
|
+
return { data: { schedules }, human: schedules.map((item) => `${item.job} at ${item.times.join(", ")}${item.days === null ? "" : ` on ${item.days.join(", ")}`} (${item.model})\n`).join("") || "nothing scheduled\n" };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
export const scheduleRemoveCommand = {
|
|
121
|
+
name: "schedule remove",
|
|
122
|
+
usage: "brain schedule remove <job> [--json]",
|
|
123
|
+
summary: "Remove one of this brain's scheduled jobs.",
|
|
124
|
+
options: {},
|
|
125
|
+
maxPositionals: 1,
|
|
126
|
+
needsBrain: true,
|
|
127
|
+
async run(context) {
|
|
128
|
+
const job = readJob(context.args.positionals[0]);
|
|
129
|
+
const env = context.environment.env ?? process.env;
|
|
130
|
+
const label = labelFor(context.brain.root, job);
|
|
131
|
+
if (platformOf(env) !== "darwin")
|
|
132
|
+
return { data: { job, label, path: null, removed: false }, warnings: [notManaged()], human: "cron lines are yours: crontab -e\n" };
|
|
133
|
+
const file = join(agentsDir(context), `${label}.plist`);
|
|
134
|
+
await refuseInside(context.brain.root, dirname(file));
|
|
135
|
+
await refuseLink(file);
|
|
136
|
+
if (!(await exists(file)))
|
|
137
|
+
return { data: { job, label, path: file, removed: false }, human: `${job} was not scheduled\n` };
|
|
138
|
+
if (!testing(env))
|
|
139
|
+
await launchctl(env, ["bootout", `gui/${process.getuid?.() ?? 0}/${label}`]).catch(() => undefined);
|
|
140
|
+
await rm(file);
|
|
141
|
+
return { data: { job, label, path: file, removed: true }, human: `Removed the ${job} schedule\n` };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
function readJob(value) {
|
|
145
|
+
if (value === undefined || !Object.hasOwn(JOBS, value))
|
|
146
|
+
throw new BrainError("USAGE", `A job is needed: ${Object.keys(JOBS).join(" or ")}`);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function readTimes(value) {
|
|
150
|
+
if (value === undefined || value.trim() === "")
|
|
151
|
+
throw new BrainError("USAGE", "--at needs one or more times, e.g. 07:00,13:00");
|
|
152
|
+
const times = value.split(",").map((part) => part.trim());
|
|
153
|
+
for (const time of times) {
|
|
154
|
+
const match = /^(\d{2}):(\d{2})$/.exec(time);
|
|
155
|
+
if (match === null || Number(match[1]) > 23 || Number(match[2]) > 59)
|
|
156
|
+
throw new BrainError("USAGE", `Not a time from 00:00 to 23:59: ${time}`);
|
|
157
|
+
}
|
|
158
|
+
return unique(times).sort();
|
|
159
|
+
}
|
|
160
|
+
function readDays(value) {
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
return null;
|
|
163
|
+
const days = value.split(",").map((part) => part.trim().toLowerCase());
|
|
164
|
+
for (const day of days)
|
|
165
|
+
if (!DAYS.includes(day))
|
|
166
|
+
throw new BrainError("USAGE", `Unknown day: ${day} (use ${DAY_ORDER.join(", ")})`);
|
|
167
|
+
return DAY_ORDER.filter((day) => days.includes(day));
|
|
168
|
+
}
|
|
169
|
+
/** `eu.ultimate-brain.<folder>-<hash>.<job>`: one entry per brain and job (spec/cli.md §5.21). */
|
|
170
|
+
function labelFor(root, job) {
|
|
171
|
+
const hash = createHash("sha256").update(root).digest("hex").slice(0, 8);
|
|
172
|
+
return `eu.ultimate-brain.${slugify(basename(root)) || "brain"}-${hash}.${job}`;
|
|
173
|
+
}
|
|
174
|
+
function plist(label, entry, command, path, log) {
|
|
175
|
+
const intervals = entry.times.flatMap((time) => {
|
|
176
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
177
|
+
return entry.days === null ? [{ Hour: hour, Minute: minute }] : entry.days.map((day) => ({ Hour: hour, Minute: minute, Weekday: DAYS.indexOf(day) }));
|
|
178
|
+
});
|
|
179
|
+
const dict = (pairs, indent) => `${indent}<dict>\n${pairs.map(([key, value]) => `${indent} <key>${xml(key)}</key>\n${indent} ${value}\n`).join("")}${indent}</dict>`;
|
|
180
|
+
const string = (value) => `<string>${xml(value)}</string>`;
|
|
181
|
+
const body = dict([
|
|
182
|
+
["Label", string(label)],
|
|
183
|
+
["ProgramArguments", `<array>\n${command.map((part) => ` ${string(part)}\n`).join("")} </array>`],
|
|
184
|
+
["WorkingDirectory", string(entry.brain)],
|
|
185
|
+
["EnvironmentVariables", `<dict>\n <key>PATH</key>\n ${string(path)}\n </dict>`],
|
|
186
|
+
["StartCalendarInterval", `<array>\n${intervals.map((interval) => `${dict(Object.entries(interval).map(([key, value]) => [key, `<integer>${value}</integer>`]), " ")}\n`).join("")} </array>`],
|
|
187
|
+
["StandardOutPath", string(log)],
|
|
188
|
+
["StandardErrorPath", string(log)],
|
|
189
|
+
["RunAtLoad", "<false/>"],
|
|
190
|
+
["ultimate-brain", string(JSON.stringify(entry))],
|
|
191
|
+
], "");
|
|
192
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n${body}\n</plist>\n`;
|
|
193
|
+
}
|
|
194
|
+
function readEntry(text) {
|
|
195
|
+
const match = /<key>ultimate-brain<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
|
|
196
|
+
if (match === null)
|
|
197
|
+
return null;
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(unxml(match[1]));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const xml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
206
|
+
const unxml = (value) => value.replaceAll(""", '"').replaceAll(">", ">").replaceAll("<", "<").replaceAll("&", "&");
|
|
207
|
+
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
208
|
+
const unique = (values) => [...new Set(values)];
|
|
209
|
+
const notManaged = () => ({ code: "SCHEDULE_NOT_MANAGED", message: "cron lines are yours: crontab -l" });
|
|
210
|
+
function platformOf(env) {
|
|
211
|
+
return env.UB_SCHEDULE_PLATFORM ?? process.platform;
|
|
212
|
+
}
|
|
213
|
+
/** With UB_LAUNCH_AGENTS_DIR set (tests), launchctl is never called and no log folder is made. */
|
|
214
|
+
function testing(env) {
|
|
215
|
+
return env.UB_LAUNCH_AGENTS_DIR !== undefined;
|
|
216
|
+
}
|
|
217
|
+
function home(context) {
|
|
218
|
+
return context.environment.homeDirectory ?? homedir();
|
|
219
|
+
}
|
|
220
|
+
function agentsDir(context) {
|
|
221
|
+
const env = context.environment.env ?? process.env;
|
|
222
|
+
return env.UB_LAUNCH_AGENTS_DIR ?? join(home(context), "Library", "LaunchAgents");
|
|
223
|
+
}
|
|
224
|
+
async function which(name, path) {
|
|
225
|
+
for (const dir of (path ?? "").split(delimiter).filter(Boolean)) {
|
|
226
|
+
const candidate = resolve(dir, name);
|
|
227
|
+
try {
|
|
228
|
+
await access(candidate, constants.X_OK);
|
|
229
|
+
return candidate;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Not here; try the next folder.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
async function exists(path) {
|
|
238
|
+
try {
|
|
239
|
+
await access(path);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function refuseLink(file) {
|
|
247
|
+
try {
|
|
248
|
+
if ((await lstat(file)).isSymbolicLink())
|
|
249
|
+
throw new BrainError("OUTSIDE_ROOT", `${file} is a symbolic link; remove it, then schedule again`);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error instanceof BrainError)
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function launchctl(env, args) {
|
|
257
|
+
await promisify(execFile)((await which("launchctl", env.PATH)) ?? "/bin/launchctl", [...args]);
|
|
258
|
+
}
|
|
259
|
+
/** A schedule writes nothing in the brain (spec/cli.md §5.21): refuse a folder that resolves inside it, links followed. */
|
|
260
|
+
async function refuseInside(root, folder) {
|
|
261
|
+
const brain = await realpath(root);
|
|
262
|
+
let existing = resolve(folder);
|
|
263
|
+
const rest = [];
|
|
264
|
+
for (;;) {
|
|
265
|
+
try {
|
|
266
|
+
existing = await realpath(existing);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
const parent = dirname(existing);
|
|
271
|
+
if (parent === existing)
|
|
272
|
+
break;
|
|
273
|
+
rest.unshift(basename(existing));
|
|
274
|
+
existing = parent;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const target = join(existing, ...rest);
|
|
278
|
+
const inside = relative(brain, target);
|
|
279
|
+
if (!isAbsolute(inside) && inside !== ".." && !inside.startsWith(`..${sep}`)) {
|
|
280
|
+
throw new BrainError("OUTSIDE_ROOT", `${folder} is inside the brain; a schedule writes nothing there`);
|
|
281
|
+
}
|
|
282
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -8,6 +8,7 @@ import { describeErrors, loadSchemas } from "../lib/schemas.js";
|
|
|
8
8
|
import { isRecord } from "../lib/input.js";
|
|
9
9
|
import { databaseId, modulesOf } from "../lib/modules.js";
|
|
10
10
|
import { expandPath, linkedRepos, workTreeTop } from "../lib/repos.js";
|
|
11
|
+
import { unattendedRuns } from "../lib/runs.js";
|
|
11
12
|
const NOTE_TYPES = ["area", "project", "person", "org", "meeting", "decision", "question", "concept", "source", "view", "goal", "memory", "reference"];
|
|
12
13
|
const PROPOSAL_STATUSES = ["pending", "approved", "edited", "rejected", "applied", "closed"];
|
|
13
14
|
/** `brain status` (spec/cli.md §5.2). */
|
|
@@ -64,6 +65,7 @@ export const statusCommand = {
|
|
|
64
65
|
notion,
|
|
65
66
|
modules: modulesOf(brain.config),
|
|
66
67
|
settings,
|
|
68
|
+
unattended: await unattendedRuns(brain.root),
|
|
67
69
|
repos,
|
|
68
70
|
index_stale: currentIndex !== index,
|
|
69
71
|
views_stale: viewStale,
|
|
@@ -83,8 +85,13 @@ function readNotion(config) {
|
|
|
83
85
|
return { configured, missing, tasks_database: typeof tasks.database === "string" && tasks.database !== "" };
|
|
84
86
|
}
|
|
85
87
|
function readSettings(config) {
|
|
86
|
-
const settings = config.settings;
|
|
87
|
-
|
|
88
|
+
const settings = isRecord(config.settings) ? config.settings : {};
|
|
89
|
+
const unattended = isRecord(settings.unattended) ? settings.unattended : {};
|
|
90
|
+
return {
|
|
91
|
+
publish: settings.publish === "auto" ? "auto" : "ask",
|
|
92
|
+
push: settings.push === "remind" ? "remind" : "auto",
|
|
93
|
+
unattended: { max_inputs: typeof unattended.max_inputs === "number" ? unattended.max_inputs : 10 },
|
|
94
|
+
};
|
|
88
95
|
}
|
|
89
96
|
async function findLastLogLine(root) {
|
|
90
97
|
let entries;
|
package/dist/commands/sync.js
CHANGED
|
@@ -10,13 +10,13 @@ export const syncPlanCommand = {
|
|
|
10
10
|
throw new BrainError("USAGE", "sync plan needs --input"); const workspace = await Workspace.open(brain); const plan = buildPlan(workspace, parseSnapshot(await readJsonInput(input, "sync plan"))); return { data: plan, human: `${plan.create.length} creates, ${plan.updates.length} updates, ${plan.corrections.length} corrections\n` }; },
|
|
11
11
|
};
|
|
12
12
|
export const syncApplyCommand = {
|
|
13
|
-
name: "sync apply", usage: "brain sync apply --input <plan|-> [--record-only] [--dry-run] [--json]", summary: "Apply a sync plan using atomic per-item writes.", options: { input: "string", "record-only": "boolean", "dry-run": "boolean" }, maxPositionals: 0, needsBrain: true,
|
|
13
|
+
name: "sync apply", usage: "brain sync apply --input <plan|-> [--record-only] [--skip-conflicted] [--dry-run] [--json]", summary: "Apply a sync plan using atomic per-item writes.", options: { input: "string", "record-only": "boolean", "skip-conflicted": "boolean", "dry-run": "boolean" }, maxPositionals: 0, needsBrain: true,
|
|
14
14
|
async run({ args, brain, now }) { const input = args.options.input; if (input === undefined)
|
|
15
|
-
throw new BrainError("USAGE", "sync apply needs --input"); const workspace = await Workspace.open(brain); const data = applyPlan(workspace, parsePlan(await readJsonInput(input, "sync apply")), now, args.options["record-only"] === true); const warnings = lintWarnings(workspace.warnings()); const dryRun = args.options["dry-run"] === true; if (!dryRun)
|
|
15
|
+
throw new BrainError("USAGE", "sync apply needs --input"); const workspace = await Workspace.open(brain); const data = applyPlan(workspace, parsePlan(await readJsonInput(input, "sync apply")), now, args.options["record-only"] === true, args.options["skip-conflicted"] === true); const warnings = lintWarnings(workspace.warnings()); const dryRun = args.options["dry-run"] === true; if (!dryRun)
|
|
16
16
|
await workspace.commit(data.log, now); const { log: _log, ...output } = data; const failed = Number(data.summary.failed ?? 0); return { data: { dry_run: dryRun, ...output }, warnings, exitCode: failed > 0 ? 1 : 0, human: `${failed} failed items\n` }; },
|
|
17
17
|
};
|
|
18
18
|
export const syncRenderCommand = {
|
|
19
|
-
name: "sync render", usage: "brain sync render (--changed | --ids <id>[,<id>…]) [--plan <plan.json>] [--json]", summary: "Render Notion payloads without writing.", options: { changed: "boolean", ids: "string", plan: "string" }, maxPositionals: 0, needsBrain: true,
|
|
19
|
+
name: "sync render", usage: "brain sync render (--changed | --ids <id>[,<id>…]) [--plan <plan.json> [--skip-conflicted]] [--json]", summary: "Render Notion payloads without writing.", options: { changed: "boolean", ids: "string", plan: "string", "skip-conflicted": "boolean" }, maxPositionals: 0, needsBrain: true,
|
|
20
20
|
async run({ args, brain, now }) {
|
|
21
21
|
const changed = args.options.changed === true;
|
|
22
22
|
const idsText = args.options.ids;
|
|
@@ -30,15 +30,40 @@ export const syncRenderCommand = {
|
|
|
30
30
|
throw new BrainError("UNKNOWN_ID", `No note with id ${id}`);
|
|
31
31
|
const held = new Map();
|
|
32
32
|
const planPath = args.options.plan;
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const skipConflicted = args.options["skip-conflicted"] === true;
|
|
34
|
+
if (skipConflicted && planPath === undefined)
|
|
35
|
+
throw new BrainError("USAGE", "--skip-conflicted needs --plan");
|
|
36
|
+
const plan = planPath === undefined ? undefined : parsePlan(await readJsonInput(planPath, "sync render"));
|
|
37
|
+
if (plan !== undefined)
|
|
38
|
+
for (const item of plan.conflicts)
|
|
35
39
|
if (item.code === "BOTH_CHANGED" && typeof item.brain_id === "string" && typeof item.property === "string") {
|
|
36
40
|
const set = held.get(item.brain_id) ?? new Set();
|
|
37
41
|
set.add(item.property);
|
|
38
42
|
held.set(item.brain_id, set);
|
|
39
43
|
}
|
|
40
44
|
const data = render(workspace, now, ids, held);
|
|
41
|
-
|
|
45
|
+
// --skip-conflicted (cli §5.14.4, 0051): a conflicted row, and a report with a What to fix nobody has read, are held back whole.
|
|
46
|
+
const withheld = new Map();
|
|
47
|
+
const conflictedPages = new Set();
|
|
48
|
+
if (skipConflicted && plan !== undefined) {
|
|
49
|
+
for (const item of plan.conflicts) {
|
|
50
|
+
if (typeof item.brain_id === "string")
|
|
51
|
+
withheld.set(item.brain_id, "ROW_CONFLICTED");
|
|
52
|
+
conflictedPages.add(String(item.page_id));
|
|
53
|
+
}
|
|
54
|
+
for (const item of plan.report_fixes)
|
|
55
|
+
if (!withheld.has(item.report))
|
|
56
|
+
withheld.set(item.report, "FIX_WAITING");
|
|
57
|
+
}
|
|
58
|
+
const skipped = [];
|
|
59
|
+
const items = data.items.filter((item) => {
|
|
60
|
+
const reason = withheld.get(item.id) ?? (item.page_id !== null && conflictedPages.has(item.page_id) ? "ROW_CONFLICTED" : undefined);
|
|
61
|
+
if (reason === undefined)
|
|
62
|
+
return true;
|
|
63
|
+
skipped.push({ id: item.id, reason });
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
return { data: { items, errors: data.errors, skipped }, warnings: data.warnings, exitCode: data.errors.length > 0 ? 1 : 0, human: `${items.length} payloads\n` };
|
|
42
67
|
},
|
|
43
68
|
};
|
|
44
69
|
export const syncTasksCommand = {
|
package/dist/lib/errors.js
CHANGED
package/dist/lib/lint.js
CHANGED
|
@@ -304,6 +304,15 @@ function deferredPair(note, other, id) {
|
|
|
304
304
|
const excluded = (proposal) => list(record(proposal.attributes.decision).exclude).some((entry) => record(entry).note === id);
|
|
305
305
|
return (note.attributes.deferred_from === other.id && excluded(other)) || (other.attributes.deferred_from === note.id && excluded(note));
|
|
306
306
|
}
|
|
307
|
+
/** Every note id a note's frontmatter references, by the fields lint's `missing-ref` checks, plus `relations` targets and `timeline` sources (spec/cli.md §5.14.3). */
|
|
308
|
+
export function referencedIds(attributes) {
|
|
309
|
+
const values = Object.entries(attributes).filter(([key]) => REFERENCE_KEYS.has(key)).map(([, value]) => value);
|
|
310
|
+
for (const relation of list(attributes.relations))
|
|
311
|
+
values.push(record(relation).target);
|
|
312
|
+
for (const entry of list(attributes.timeline))
|
|
313
|
+
values.push(record(entry).source);
|
|
314
|
+
return values.flatMap((value) => collectIds(value));
|
|
315
|
+
}
|
|
307
316
|
function collectIds(value, out = []) {
|
|
308
317
|
if (typeof value === "string") {
|
|
309
318
|
if (ID.test(value))
|
package/dist/lib/log.js
CHANGED
|
@@ -28,6 +28,55 @@ export async function appendLog(root, now, entries) {
|
|
|
28
28
|
await appendFile(target, entries.map((entry) => `${formatLogLine(now, entry)}\n`).join(""));
|
|
29
29
|
return path;
|
|
30
30
|
}
|
|
31
|
+
/** Read one line written by formatLogLine (spec/cli.md §3.9); null for anything else. */
|
|
32
|
+
export function parseLogLine(line) {
|
|
33
|
+
const head = /^- (\S+) ([a-z][a-z0-9-]*) (\S+) /.exec(line);
|
|
34
|
+
if (head === null)
|
|
35
|
+
return null;
|
|
36
|
+
let position = head[0].length;
|
|
37
|
+
const title = readQuoted(line, position);
|
|
38
|
+
if (title === null)
|
|
39
|
+
return null;
|
|
40
|
+
position = title.end;
|
|
41
|
+
const pairs = {};
|
|
42
|
+
while (position < line.length) {
|
|
43
|
+
const key = /^ ([^\s=]+)=/.exec(line.slice(position));
|
|
44
|
+
if (key === null)
|
|
45
|
+
return null;
|
|
46
|
+
position += key[0].length;
|
|
47
|
+
if (line[position] === '"') {
|
|
48
|
+
const value = readQuoted(line, position);
|
|
49
|
+
if (value === null)
|
|
50
|
+
return null;
|
|
51
|
+
pairs[key[1]] = value.text;
|
|
52
|
+
position = value.end;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const end = line.indexOf(" ", position);
|
|
56
|
+
pairs[key[1]] = line.slice(position, end === -1 ? line.length : end);
|
|
57
|
+
position = end === -1 ? line.length : end;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { timestamp: head[1], op: head[2], id: head[3], title: title.text, pairs };
|
|
61
|
+
}
|
|
62
|
+
function readQuoted(line, start) {
|
|
63
|
+
if (line[start] !== '"')
|
|
64
|
+
return null;
|
|
65
|
+
let text = "";
|
|
66
|
+
for (let index = start + 1; index < line.length; index += 1) {
|
|
67
|
+
const char = line[index];
|
|
68
|
+
if (char === '"')
|
|
69
|
+
return { text, end: index + 1 };
|
|
70
|
+
if (char === "\\") {
|
|
71
|
+
const next = line[index + 1];
|
|
72
|
+
text += next === "n" ? "\n" : next === "r" ? "\r" : (next ?? "");
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
text += char;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
31
80
|
function quote(value) {
|
|
32
81
|
const escaped = value
|
|
33
82
|
.replaceAll("\\", "\\\\")
|
package/dist/lib/runs.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseLogLine } from "./log.js";
|
|
4
|
+
/**
|
|
5
|
+
* The unattended catch-up runs since the last attended one, read from the log (spec/cli.md §3.9, §5.2; decision 0051).
|
|
6
|
+
* Counts that repeat from run to run (an input still skipped, a Fix still waiting) come from the latest run only.
|
|
7
|
+
*/
|
|
8
|
+
export async function unattendedRuns(root) {
|
|
9
|
+
const lines = await catchUpLines(root);
|
|
10
|
+
const unattended = (line) => line.pairs.mode === "unattended";
|
|
11
|
+
let lastAttended = -1;
|
|
12
|
+
lines.forEach((line, index) => { if (!unattended(line))
|
|
13
|
+
lastAttended = index; });
|
|
14
|
+
const since = lines.slice(lastAttended + 1);
|
|
15
|
+
const recent = lines.filter(unattended).slice(-3);
|
|
16
|
+
const backlog = recent.length === 3 && recent.every((line) => count(line, "left") > 0);
|
|
17
|
+
if (since.length === 0)
|
|
18
|
+
return { catch_up: null, backlog };
|
|
19
|
+
const latest = since.at(-1);
|
|
20
|
+
const failures = [];
|
|
21
|
+
for (const line of since) {
|
|
22
|
+
for (const reason of (line.pairs.failures ?? "").split(";").map((part) => part.trim()).filter(Boolean))
|
|
23
|
+
if (!failures.includes(reason))
|
|
24
|
+
failures.push(reason);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
catch_up: {
|
|
28
|
+
since: lastAttended === -1 ? null : lines[lastAttended].timestamp,
|
|
29
|
+
runs: since.length,
|
|
30
|
+
inputs: since.reduce((sum, line) => sum + count(line, "inputs"), 0),
|
|
31
|
+
failed: since.reduce((sum, line) => sum + count(line, "failed"), 0),
|
|
32
|
+
failures,
|
|
33
|
+
left: count(latest, "left"),
|
|
34
|
+
skipped: count(latest, "skipped"),
|
|
35
|
+
waiting: count(latest, "waiting"),
|
|
36
|
+
last: latest.timestamp,
|
|
37
|
+
},
|
|
38
|
+
backlog,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function count(line, key) {
|
|
42
|
+
const value = Number(line.pairs[key]);
|
|
43
|
+
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
44
|
+
}
|
|
45
|
+
/** Every `catch-up` line, oldest first, across the month files. An unreadable file is passed over: status is diagnostic. */
|
|
46
|
+
async function catchUpLines(root) {
|
|
47
|
+
let files;
|
|
48
|
+
try {
|
|
49
|
+
files = (await readdir(join(root, "log"))).filter((entry) => /^\d{4}-\d{2}\.md$/.test(entry)).sort();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = await readFile(join(root, "log", file), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const raw of text.split("\n")) {
|
|
64
|
+
const line = parseLogLine(raw);
|
|
65
|
+
if (line !== null && line.op === "catch-up")
|
|
66
|
+
lines.push(line);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
package/dist/lib/sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
|
7
7
|
import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
|
|
8
8
|
import { compareCodePoints } from "./notes.js";
|
|
9
9
|
import { splitParts } from "./parts.js";
|
|
10
|
+
import { referencedIds } from "./lint.js";
|
|
10
11
|
import { loadSchemas } from "./schemas.js";
|
|
11
12
|
import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
|
|
12
13
|
import { lineDiff } from "./diff.js";
|
|
@@ -368,6 +369,15 @@ function planInbox(workspace, row, out) {
|
|
|
368
369
|
if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
|
|
369
370
|
out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
|
|
370
371
|
}
|
|
372
|
+
// An unattended run's Report row has no Brain ID and holds no report (cli §5.14.2, §6.1).
|
|
373
|
+
if (typeof brainId !== "string" || brainId === "") {
|
|
374
|
+
const reason = modules.reports ? "NOT_A_REPORT" : "MODULE_OFF";
|
|
375
|
+
if (property(row.properties, "Refresh asked") === true || property(row.properties, "Refresh") === true)
|
|
376
|
+
out.ignored.push(ignoredRow(row, reason, "Refresh asked"));
|
|
377
|
+
const text = property(row.properties, "What to fix");
|
|
378
|
+
if (typeof text === "string" && text.trim() !== "")
|
|
379
|
+
out.ignored.push(ignoredRow(row, reason, "What to fix"));
|
|
380
|
+
}
|
|
371
381
|
if (property(row.properties, "Document asked") !== true)
|
|
372
382
|
return;
|
|
373
383
|
if (!modules.documents) {
|
|
@@ -570,17 +580,61 @@ export function parsePlan(input) {
|
|
|
570
580
|
assertNoProblems(problems);
|
|
571
581
|
return { ...p, document_requests: p.document_requests ?? [], promote_requests: p.promote_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
|
|
572
582
|
}
|
|
573
|
-
export function applyPlan(workspace,
|
|
583
|
+
export function applyPlan(workspace, input, now, recordOnly, skipConflicted = false) {
|
|
574
584
|
const create = [];
|
|
575
585
|
const updates = [];
|
|
576
586
|
const dependencyFailures = new Set();
|
|
587
|
+
// --skip-conflicted (cli §5.14.3, 0051): a row with a conflict is skipped whole, and so is a create that depends on a skipped one.
|
|
588
|
+
const conflictedIds = new Set(skipConflicted ? input.conflicts.map((item) => item.brain_id).filter((id) => typeof id === "string") : []);
|
|
589
|
+
const conflictedPages = new Set(skipConflicted ? input.conflicts.map((item) => item.page_id) : []);
|
|
590
|
+
// The skip closure is computed before any create runs, so a dependent create is skipped whatever its place in the plan.
|
|
591
|
+
// It is by position in the plan, so a create that names no id of its own (cli §5.5) is covered too; only an id can be referred to.
|
|
592
|
+
const skippedAt = new Set();
|
|
593
|
+
if (skipConflicted) {
|
|
594
|
+
const skippedIds = new Set();
|
|
595
|
+
const skip = (index) => { skippedAt.add(index); const id = input.create[index].id; if (id !== undefined)
|
|
596
|
+
skippedIds.add(id); };
|
|
597
|
+
input.create.forEach((item, index) => { const page = isRecord(item.frontmatter) ? item.frontmatter.notion : undefined; if ((item.id !== undefined && conflictedIds.has(item.id)) || (typeof page === "string" && conflictedPages.has(page)))
|
|
598
|
+
skip(index); });
|
|
599
|
+
for (let grew = true; grew;) {
|
|
600
|
+
grew = false;
|
|
601
|
+
input.create.forEach((item, index) => { if (!skippedAt.has(index) && relationsOf(item.frontmatter).some((id) => skippedIds.has(id))) {
|
|
602
|
+
skip(index);
|
|
603
|
+
grew = true;
|
|
604
|
+
} });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const rowSkipped = { result: "skipped", reason: "ROW_CONFLICTED", changed: [] };
|
|
608
|
+
const plan = { ...input, updates: [], corrections: [], decisions: [] };
|
|
609
|
+
const skippedUpdates = [];
|
|
610
|
+
const skippedCorrections = [];
|
|
611
|
+
const skippedDecisions = [];
|
|
612
|
+
for (const update of input.updates)
|
|
613
|
+
if (conflictedIds.has(String(update.id)))
|
|
614
|
+
skippedUpdates.push({ id: update.id, ...rowSkipped });
|
|
615
|
+
else
|
|
616
|
+
plan.updates.push(update);
|
|
617
|
+
for (const correction of input.corrections)
|
|
618
|
+
if (conflictedIds.has(correction.target))
|
|
619
|
+
skippedCorrections.push({ target: correction.target, field: correction.field, ...rowSkipped });
|
|
620
|
+
else
|
|
621
|
+
plan.corrections.push(correction);
|
|
622
|
+
for (const decision of input.decisions)
|
|
623
|
+
if (conflictedIds.has(decision.proposal))
|
|
624
|
+
skippedDecisions.push({ proposal: decision.proposal, action: decision.action, ...rowSkipped });
|
|
625
|
+
else
|
|
626
|
+
plan.decisions.push(decision);
|
|
577
627
|
for (let index = 0; index < plan.create.length; index += 1) {
|
|
578
628
|
const item = plan.create[index];
|
|
579
629
|
const checkpoint = workspace.checkpoint();
|
|
630
|
+
if (skippedAt.has(index)) {
|
|
631
|
+
create.push({ id: item.id, ...rowSkipped });
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
580
634
|
try {
|
|
581
635
|
if (item.id !== undefined && workspace.get(item.id) !== undefined)
|
|
582
636
|
throw new Error("ID_IN_USE");
|
|
583
|
-
const depends =
|
|
637
|
+
const depends = relationsOf(item.frontmatter).some((id) => dependencyFailures.has(id));
|
|
584
638
|
if (depends)
|
|
585
639
|
throw new Error("DEPENDENCY_FAILED");
|
|
586
640
|
const staged = stageNotes(workspace, [item], () => `/create/${index}`, { now, lifecycle: String(item.lifecycle ?? "draft") });
|
|
@@ -640,11 +694,13 @@ export function applyPlan(workspace, plan, now, recordOnly) {
|
|
|
640
694
|
...updates.filter((item) => item.result === "applied").map((item) => ({ op: "sync-update", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
|
|
641
695
|
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
642
696
|
];
|
|
643
|
-
|
|
697
|
+
updates.push(...skippedUpdates);
|
|
698
|
+
const correctionsOut = [...corrections, ...skippedCorrections];
|
|
699
|
+
const decisionsOut = [...decisionResult, ...skippedDecisions];
|
|
700
|
+
return { create, updates, corrections: correctionsOut, decisions: decisionsOut, document_requests: plan.document_requests, promote_requests: plan.promote_requests, refresh_requests: plan.refresh_requests, report_fixes: plan.report_fixes, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...correctionsOut, ...decisionsOut].map((item) => ({ ...item }))), log };
|
|
644
701
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem)-/.test(value) ? [value] : []; }
|
|
702
|
+
/** The ids a create references (cli §5.14.3): free text such as a summary never counts. */
|
|
703
|
+
function relationsOf(frontmatter) { return isRecord(frontmatter) ? referencedIds(frontmatter) : []; }
|
|
648
704
|
function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
|
|
649
705
|
if (typeof item.result === "string" && Object.hasOwn(result, item.result))
|
|
650
706
|
result[item.result] += 1; return result; }
|
package/package.json
CHANGED
|
@@ -61,6 +61,19 @@
|
|
|
61
61
|
"remind"
|
|
62
62
|
],
|
|
63
63
|
"default": "auto"
|
|
64
|
+
},
|
|
65
|
+
"unattended": {
|
|
66
|
+
"description": "Unattended catch-up (decision 0051, spec/schema.md §12, cli.md §6.1).",
|
|
67
|
+
"type": "object",
|
|
68
|
+
"additionalProperties": false,
|
|
69
|
+
"properties": {
|
|
70
|
+
"max_inputs": {
|
|
71
|
+
"description": "Inputs one unattended run takes in, oldest first.",
|
|
72
|
+
"type": "integer",
|
|
73
|
+
"minimum": 1,
|
|
74
|
+
"default": 10
|
|
75
|
+
}
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
}
|
|
66
79
|
},
|
|
@@ -49,7 +49,7 @@ Each operation has a skill; follow it end to end:
|
|
|
49
49
|
7. **Dated, not "current".** Write facts as timeless, dated ("as of 2026-12-03"), or as pointers ("status: see Notion"). Volatile status (task state, project status, priority) lives in Notion.
|
|
50
50
|
8. **Cite canonical notes.** `index.md` and the views are navigation; cite the note or source they point to.
|
|
51
51
|
9. **Answering is read-only.** Answering the user's questions from the brain writes nothing; save an answer to the brain only when the user asks. A report the user asks for is stored as a dated view in `reports/` and published, never as knowledge: it is never ingested or cited as a source (decision 0043).
|
|
52
|
-
10. **Unattended runs add,
|
|
52
|
+
10. **Unattended runs add, and apply only what you decided word for word.** With no user present, an agent adds drafts and proposals and carries out decisions you already made (Approve, Reject, your edits in Notion); it never interprets a What to fix, a Fix or an "all except …", and never decides anything itself. Those wait for an attended run.
|
|
53
53
|
|
|
54
54
|
## Reading notes
|
|
55
55
|
|