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.
Files changed (60) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/SHIPPED.md +1 -1
  4. package/docs/extending.md +1 -1
  5. package/docs/guides/upgrade.md +2 -2
  6. package/docs/web-ui.md +28 -7
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -1
  9. package/js/inspect/diff.mjs +42 -9
  10. package/js/web/actions.mjs +61 -16
  11. package/js/web/api.mjs +15 -2
  12. package/js/web/catalog.mjs +17 -2
  13. package/js/web/docs.mjs +31 -11
  14. package/js/web/handler.mjs +83 -30
  15. package/js/web/jobs.mjs +7 -7
  16. package/js/web/server.mjs +4 -0
  17. package/js/web/user-files.mjs +24 -7
  18. package/package.json +1 -1
  19. package/web/dist/assets/Activity-D3_bQCUu.js +1 -0
  20. package/web/dist/assets/ActivityDetail-CF4t4Tfu.js +1 -0
  21. package/web/dist/assets/Dashboard-CLs-bR_v.js +3 -0
  22. package/web/dist/assets/Docs-DxUYipLM.js +4 -0
  23. package/web/dist/assets/GreenhouseView-DYYctWtr.js +1 -0
  24. package/web/dist/assets/Installs-sz2MgKXB.js +3 -0
  25. package/web/dist/assets/JournalView-DFvDimtU.js +1 -0
  26. package/web/dist/assets/KitApply-BllCOBIp.js +1 -0
  27. package/web/dist/assets/Kits-Bkujcmd1.js +1 -0
  28. package/web/dist/assets/Laws-B2cyrICK.js +3 -0
  29. package/web/dist/assets/Library-CsVwkY-2.js +1 -0
  30. package/web/dist/assets/Loops-Dj50x8qB.js +2 -0
  31. package/web/dist/assets/Markdown-OReRRoEV.js +65 -0
  32. package/web/dist/assets/OperatorHudView-DcY3jDhO.js +1 -0
  33. package/web/dist/assets/Personal-RriJXVUS.js +1 -0
  34. package/web/dist/assets/RailCats-D5RVYEjy.js +1 -0
  35. package/web/dist/assets/{Tabs-RdL1pfIT.js → Tabs-u5wJjzu8.js} +1 -1
  36. package/web/dist/assets/drawable-CUtqWN8k.js +1 -0
  37. package/web/dist/assets/index-D7KB52Os.js +2 -0
  38. package/web/dist/assets/index-ngEyS9_E.css +1 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/src/pages/Laws.jsx +56 -24
  41. package/web/dist/assets/Activity-DNVzYkIY.js +0 -1
  42. package/web/dist/assets/ActivityDetail-CMBXjmTo.js +0 -1
  43. package/web/dist/assets/Dashboard-DFY_FarE.js +0 -2
  44. package/web/dist/assets/Docs-BS0NIsA0.js +0 -5
  45. package/web/dist/assets/FilterInput-CZREvrjX.js +0 -1
  46. package/web/dist/assets/GreenhouseView-M3wWndqG.js +0 -2
  47. package/web/dist/assets/Installs-CvRxZcZc.js +0 -3
  48. package/web/dist/assets/JournalView-BSD-1Z83.js +0 -1
  49. package/web/dist/assets/KitApply-_4Ug9pMF.js +0 -1
  50. package/web/dist/assets/Kits-c5mVesJ9.js +0 -1
  51. package/web/dist/assets/Laws-Bpn4ZBzL.js +0 -3
  52. package/web/dist/assets/Library-DWgfAfkV.js +0 -1
  53. package/web/dist/assets/Loops-BqP3c0c-.js +0 -2
  54. package/web/dist/assets/Markdown-zaXXXHz9.js +0 -65
  55. package/web/dist/assets/OperatorHudView-DLEGd63S.js +0 -2
  56. package/web/dist/assets/Personal-B_LeqnDL.js +0 -1
  57. package/web/dist/assets/drawable-inwWR3uw.js +0 -1
  58. package/web/dist/assets/index-BAArp8rX.js +0 -2
  59. package/web/dist/assets/index-jYlWihN-.css +0 -1
  60. 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 that drift to an `improvements/` file inside the install before overwriting. Details: [Upgrade](docs/guides/upgrade.md).
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.17.1**.
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` — 35 rows, each a one-string edit to a
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
@@ -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 the files they are about to overwrite differ from a fresh render, 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.
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). Headlines reflect the
78
- active theme's voice.
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) and trial chips, add/edit/retire forms, a
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 with per-file expansion and coloured unified-diff lines. Select
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. Files you edited in a kit that is now off are kept,
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, uninstall), an offline package download, server controls (Stop), and the
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
 
@@ -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.17.1"
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 guards and body cap (before the body is read), static SPA fallback |
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 |
@@ -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([...ownedSet(dir), ...exp.owned])].sort();
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
- rec = { rel, status: 'edited', diff };
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
- /** The records that are drift: everything but a `linked` path, which is the user's own. */
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
- const files = drifted(allFiles);
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
- if (!collected.files || drifted(collected.files).length === 0) return [null, collected.files];
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 (drifted(files).length || notes.length) {
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.
@@ -27,7 +27,8 @@
27
27
  */
28
28
  import { spawn } from 'node:child_process';
29
29
  import {
30
- accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync,
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
- * expected present -> overwrite/create the deployed copy; expected absent but deployed present
512
- * (an 'added' file) -> delete it; neither -> an error and nothing touched.
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 (isFile(src)) {
572
- mkdirSync(path.dirname(dst), { recursive: true });
573
- // A BYTE copy, so the restored file keeps the render's own line endings rather than
574
- // the platform's (the `writeText`/`copy2` rule, one file over).
575
- copyFileSync(src, dst);
576
- restored.push(rel);
577
- } else if (isFile(dst)) {
578
- unlinkSync(dst);
579
- deleted.push(rel);
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
- errors.push(`${rel}: not in the source render nor deployed`);
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.runDoctor();
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
  /**
@@ -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 = constitutionItems(inv);
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
- return id ? `](#/docs/${id})` : `](${GITHUB_BLOB}${resolved}${anchor ?? ''})`;
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 load = (theme) => {
464
- const doc = readJsonMaybe(path.join(THEMES, `${theme}.json`));
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 body = rewriteDocLinks(stripHarnessBlocks(readDocSource(page.source), hn),
559
- page.source, idsByRel());
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
  },