geneseed 4.17.1 → 4.18.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/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/SHIPPED.md +1 -1
- package/docs/extending.md +1 -1
- package/docs/guides/upgrade.md +2 -2
- package/docs/web-ui.md +28 -7
- package/harness.config.json +1 -1
- package/js/README.md +1 -1
- package/js/inspect/diff.mjs +42 -9
- package/js/web/actions.mjs +61 -16
- package/js/web/api.mjs +15 -2
- package/js/web/catalog.mjs +17 -2
- package/js/web/docs.mjs +31 -11
- package/js/web/handler.mjs +83 -30
- package/js/web/jobs.mjs +7 -7
- package/js/web/server.mjs +4 -0
- package/js/web/user-files.mjs +24 -7
- package/package.json +1 -1
- package/web/dist/assets/Activity-D3_bQCUu.js +1 -0
- package/web/dist/assets/ActivityDetail-CF4t4Tfu.js +1 -0
- package/web/dist/assets/Dashboard-CLs-bR_v.js +3 -0
- package/web/dist/assets/Docs-DxUYipLM.js +4 -0
- package/web/dist/assets/GreenhouseView-DYYctWtr.js +1 -0
- package/web/dist/assets/Installs-sz2MgKXB.js +3 -0
- package/web/dist/assets/JournalView-DFvDimtU.js +1 -0
- package/web/dist/assets/KitApply-BllCOBIp.js +1 -0
- package/web/dist/assets/Kits-Bkujcmd1.js +1 -0
- package/web/dist/assets/Laws-B2cyrICK.js +3 -0
- package/web/dist/assets/Library-CsVwkY-2.js +1 -0
- package/web/dist/assets/Loops-Dj50x8qB.js +2 -0
- package/web/dist/assets/Markdown-OReRRoEV.js +65 -0
- package/web/dist/assets/OperatorHudView-DcY3jDhO.js +1 -0
- package/web/dist/assets/Personal-RriJXVUS.js +1 -0
- package/web/dist/assets/RailCats-D5RVYEjy.js +1 -0
- package/web/dist/assets/{Tabs-RdL1pfIT.js → Tabs-u5wJjzu8.js} +1 -1
- package/web/dist/assets/drawable-CUtqWN8k.js +1 -0
- package/web/dist/assets/index-D7KB52Os.js +2 -0
- package/web/dist/assets/index-ngEyS9_E.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +56 -24
- package/web/dist/assets/Activity-DNVzYkIY.js +0 -1
- package/web/dist/assets/ActivityDetail-CMBXjmTo.js +0 -1
- package/web/dist/assets/Dashboard-DFY_FarE.js +0 -2
- package/web/dist/assets/Docs-BS0NIsA0.js +0 -5
- package/web/dist/assets/FilterInput-CZREvrjX.js +0 -1
- package/web/dist/assets/GreenhouseView-M3wWndqG.js +0 -2
- package/web/dist/assets/Installs-CvRxZcZc.js +0 -3
- package/web/dist/assets/JournalView-BSD-1Z83.js +0 -1
- package/web/dist/assets/KitApply-_4Ug9pMF.js +0 -1
- package/web/dist/assets/Kits-c5mVesJ9.js +0 -1
- package/web/dist/assets/Laws-Bpn4ZBzL.js +0 -3
- package/web/dist/assets/Library-DWgfAfkV.js +0 -1
- package/web/dist/assets/Loops-BqP3c0c-.js +0 -2
- package/web/dist/assets/Markdown-zaXXXHz9.js +0 -65
- package/web/dist/assets/OperatorHudView-DLEGd63S.js +0 -2
- package/web/dist/assets/Personal-B_LeqnDL.js +0 -1
- package/web/dist/assets/drawable-inwWR3uw.js +0 -1
- package/web/dist/assets/index-BAArp8rX.js +0 -2
- package/web/dist/assets/index-jYlWihN-.css +0 -1
- package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.18.0] — 2026-10-24
|
|
12
|
+
|
|
13
|
+
Minor: the web console's numbers tell the truth (stale vs edited drift, lenses, Constitution, Docs) and it keeps your work (error boundary, draft guard, shared staging).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Drift numbers tell the truth.** A file that differs from the source only because the install is behind (unmodified since its build) is now `stale`, not `edited`; `.geneseed-version` is always a build stamp. The Overview counts only real hand edits and shows stale files as "N files behind"; Local edits lists them apart under **Behind source** with a rebuild banner; `diff --out` and the upgrade export include only real local changes. "Never fired" counts each skill once across the fleet. A failed fleet read says so and keeps the last good table; a zero ceiling prints "no ceiling"; the ceiling marker sits on the root file's own lane.
|
|
17
|
+
- **The four Overview lenses agree with the Ledger.** They show the newest runs first, carry the fleet verdict badge, and read runs as `verb · scope · result · duration`. The Greenhouse ring is full at 100%, the Lineage badge no longer hard-codes "in sync", a failed doctor says so instead of drawing a false "1 checks", the HUD KPIs sit on one row, and the Journal map keeps its height.
|
|
18
|
+
- **Constitution ranks tiers by the real precedence** (Dictates, your rules, Credo, Doctrines) and renders rule bodies as Markdown. Rule texts are cached and a failed read offers Retry; a partly-on pack reads as mixed; a disarmed gate is marked off; tier names follow the voice.
|
|
19
|
+
- **Docs pages render their tokens.** The Memory and Notebook pages no longer show raw `{{…}}` build tokens. Heading ids come from one pass, so every outline link lands; wide tables scroll; Back no longer bounces off a hidden page; blocked storage no longer crashes the page; the Docs page no longer scrolls past the shell.
|
|
20
|
+
- **The console survives a page crash.** A page error or a missing chunk after an upgrade shows the error with Retry (or Restart) while the sidebar, topbar and console stay. A malformed `#/item/…` link no longer blanks the app; Skip to content works; each page change sets the title, scrolls to the top and moves focus to the page.
|
|
21
|
+
- **Unsaved work is kept.** Profile and Rules keep a draft through a 409 conflict and ask before an in-app navigation discards it; a draft whose rule was removed reopens as a new rule. Staged kit and doctrine switches are held for the whole console, survive leaving the page, and are kept after a failed rebuild; a row shows "Rebuilding…" while its job runs.
|
|
22
|
+
- **Slow answers no longer overwrite newer ones** in the console's data hook, and a failed first read shows an error instead of "Loading…" forever (Library entry, Activity session).
|
|
23
|
+
|
|
24
|
+
## [4.17.2] — 2026-10-24
|
|
25
|
+
|
|
26
|
+
Patch: the web console closes its security and data-loss holes from the 2026-10-24 audit.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
- **Restore acts only on what the diff offers.** `POST /api/actions/restore` trusted the client's file list: one call could reset `user-rules.md`, `PROFILE.md` or a memory file to its seed, or delete a file the diff never listed. It now restores only the paths `/api/diff` offers, computed on the server. It never touches the files written once and then left to you (`user-rules.md`, `PROFILE.md`, `memory/`, `notebook/`). An added file is moved to `archived-restore/<stamp>/` in the install instead of being deleted.
|
|
30
|
+
- **The web console cannot be framed or driven from another site.** Every response carries a Content-Security-Policy with `frame-ancestors 'none'` and a per-response nonce on the token script, plus `X-Frame-Options: DENY`, `nosniff` and `Referrer-Policy: no-referrer`. One request guard checks the exact Host and port, refuses a cross-site `/api` request (`Sec-Fetch-Site`, foreign `Origin`), and compares the token in constant time. A repeated `GET /api/doctor?fresh=1` reuses a run less than 3 s old. The server sets request and header timeouts.
|
|
31
|
+
- **The markdown sanitizer drops SVG animation elements, `template`, raw-text elements and `style` attributes.** These closed a `javascript:` link set through `<set>`/`<animate>`, a full-page overlay drawn with `style`, and a mutation XSS through `xmp`, `noembed`, `noframes`, `noscript` or `plaintext`.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **Settings › Maintenance no longer offers an Uninstall.** It ran `geneseed uninstall --yes` with no target, so it always removed the OpenCode global install, whichever install you were viewing. Each install keeps its own Uninstall in Installs › Hosts.
|
|
35
|
+
- **Editing a rule keeps its scope.** A rule written `scope: global` was saved back as `project` on every Edit or Graduate. A scope the editor does not list is now kept as written. A rule body with a line that reads as a rule heading (`## …`) is refused with a 400, because it could create a second rule with a forged id. So is a `|` in the scope or source.
|
|
36
|
+
|
|
11
37
|
## [4.17.1] — 2026-10-23
|
|
12
38
|
|
|
13
39
|
Patch: the web console's Update button works on a global npm install and re-checks for updates while the page is open.
|
package/README.md
CHANGED
|
@@ -334,7 +334,7 @@ geneseed rebuild-all # re-render every registered install in its own theme + m
|
|
|
334
334
|
./geneseed upgrade # just the content refresh (remembers theme + emit mode)
|
|
335
335
|
```
|
|
336
336
|
|
|
337
|
-
**Local edits survive.** The self-improvement loops write to notes under `notebook/`, which no rebuild touches. If you or the agent edited deployed agent/skill files in place anyway, an upgrade exports
|
|
337
|
+
**Local edits survive.** The self-improvement loops write to notes under `notebook/`, which no rebuild touches. If you or the agent edited deployed agent/skill files in place anyway, an upgrade exports those edits to an `improvements/` file inside the install; the rebuild then keeps each edited file instead of overwriting it. Details: [Upgrade](docs/guides/upgrade.md).
|
|
338
338
|
|
|
339
339
|
## 📚 Documentation
|
|
340
340
|
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.
|
|
23
|
+
> string. Current label: **4.18.0**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
package/docs/extending.md
CHANGED
|
@@ -125,7 +125,7 @@ Notes that matter:
|
|
|
125
125
|
|
|
126
126
|
### The gates CI does not run
|
|
127
127
|
|
|
128
|
-
- **The mutation matrix itself.** `tests/mutate.mjs` —
|
|
128
|
+
- **The mutation matrix itself.** `tests/mutate.mjs` — 34 rows, each a one-string edit to a
|
|
129
129
|
product file with a written argument for which gate should catch it. The full run is **in no
|
|
130
130
|
workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
|
|
131
131
|
Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
|
package/docs/guides/upgrade.md
CHANGED
|
@@ -64,7 +64,7 @@ A rebuild only replaces files the install's manifest says Geneseed owns. It leav
|
|
|
64
64
|
|
|
65
65
|
## Local edits, and the improvements file
|
|
66
66
|
|
|
67
|
-
The agent records what it learns about a skill or agent in notes under `notebook/skill-notes/` and `notebook/agent-notes/`, which rebuilds never touch. Edits made to the deployed agent and skill files themselves, by you or by an older harness, are another matter: a rebuild would overwrite those, so **setup, re-theme and upgrade export them first**. When
|
|
67
|
+
The agent records what it learns about a skill or agent in notes under `notebook/skill-notes/` and `notebook/agent-notes/`, which rebuilds never touch. Edits made to the deployed agent and skill files themselves, by you or by an older harness, are another matter: a rebuild would overwrite those, so **setup, re-theme and upgrade export them first**. When deployed files carry local edits (files only behind the source are not edits and are not exported), the differences are written to a markdown **improvements file** in `improvements/`, inside the install's own folder (for the OpenCode global install, `~/.config/opencode/improvements/`). The manifest does not list that folder, so rebuilds never overwrite it and uninstall never removes it.
|
|
68
68
|
|
|
69
69
|
A rebuild also refuses to overwrite a file you edited. The manifest records a fingerprint of every file Geneseed wrote; when a file no longer matches it, the rebuild leaves it alone and prints `kept your edited <file> — delete it to get Geneseed's copy back`. From then on the file is yours and later rebuilds skip it. A file the new version no longer ships is kept the same way, with `(no longer shipped)`. Line-ending changes from a git checkout do not count as edits. Installs made before fingerprints existed are overwritten as before.
|
|
70
70
|
|
|
@@ -76,7 +76,7 @@ geneseed diff --full # with line-level diffs
|
|
|
76
76
|
geneseed diff --out improvements.md # write the improvements file
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
`diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. A file you replaced with a link to your own copy (a skill folder junctioned or symlinked elsewhere, say) is listed as `linked`: Geneseed never writes through a link, so it is yours and not drift, and it stays out of the improvements file. `diff` also lists every non-empty self-improvement note, and `--out` copies them into the improvements file; the upgrade export does not fire on notes alone, since a rebuild cannot lose them. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
|
|
79
|
+
`diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. A file you replaced with a link to your own copy (a skill folder junctioned or symlinked elsewhere, say) is listed as `linked`: Geneseed never writes through a link, so it is yours and not drift, and it stays out of the improvements file. A file unmodified since its build that differs only because the source moved on is listed as behind source; a rebuild updates it, and it stays out of the improvements file too. `diff` also lists every non-empty self-improvement note, and `--out` copies them into the improvements file; the upgrade export does not fire on notes alone, since a rebuild cannot lose them. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
|
|
80
80
|
|
|
81
81
|
## Moving from a clone to npm
|
|
82
82
|
|
package/docs/web-ui.md
CHANGED
|
@@ -44,6 +44,15 @@ so other sites can't trigger builds, deletes, or shutdown. Mutating actions (bui
|
|
|
44
44
|
update, export, restore, memory-delete, uninstall) run in the background and report back
|
|
45
45
|
as toasts (fire-and-notify). Read-only browsing needs no token.
|
|
46
46
|
|
|
47
|
+
Every request passes one guard first: the `Host` must be `127.0.0.1`, `localhost` or `[::1]`
|
|
48
|
+
**at the server's own port** (DNS rebinding, other local servers), `/api/*` refuses a
|
|
49
|
+
`Sec-Fetch-Site` of `cross-site`/`same-site` or a foreign `Origin` (so another site cannot read
|
|
50
|
+
the API either), and every write compares the token in constant time. Every response carries
|
|
51
|
+
`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and a
|
|
52
|
+
strict CSP (`frame-ancestors 'none'`, same-origin only, the one inline token script admitted by
|
|
53
|
+
a per-response nonce). `?fresh=1` on the doctor re-runs at most once per 3 s, and the server
|
|
54
|
+
drops a request whose headers take over 10 s or whose whole request takes over 30 s.
|
|
55
|
+
|
|
47
56
|
## The rail
|
|
48
57
|
|
|
49
58
|
The left rail mirrors the harness's own shape:
|
|
@@ -74,8 +83,11 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
74
83
|
- **Dashboard** (`#/`) — a live readout of what's deployed. Three lenses: **Status**
|
|
75
84
|
(readiness ring, a KPI band, a genome grid, recent activity), **Lineage** (the
|
|
76
85
|
source → render → deployed timeline and the genome-by-volume strand chart), and
|
|
77
|
-
**Operator** (a searchable table of every deployed capability).
|
|
78
|
-
|
|
86
|
+
**Operator** (a searchable table of every deployed capability). Every lens hero carries the
|
|
87
|
+
fleet verdict badge (the Ledger's `fleetVerdict`). The headline speaks the theme's voice only
|
|
88
|
+
while that verdict is good or still loading; a warn, bad or unreachable fleet reads plain
|
|
89
|
+
*Deployed*. Recent-run previews read `verb · scope · result · duration`, and the raw command
|
|
90
|
+
is a tooltip only.
|
|
79
91
|
|
|
80
92
|
The KPI band reads **Agents · Skills · Dictates · Local edits**. That third tile is
|
|
81
93
|
labelled *Dictates* (the name AGENT.md gives the laws) and not *Constitution* on purpose: its value is the law count
|
|
@@ -89,7 +101,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
89
101
|
|
|
90
102
|
- **Rules** (`#/rules`) — your own standing rules, read from and written to the
|
|
91
103
|
`user-rules.md` beside the deployed AGENT.md (the seed-once file no update ever
|
|
92
|
-
touches). List with scope (user/project
|
|
104
|
+
touches). List with scope (user/project, or any other scope written by hand, which a
|
|
105
|
+
save keeps as written) and trial chips, add/edit/retire forms (a body line starting
|
|
106
|
+
`## ` is refused: the file would read it as a new rule heading), a
|
|
93
107
|
**Graduate** action that adopts a trial rule for good, and a budget meter that turns
|
|
94
108
|
amber as the always-loaded set gets heavy. Every write carries the fingerprint of the
|
|
95
109
|
content the page last read — if an agent session edited the file meanwhile, the save
|
|
@@ -167,8 +181,12 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
167
181
|
- **Activity** (`#/activity`) — the agent sessions on this machine: each one's status,
|
|
168
182
|
the files it touched and its todo progress; open one for its detail.
|
|
169
183
|
- **Changes** (`#/diff`) — the diff between the deployed harness and the source: edited /
|
|
170
|
-
added / missing files
|
|
184
|
+
added / missing files, and files only behind the source (unedited; a rebuild updates them)
|
|
185
|
+
listed apart under **Behind source**, with per-file expansion and coloured unified-diff lines. Select
|
|
171
186
|
files to **export an `improvements.md`** back-port, or **restore** them to source.
|
|
187
|
+
Restore acts only on files the diff lists, and never on your own files (`user-rules.md`,
|
|
188
|
+
`PROFILE.md`, `memory/`, `notebook/`). An added file is moved into
|
|
189
|
+
`archived-restore/<date-time>/` in the install, not deleted.
|
|
172
190
|
- **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
|
|
173
191
|
theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
|
|
174
192
|
the server's last run — warmed at start, redone after a job, an install toggle or a view
|
|
@@ -201,14 +219,17 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
201
219
|
add or remove one here, the web mirror of `harness exclude add|remove|list`.
|
|
202
220
|
- **Kits** (`#/installs/kits`) — every install (disabled ones read-only) as a panel with one switch per kit.
|
|
203
221
|
Toggling only stages the set; **Apply** asks first, then rebuilds that install with
|
|
204
|
-
exactly that set as a console job.
|
|
222
|
+
exactly that set as a console job. Staged switches are held for the whole console: they
|
|
223
|
+
survive leaving the page, and the Kits page and Installs › Kits share them. A refetch drops a
|
|
224
|
+
row's staging only when that install's kits changed. A failed rebuild keeps it and says so,
|
|
225
|
+
and a reload asks first while anything is staged. Files you edited in a kit that is now off are kept,
|
|
205
226
|
and the row lists them.
|
|
206
227
|
- **Settings** (`#/settings`) — a **Console direction** picker (the visual flavour of the
|
|
207
228
|
console: Cultivar, Operator, Matrix or Atlas) with a **Dashboard layout** control (Auto follows each theme's designed Status
|
|
208
229
|
lens; Cultivar / Greenhouse / Operator / Journal force one regardless of skin), the
|
|
209
230
|
install snapshot (deployed/target/theme/version/memory store), a Maintenance card (PATH
|
|
210
|
-
link/unlink
|
|
211
|
-
**About** colophon — project + creator credits and the source link, folded in from the
|
|
231
|
+
link/unlink and Update; each install is uninstalled from its own panel in Hosts), an
|
|
232
|
+
offline package download, server controls (Stop), and the **About** colophon — project + creator credits and the source link, folded in from the
|
|
212
233
|
retired `#/about` tab (which still resolves here). Build, update, and MCP wiring live in
|
|
213
234
|
the Harness tab.
|
|
214
235
|
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.18.0"
|
|
6
6
|
}
|
package/js/README.md
CHANGED
|
@@ -235,7 +235,7 @@ the deployed harness as JSON.
|
|
|
235
235
|
|---|---|
|
|
236
236
|
| `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
|
|
237
237
|
| `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
|
|
238
|
-
| `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF
|
|
238
|
+
| `handler.mjs` | One function per request: dispatch, gzip, `requestGuard` (Host+port, Origin/Sec-Fetch-Site on `/api`, constant-time CSRF token) and body cap (before the body is read), `securityHeaders` (CSP nonce, framing), static SPA fallback |
|
|
239
239
|
| `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
|
|
240
240
|
| `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
|
|
241
241
|
| `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
trustOfDir,
|
|
55
55
|
readJsonMaybe, readMaybe, themeOfDir,
|
|
56
56
|
} from '../hosts/installs.mjs';
|
|
57
|
-
import { safeLeaf } from '../hosts/owned.mjs';
|
|
57
|
+
import { hashesOf, ownedState, safeLeaf } from '../hosts/owned.mjs';
|
|
58
58
|
import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
|
|
59
59
|
import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
|
|
60
60
|
import { toPlatformPath } from '../lib/paths.mjs';
|
|
@@ -86,8 +86,7 @@ export function cmpKey(rel, text) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** `_harness_diff._owned_set` — the files a global install claims, per its manifest. */
|
|
89
|
-
function ownedSet(d) {
|
|
90
|
-
const data = readJsonMaybe(path.join(d, GLOBAL_MANIFEST));
|
|
89
|
+
function ownedSet(d, data = readJsonMaybe(path.join(d, GLOBAL_MANIFEST))) {
|
|
91
90
|
if (data === null || typeof data !== 'object') return new Set();
|
|
92
91
|
const owned = data.owned;
|
|
93
92
|
return new Set(Array.isArray(owned) ? owned : []);
|
|
@@ -275,8 +274,17 @@ export function diffCollect({
|
|
|
275
274
|
? memoExpected(memo, host, axes, deployedDir, sourceFp)
|
|
276
275
|
: renderExpected(host, axes, deployedDir);
|
|
277
276
|
|
|
277
|
+
// The deployed manifest, read once: its `owned` and its `hashes` both classify below.
|
|
278
|
+
const manifest = readJsonMaybe(path.join(dir, GLOBAL_MANIFEST));
|
|
279
|
+
const deployedOwned = ownedSet(dir, manifest);
|
|
280
|
+
const hashes = hashesOf(manifest);
|
|
281
|
+
// STALE IS NOT AN EDIT. A file whose bytes still match the hash its emit recorded was never
|
|
282
|
+
// touched: it differs only because the source moved on, and the next rebuild overwrites it.
|
|
283
|
+
// Reporting it as "edited" told every install behind its source that it was hand-edited.
|
|
284
|
+
// Only a manifest with hashes can tell the two apart; a legacy one keeps the old reading.
|
|
285
|
+
const hashed = Object.keys(hashes).length > 0;
|
|
278
286
|
const files = [];
|
|
279
|
-
const rels = [...new Set([...
|
|
287
|
+
const rels = [...new Set([...deployedOwned, ...exp.owned])].sort();
|
|
280
288
|
for (const rel of rels) {
|
|
281
289
|
const a = path.join(dir, rel);
|
|
282
290
|
const tb = exp.texts.get(posixRel(rel));
|
|
@@ -289,7 +297,11 @@ export function diffCollect({
|
|
|
289
297
|
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
290
298
|
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
291
299
|
});
|
|
292
|
-
|
|
300
|
+
// `.geneseed-version` is a build stamp: a different fingerprint in it is the install
|
|
301
|
+
// being behind, never an edit, with or without hashes.
|
|
302
|
+
const stale = rel === VERSION_MARKER
|
|
303
|
+
|| (hashed && ownedState(dir, rel, hashes) === 'unmodified');
|
|
304
|
+
rec = { rel, status: stale ? 'stale' : 'edited', diff };
|
|
293
305
|
}
|
|
294
306
|
} else if (isFile(a)) {
|
|
295
307
|
const body = splitLines(readText(a));
|
|
@@ -298,6 +310,9 @@ export function diffCollect({
|
|
|
298
310
|
status: 'added',
|
|
299
311
|
diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
|
|
300
312
|
};
|
|
313
|
+
} else if (hashed && !deployedOwned.has(rel)) {
|
|
314
|
+
// In source and never in this install's manifest: new since its emit, not deleted.
|
|
315
|
+
rec = { rel, status: 'stale', diff: ['(new in source, not yet deployed — a rebuild adds it)'] };
|
|
301
316
|
} else {
|
|
302
317
|
rec = { rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'] };
|
|
303
318
|
}
|
|
@@ -318,9 +333,20 @@ export function diffCollect({
|
|
|
318
333
|
return { target: dir, theme: themeName, files };
|
|
319
334
|
}
|
|
320
335
|
|
|
321
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* The records that are drift: everything but a `linked` path, which is the user's own. A
|
|
338
|
+
* `stale` file stays in: `apiRestore` offers exactly this set, and restoring a stale file is
|
|
339
|
+
* the same as rebuilding it.
|
|
340
|
+
*/
|
|
322
341
|
export const drifted = (files) => files.filter((f) => f.status !== 'linked');
|
|
323
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The records that are LOCAL CHANGES: drift minus `stale`. A stale file has nothing to
|
|
345
|
+
* back-port (a rebuild updates it), so the improvements report and the export `setup` and
|
|
346
|
+
* `update` fire read this set, not `drifted`.
|
|
347
|
+
*/
|
|
348
|
+
export const localChanges = (files) => drifted(files).filter((f) => f.status !== 'stale');
|
|
349
|
+
|
|
324
350
|
/** Where the self-improvement loops write, relative to an install's `notebook/`. */
|
|
325
351
|
export const NOTES_DIRS = ['skill-notes', 'agent-notes'];
|
|
326
352
|
|
|
@@ -354,7 +380,8 @@ export function collectNotes(dir) {
|
|
|
354
380
|
/** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
|
|
355
381
|
export function improvementsMd(target, theme, allFiles, when, notes = []) {
|
|
356
382
|
// A linked path is never a back-port item: the file is the user's, not a local edit of ours.
|
|
357
|
-
|
|
383
|
+
// Nor is a stale one: the source already has what it lacks.
|
|
384
|
+
const files = localChanges(allFiles);
|
|
358
385
|
const edited = files.filter((f) => f.status === 'edited');
|
|
359
386
|
const added = files.filter((f) => f.status === 'added');
|
|
360
387
|
const missing = files.filter((f) => f.status === 'missing');
|
|
@@ -458,7 +485,10 @@ export function exportImprovements(target = null, theme = null, outPath = null)
|
|
|
458
485
|
// Python's `if not files` — null AND the empty list both return, which is why the caller
|
|
459
486
|
// gets `files` back rather than a bare path and can tell the two apart itself.
|
|
460
487
|
// A linked path alone is not drift, or every update would export an empty report.
|
|
461
|
-
|
|
488
|
+
// Nor are stale files alone, or every update over an older install would export one.
|
|
489
|
+
if (!collected.files || localChanges(collected.files).length === 0) {
|
|
490
|
+
return [null, collected.files];
|
|
491
|
+
}
|
|
462
492
|
return [writeImprovements(collected.target, collected.theme, collected.files, outPath),
|
|
463
493
|
collected.files];
|
|
464
494
|
}
|
|
@@ -515,20 +545,23 @@ export function cmdDiff(args) {
|
|
|
515
545
|
const added = files.filter((f) => f.status === 'added');
|
|
516
546
|
const missing = files.filter((f) => f.status === 'missing');
|
|
517
547
|
const linked = files.filter((f) => f.status === 'linked');
|
|
548
|
+
const stale = files.filter((f) => f.status === 'stale');
|
|
518
549
|
printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
|
|
519
550
|
printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
|
|
520
551
|
+ `${missing.length} missing-from-deployed\n`);
|
|
521
552
|
// Its own line, printed only when there is one, so the summary above is unchanged otherwise.
|
|
522
553
|
if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
|
|
554
|
+
if (stale.length) printOut(`[diff] ${stale.length} file(s) behind source — rebuild to update them\n`);
|
|
523
555
|
const notes = collectNotes(target);
|
|
524
556
|
if (notes.length) printOut(`[diff] ${notes.length} self-improvement note(s) to back-port\n`);
|
|
525
557
|
for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
|
|
526
558
|
for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
|
|
527
559
|
for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
|
|
528
560
|
for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
|
|
561
|
+
for (const f of stale) printOut(` · ${f.rel} (behind source — a rebuild updates it)\n`);
|
|
529
562
|
for (const n of notes) printOut(` * ${n.rel} (self-improvement note — review to back-port)\n`);
|
|
530
563
|
if (args.out) {
|
|
531
|
-
if (
|
|
564
|
+
if (localChanges(files).length || notes.length) {
|
|
532
565
|
// Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
|
|
533
566
|
// path". `parse()` gives a flag `true` and an option a string, so the two arrive
|
|
534
567
|
// distinguishable here without a third kind of entry in the VERBS table.
|
package/js/web/actions.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn } from 'node:child_process';
|
|
29
29
|
import {
|
|
30
|
-
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync,
|
|
30
|
+
accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync,
|
|
31
|
+
renameSync, rmSync,
|
|
31
32
|
} from 'node:fs';
|
|
32
33
|
import os from 'node:os';
|
|
33
34
|
import path from 'node:path';
|
|
@@ -36,7 +37,8 @@ import { emitGlobalInto } from '../build/driver.mjs';
|
|
|
36
37
|
import {
|
|
37
38
|
ROOT, discoverNames, knownRuleIds, loadKits, PACK_ORDER,
|
|
38
39
|
} from '../build/source.mjs';
|
|
39
|
-
import { withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
40
|
+
import { diffCollect, drifted, withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
41
|
+
import { SESSION_FILES } from '../hosts/hooks-context.mjs';
|
|
40
42
|
import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
41
43
|
import { installProfile, setupBuildArgs } from '../build/generate.mjs';
|
|
42
44
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
@@ -503,13 +505,30 @@ export function globalEmitHostFor(emit) {
|
|
|
503
505
|
return HOSTS.some((h) => h.host === host) ? host : 'opencode';
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
/**
|
|
509
|
+
* The session files' names and their top folders (`memory/`, `notebook/`, …): seed-once, the
|
|
510
|
+
* user's after the first build. Derived from `SESSION_FILES`, the one list of them.
|
|
511
|
+
*/
|
|
512
|
+
const SEED_FILES = new Set(SESSION_FILES.filter((r) => !r.includes('/')));
|
|
513
|
+
const SEED_DIRS = new Set(SESSION_FILES.filter((r) => r.includes('/')).map((r) => r.split('/')[0]));
|
|
514
|
+
const isSeedOnce = (rel) => SEED_FILES.has(rel) || SEED_DIRS.has(rel.split('/')[0]);
|
|
515
|
+
|
|
506
516
|
/**
|
|
507
517
|
* Restore selected drifted files from the SOURCE render.
|
|
508
518
|
*
|
|
509
519
|
* Source wins and local edits are discarded (the inverse, keeping them, is Export
|
|
510
|
-
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel
|
|
511
|
-
*
|
|
512
|
-
*
|
|
520
|
+
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel, BY ITS
|
|
521
|
+
* DIFF STATUS: edited/missing -> overwrite/create the deployed copy; added -> move it into
|
|
522
|
+
* `archived-restore/<stamp>/` inside the install; anything else -> an error and nothing touched.
|
|
523
|
+
*
|
|
524
|
+
* THE CLIENT'S LIST IS A SELECTION, NEVER AN AUTHORITY. Only a rel the drift list offers is
|
|
525
|
+
* acted on, and that list is `diffCollect` with the arguments `apiDiff` passes, so the panel and
|
|
526
|
+
* this verb cannot disagree. Trusting the POST made it a write-or-delete primitive over the whole
|
|
527
|
+
* install (audit SE-1, 2026-10): one request reset `user-rules.md` and the memory index to their
|
|
528
|
+
* stubs, a second unlinked a memory fact and an unrelated secret file. The seed-once files are
|
|
529
|
+
* refused even when a manifest claims them. An added file is ARCHIVED, never unlinked, in the
|
|
530
|
+
* `archived-<name>/<stamp>` shape `archiveStore` gives uninstall; nothing owns the archive, so
|
|
531
|
+
* the next diff does not list it.
|
|
513
532
|
*
|
|
514
533
|
* SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
|
|
515
534
|
* structured result rather than a job id — which is why the dispatcher answers it before it
|
|
@@ -534,7 +553,11 @@ export function apiRestore(state, files) {
|
|
|
534
553
|
const restored = [];
|
|
535
554
|
const deleted = [];
|
|
536
555
|
const errors = [];
|
|
556
|
+
let archive = null;
|
|
537
557
|
const target = resolvePath(state.target);
|
|
558
|
+
const offered = new Map(drifted(diffCollect({
|
|
559
|
+
target: state.target, theme: state.theme, emit: state.emit }).files ?? [])
|
|
560
|
+
.map((f) => [f.rel, f.status]));
|
|
538
561
|
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
|
|
539
562
|
try {
|
|
540
563
|
const expected = resolvePath(path.join(tmp, 'expected'));
|
|
@@ -566,26 +589,48 @@ export function apiRestore(state, files) {
|
|
|
566
589
|
const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
567
590
|
const dst = resolvePath(path.join(target, rel));
|
|
568
591
|
const src = resolvePath(path.join(expected, rel));
|
|
592
|
+
const status = offered.get(rel);
|
|
569
593
|
if (!rel || !within(dst, target) || !within(src, expected)) {
|
|
570
594
|
errors.push(`${rel}: outside the deployed tree`);
|
|
571
|
-
} else if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
595
|
+
} else if (!status || isSeedOnce(rel)) {
|
|
596
|
+
errors.push(`${rel}: not a drifted file restore may touch`);
|
|
597
|
+
} else if (status !== 'added' && !isFile(src)) {
|
|
598
|
+
errors.push(`${rel}: not in the source render`);
|
|
599
|
+
} else if (status !== 'added') {
|
|
600
|
+
// Caught per file, so one EISDIR/EBUSY cannot lose the report of the files before it.
|
|
601
|
+
try {
|
|
602
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
603
|
+
// A BYTE copy, so the restored file keeps the render's own line endings rather than
|
|
604
|
+
// the platform's (the `writeText`/`copy2` rule, one file over).
|
|
605
|
+
copyFileSync(src, dst);
|
|
606
|
+
restored.push(rel);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
errors.push(`${rel}: could not restore (${e.code || e.message})`);
|
|
609
|
+
}
|
|
580
610
|
} else {
|
|
581
|
-
|
|
611
|
+
// `archiveStore`'s local `YYYYMMDD-HHMMSS` stamp, one per call; a taken name gets `-2`, …
|
|
612
|
+
archive ??= path.join(target, 'archived-restore', new Date().toLocaleString('sv-SE')
|
|
613
|
+
.replace(/\D/g, '').replace(/^(\d{8})/, '$1-'));
|
|
614
|
+
let dest = resolvePath(path.join(archive, rel));
|
|
615
|
+
for (let n = 2; existsSync(dest); n += 1) dest = resolvePath(path.join(archive, `${rel}-${n}`));
|
|
616
|
+
if (!within(dest, archive)) {
|
|
617
|
+
errors.push(`${rel}: outside the archive`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
622
|
+
renameSync(dst, dest);
|
|
623
|
+
deleted.push(rel);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
errors.push(`${rel}: could not archive (${e.code || e.message})`);
|
|
626
|
+
}
|
|
582
627
|
}
|
|
583
628
|
}
|
|
584
629
|
} finally {
|
|
585
630
|
rmSync(tmp, { recursive: true, force: true });
|
|
586
631
|
}
|
|
587
632
|
state.refresh();
|
|
588
|
-
return { restored, deleted, errors };
|
|
633
|
+
return { restored, deleted, errors, archive };
|
|
589
634
|
}
|
|
590
635
|
|
|
591
636
|
/** (host, scope) -> the emit name that installs it. */
|
package/js/web/api.mjs
CHANGED
|
@@ -80,13 +80,24 @@ export function apiSetup(state) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Short: a user who fixes a problem and clicks Re-run must not be shown the old verdict for long.
|
|
84
|
+
const DOCTOR_COOLDOWN_MS = 3_000;
|
|
85
|
+
|
|
83
86
|
/**
|
|
84
87
|
* The same engine as the `doctor` verb, grouped per check — served from the state's cached run
|
|
85
88
|
* (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
|
|
86
89
|
* for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
|
|
90
|
+
*
|
|
91
|
+
* A fresh ask within `DOCTOR_COOLDOWN_MS` of the last one THIS PATH ran reuses that run, so a
|
|
92
|
+
* page looping on `?fresh=1` cannot pin the event loop. `_doctorFreshAt` is written here only: a
|
|
93
|
+
* verdict planted any other way still re-runs. No single-flight promise — `runDoctor` is
|
|
94
|
+
* synchronous, so two fresh runs cannot overlap (revisit if it ever goes async).
|
|
87
95
|
*/
|
|
88
96
|
export function apiDoctor(state, fresh = false) {
|
|
89
|
-
if (fresh) state.
|
|
97
|
+
if (fresh && Date.now() - (state._doctorFreshAt ?? 0) >= DOCTOR_COOLDOWN_MS) {
|
|
98
|
+
state.runDoctor();
|
|
99
|
+
state._doctorFreshAt = Date.now();
|
|
100
|
+
}
|
|
90
101
|
const { ok, problems, checked_at } = state.doctor;
|
|
91
102
|
return { ...state._doctorRun, ok, problems, checked_at };
|
|
92
103
|
}
|
|
@@ -133,13 +144,15 @@ const memoOf = (state) => (state.renderMemo ??= renderMemo());
|
|
|
133
144
|
|
|
134
145
|
/**
|
|
135
146
|
* The edited/added/missing tally of a `diffCollect` file list, and apart from it the `linked`
|
|
136
|
-
* paths — the user's own links, which the console never adds into a drift total
|
|
147
|
+
* paths — the user's own links, which the console never adds into a drift total — and the
|
|
148
|
+
* `stale` ones, unedited files a rebuild updates, which the console shows as "behind".
|
|
137
149
|
*/
|
|
138
150
|
const driftCounts = (files) => ({
|
|
139
151
|
edited: files.filter((f) => f.status === 'edited').length,
|
|
140
152
|
added: files.filter((f) => f.status === 'added').length,
|
|
141
153
|
missing: files.filter((f) => f.status === 'missing').length,
|
|
142
154
|
linked: files.filter((f) => f.status === 'linked').length,
|
|
155
|
+
stale: files.filter((f) => f.status === 'stale').length,
|
|
143
156
|
});
|
|
144
157
|
|
|
145
158
|
/**
|
package/js/web/catalog.mjs
CHANGED
|
@@ -24,11 +24,15 @@ import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
|
|
|
24
24
|
import { comparePaths, normcase, within } from '../lib/paths.mjs';
|
|
25
25
|
import { stripWhitespace } from '../lib/text.mjs';
|
|
26
26
|
import { readJsonc } from '../hosts/settings.mjs';
|
|
27
|
-
import { loadKits } from '../build/source.mjs';
|
|
27
|
+
import { loadKits, makeCfg } from '../build/source.mjs';
|
|
28
|
+
import { loadTheme } from '../build/render.mjs';
|
|
28
29
|
|
|
29
30
|
/** A requested catalog section or item that does not exist. */
|
|
30
31
|
export class NotFound extends Error {}
|
|
31
32
|
|
|
33
|
+
/** A request body the route refuses on its content (a 400, not a 500): the caller can fix it. */
|
|
34
|
+
export class BadRequest extends Error {}
|
|
35
|
+
|
|
32
36
|
/** `??` would also swallow an explicit `null` the file declared; this does not. */
|
|
33
37
|
export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
34
38
|
|
|
@@ -479,6 +483,17 @@ function kitOwners() {
|
|
|
479
483
|
return owner;
|
|
480
484
|
}
|
|
481
485
|
|
|
486
|
+
/**
|
|
487
|
+
* The constitution's tier nouns in the install's own voice. Pack titles already arrive themed
|
|
488
|
+
* ("Observance"), so a page that hard-coded "Ethos" beside "Dictates" mixed two voices; the
|
|
489
|
+
* theme file is the one writer of these words. An unknown theme throws here exactly as it
|
|
490
|
+
* already throws in `tuiInventory` for the same `state.theme`, so this adds no failure mode.
|
|
491
|
+
*/
|
|
492
|
+
function tierNouns(theme) {
|
|
493
|
+
const t = loadTheme(makeCfg(), theme);
|
|
494
|
+
return { ontology: t.ONTOLOGY, law: t.LAW, laws: t.LAWS, doctrines: t.DOCTRINES };
|
|
495
|
+
}
|
|
496
|
+
|
|
482
497
|
export function apiCatalog(state, section) {
|
|
483
498
|
if (!SECTIONS.includes(section)) throw new NotFound(section);
|
|
484
499
|
const inv = state.inventory;
|
|
@@ -491,7 +506,7 @@ export function apiCatalog(state, section) {
|
|
|
491
506
|
items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
492
507
|
source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown', kit: owner.get(`skill:${e.name}`) ?? null }));
|
|
493
508
|
} else if (section === 'laws') {
|
|
494
|
-
items
|
|
509
|
+
return { section, items: constitutionItems(inv), nouns: tierNouns(state.theme) };
|
|
495
510
|
} else if (section === 'memory') {
|
|
496
511
|
items = memoryItems(state);
|
|
497
512
|
} else if (section === 'notebook') {
|
package/js/web/docs.mjs
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
import { readdirSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
|
-
import { DOCS, DOC_FOLDERS, ROOT, THEMES } from '../build/source.mjs';
|
|
21
|
+
import { DOCS, DOC_FOLDERS, ROOT, THEMES, resolveRuleIds } from '../build/source.mjs';
|
|
22
|
+
import { STRUCTURE, substitute } from '../build/render.mjs';
|
|
22
23
|
import { cliReference } from '../ui/cli.mjs';
|
|
23
24
|
import { readJsonMaybe, readMaybe } from '../hosts/installs.mjs';
|
|
24
25
|
import { resolvePath } from '../hosts/hosts.mjs';
|
|
@@ -222,7 +223,9 @@ const GITHUB_BLOB = 'https://github.com/Arylmera/Geneseed/blob/main/';
|
|
|
222
223
|
* A target that is a docs page becomes `#/docs/<id>` (its `#anchor` dropped — the console
|
|
223
224
|
* addresses pages, not headings); any other repo file — a maintainer doc, a README under
|
|
224
225
|
* `src/` — opens on GitHub, since the console does not serve it. Absolute URLs, in-page
|
|
225
|
-
* anchors and fenced examples are left alone.
|
|
226
|
+
* anchors and fenced examples are left alone. So is a target that is no file at all: the
|
|
227
|
+
* memory convention shows example links (`topics/<topic>.md`) in code spans, and a GitHub URL
|
|
228
|
+
* to a file that does not exist is a dead link where the relative one is merely inert.
|
|
226
229
|
*/
|
|
227
230
|
export function rewriteDocLinks(body, fromRel, idByRel) {
|
|
228
231
|
if (!body.includes('.md')) return body;
|
|
@@ -233,7 +236,8 @@ export function rewriteDocLinks(body, fromRel, idByRel) {
|
|
|
233
236
|
return line.replace(MD_LINK_RE, (m, target, anchor) => {
|
|
234
237
|
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), target));
|
|
235
238
|
const id = idByRel.get(resolved);
|
|
236
|
-
|
|
239
|
+
if (id) return `](#/docs/${id})`;
|
|
240
|
+
return isFile(path.join(ROOT, resolved)) ? `](${GITHUB_BLOB}${resolved}${anchor ?? ''})` : m;
|
|
237
241
|
});
|
|
238
242
|
}).join('\n');
|
|
239
243
|
}
|
|
@@ -458,14 +462,28 @@ function readDocSource(rel) {
|
|
|
458
462
|
return readMaybe(target) ?? '';
|
|
459
463
|
}
|
|
460
464
|
|
|
465
|
+
/** A theme's words, `{}` for a theme that is missing or malformed — never a throw. */
|
|
466
|
+
function themeWords(theme) {
|
|
467
|
+
const doc = readJsonMaybe(path.join(THEMES, `${theme}.json`));
|
|
468
|
+
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A `src/` file as an install would render it: rule citations named, then the deployed theme's
|
|
473
|
+
* words (neutral underneath, for a key the theme lacks) and the fixed STRUCTURE substituted —
|
|
474
|
+
* the build's own functions, so the page cannot drift from what lands on disk. Only string
|
|
475
|
+
* words go in: `substitute` throws on any other value, and a theme's AGENT_COLORS is a dict.
|
|
476
|
+
*/
|
|
477
|
+
function renderSource(text, theme) {
|
|
478
|
+
const words = Object.entries({ ...themeWords('neutral'), ...themeWords(theme) })
|
|
479
|
+
.filter(([, v]) => typeof v === 'string');
|
|
480
|
+
return substitute(resolveRuleIds(text), { ...Object.fromEntries(words), ...STRUCTURE });
|
|
481
|
+
}
|
|
482
|
+
|
|
461
483
|
/** The deployed theme's words beside the neutral ones, from `docs/reference/glossary.md`. */
|
|
462
484
|
function glossary(state, page) {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
|
|
466
|
-
};
|
|
467
|
-
const neutral = load('neutral');
|
|
468
|
-
const themed = state.theme !== 'neutral' ? load(state.theme) : neutral;
|
|
485
|
+
const neutral = themeWords('neutral');
|
|
486
|
+
const themed = state.theme !== 'neutral' ? themeWords(state.theme) : neutral;
|
|
469
487
|
const rows = [];
|
|
470
488
|
for (const { label, key, desc, analogy } of parseGlossaryTable(page.body ?? '')) {
|
|
471
489
|
if (key === null) {
|
|
@@ -554,9 +572,11 @@ export function apiDocsPage(state, pageId, harnessName = null) {
|
|
|
554
572
|
* using it.
|
|
555
573
|
*/
|
|
556
574
|
const KIND_ROUTES = {
|
|
575
|
+
// The body's own H1 goes: MarkdownPage prints the page title as the H1, and a page has one.
|
|
557
576
|
markdown: (state, pageId, page, hn) => {
|
|
558
|
-
const
|
|
559
|
-
|
|
577
|
+
const text = renderSource(stripHarnessBlocks(readDocSource(page.source), hn), state.theme)
|
|
578
|
+
.replace(/^# [^\n]*(?:\r?\n)+/, '');
|
|
579
|
+
const body = rewriteDocLinks(text, page.source, idsByRel());
|
|
560
580
|
return { id: pageId, title: page.title, kind: 'markdown', body,
|
|
561
581
|
source: page.source, links: resolveLinks(state, body) };
|
|
562
582
|
},
|