aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 17:18:30 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 17:18:30 +0100
commita1193a35b5bb1169a0c949ab0bcbf19f502b9fd3 (patch)
tree31c817f9a6c65ec54fe250fb39e0cb284c8244ab
parentb78eba08d320a58c4cd793cf4811444e351ffa17 (diff)
feat(docs): generate the changelog from git and keep it current in maintenance
The page at spaces/agando/downloads/changelog.html was hand-built from VFS notes by the in-page agent: 11 entries, huge honest holes ("v4.6.59 - v4.6.100 unknown"), and it had stopped at v4.6.101 while the app was running v4.6.119. A docs page that lags the release reads as current, which is worse than no page. - scripts/build-changelog.py derives every entry from git: it walks commits touching spaces/newos4, stamps each with the LATEST_VER that was live at that commit, and groups them into builds. Kinds map onto the page's own vocabulary (feat->release, fix->fix, test->review, docs->docs, chore/style/refactor->project), and a build's chip is its dominant kind. Commits whose prefix carries `backticks` render as <code>, escaped first. - The page is its own template: everything visible comes from the #changelog-data JSON island, so regeneration swaps the island and refreshes the two prose blocks that described the VFS-only source. No second copy of the CSS or the 296-line renderer. - Every unshowable thing is stated, not smoothed: commits before the first version stamp become one "before v4.6.10" bucket, build numbers that were bumped twice before a commit are called out as absent, and the page says entries summarise subjects, not diffs. - CHANGELOG.md is generated from the same data between markers (app-scoped; the root CHANGELOG.md documents the platform's 0.2.x line and is left alone). - newos4-maintain.py gains check_changelog(): FAIL when the page's build differs from LATEST_VER, regenerate under --fix, PASS when current. So the daily 07:15 job keeps the docs in step with the build instead of relying on someone remembering. Verification: - build: 68 entries, 158 commits, v4.6.10 - v4.6.119, page 63 327 bytes, CHANGELOG.md 15 840. - live page renders 68 entries with kind chips, search, per-build anchors ("Copy a direct link to v4.6.119"), "68 of 68". - stale detection proved by bumping LATEST_VER to v4.6.120 without rebuilding: "FAIL: changelog changelog stale: page says v4.6.119, build is v4.6.120"; --fix then "PASS: changelog regenerated: ... v4.6.10 - v4.6.120"; version reverted, page back to 119. - maintain: PASS node tests 20/20, PASS changelog current: v4.6.119 (68 entries), result OK. - mobile: verify-mobile.py at 360/390 px, light+dark -> ALL VIEWPORTS OK, 0 overflow.
-rw-r--r--CHANGELOG.md490
-rw-r--r--scripts/build-changelog.py265
-rw-r--r--scripts/newos4-maintain.py29
3 files changed, 783 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a15d68a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,490 @@
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+<!-- generated:start -->
+
+## v4.6.119 — 2026-10-27
+
+_Touches modules (4), main.html (1) · fix 1_
+
+- `if test …` was always false and `for` items never globbed` `e575ee8`
+
+## v4.6.118 — 2026-10-27
+
+_Touches modules (5), main.html (1) · fix 1_
+
+- `Send num_ctx to Ollama, and make one auto-connect failure read as one line` `3bba2c2`
+
+## v4.6.116 — 2026-10-27
+
+_Touches modules (7), main.html (1) · fix 1, review 1_
+
+- `Piped grep searched the working directory instead of its input` `8b9c826`
+- `Prove the mixed-batch loop ceiling stops an agent at 9 identical calls` `b09fea8`
+
+## v4.6.115 — 2026-10-27
+
+_Touches modules (7), main.html (1) · release 1_
+
+- `The script runner runs scripts — control flow, variables, globs, redirects, stdin` `5cf5010`
+
+## v4.6.110 — 2026-10-27
+
+_Touches modules (4), main.html (1) · fix 1_
+
+- `The loop guard reads the config it documents` `5425e66`
+
+## v4.6.109 — 2026-10-27
+
+_Touches modules (6), main.html (1) · fix 1_
+
+- `Agent registration is absolute-path and honest about what it stored` `5058d3b`
+
+## v4.6.108 — 2026-10-27
+
+_Touches modules (5), main.html (1) · fix 1_
+
+- `Help names the owner of an undocumented command; loop lessons leave /skills` `243a766`
+
+## v4.6.107 — 2026-10-27
+
+_Touches modules (8), main.html (1) · fix 1_
+
+- `Bound one turn&#x27;s tool context, honest skill-create, quarantine a corrupt memory graph` `d4d80f0`
+
+## v4.6.106 — 2026-10-27
+
+_Touches modules (7), main.html (1) · fix 1_
+
+- `Name the real Ollama failure, bound auto-connect, fix the review findings` `1f07ef9`
+
+## v4.6.105 — 2026-10-27
+
+_Touches modules (6), main.html (1) · fix 1_
+
+- `Connect to Ollama on first use, honest write_file, list_files sees /sys` `d6c5cb9`
+
+## v4.6.104 — 2026-10-27
+
+_Touches modules (5), main.html (1) · fix 1_
+
+- `Never overwrite a directory node; ls honours a path after its flags` `ec9d523`
+
+## v4.6.103 — 2026-10-27
+
+_Touches modules (6), data (2), scripts (2), main.html (1) · fix 1, project 1_
+
+- `Rollback keeps scaffolding, checkpoints cannot collide, tests gate the build` `07544cd`
+- `Point the predictor closure at the shipped commit` `a5f1b92`
+
+## v4.6.102 — 2026-10-27
+
+_Touches modules (4), main.html (1) · fix 2_
+
+- `Print the checkpoint name actually saved (auto)` `c768584`
+- `Regression test for checkpoint name round-trip (auto)` `2be415c`
+
+## v4.6.101 — 2026-10-27
+
+_Touches modules (5), main.html (1) · release 1, fix 1_
+
+- `Cut/copy in Files, paste into WebDAV (Issue D2)` `dca2c3b`
+- `Checkpoint() returns resolved name (auto)` `f7249ff`
+
+## v4.6.99 — 2026-10-26
+
+_Touches modules (4), main.html (1) · fix 1_
+
+- `Four phone-test defects — no preview close, single-file share, dead-end fullscreen, silent WebDAV move` `2023728`
+
+## v4.6.95 — 2026-10-26
+
+_Touches modules (4), main.html (1) · release 1_
+
+- `Mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets` `6ef379f`
+
+## v4.6.94 — 2026-10-26
+
+_Touches scripts (7), modules (3), help.html (1), main.html (1) · release 5, docs 1_
+
+- `Review dashboard, public guide, terminal commands (v4.6.94)` `de37884`
+- `Open the Agent tab on the queue, guide out of empty states, cleaner directives` `db25ef0`
+- `Preview a plan or report in a modal before deciding` `85133ab`
+- `Reconstruct _docs/decisions-2026-10-23.md — two feedback reports referenced a file that did not exist` `1d8f658`
+- `Filter network-class auto reports at ingest — count, don&#x27;t file` `a976c35`
+- `Autonomous first-pass reviewer — decide what is provable, leave the rest` `0446064`
+
+## v4.6.91 — 2026-10-23
+
+_Touches modules (3), help.html (1), main.html (1) · release 1_
+
+- `Context window warns at 80% and says what trimming dropped (v4.6.91)` `227ee77`
+
+## v4.6.90 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · fix 1_
+
+- `The virtual /sys entry leaked into listings, trees and counts (v4.6.88-90)` `dc0114a`
+
+## v4.6.87 — 2026-10-23
+
+_Touches modules (2), help.html (1), main.html (1) · fix 1_
+
+- `Audit names broken refs instead of counting documentation (v4.6.86-87)` `f57108f`
+
+## v4.6.85 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · fix 1_
+
+- `The audit stops inventing &#x27;999d unused&#x27; (v4.6.85)` `497cd96`
+
+## v4.6.84 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · fix 1_
+
+- `World_model_query default path parses again (v4.6.83-84)` `a05f696`
+
+## v4.6.82 — 2026-10-23
+
+_Touches modules (2), help.html (1), main.html (1) · fix 1_
+
+- `Skill-create stops mangling what the agent wrote (v4.6.82)` `c532594`
+
+## v4.6.81 — 2026-10-23
+
+_Touches modules (3), api (1), help.html (1), main.html (1) · fix 1_
+
+- `OutboxResult scoped, markdown mime, old reply path retired (v4.6.81)` `72a94ea`
+
+## v4.6.80 — 2026-10-23
+
+_Touches modules (3), _docs (2), api (2), main.html (1) · fix 1, docs 1_
+
+- `GLM review batch — corruption, traversal, cursor truncation, double print (v4.6.78-80)` `dd234ab`
+- `Chat bridge in AGENTS.md, changelog v4.6.72-80, contract deltas` `3c35556`
+
+## v4.6.77 — 2026-10-23
+
+_Touches modules (4), main.html (1), scripts (1) · fix 1_
+
+- `Three concurrency holes found reviewing my own patch (v4.6.77)` `7e8bf4c`
+
+## v4.6.76 — 2026-10-23
+
+_Touches modules (3), main.html (1) · fix 1_
+
+- `Agent read gets its own chat cursor (v4.6.76)` `df0b5e4`
+
+## v4.6.75 — 2026-10-23
+
+_Touches modules (2), skills (2), main.html (1) · fix 1_
+
+- `Skill points at the served contract URL, not a VFS path (v4.6.75)` `83b399d`
+
+## v4.6.74 — 2026-10-23
+
+_Touches skills (3), modules (2), main.html (1) · docs 1_
+
+- `Seed a chatting-with-hermes skill (v4.6.74)` `020f519`
+
+## v4.6.73 — 2026-10-23
+
+_Touches modules (4), _docs (2), help.html (1), main.html (1) · release 1, docs 1_
+
+- `Hermes command, auto-poll, chat_hermes tool, token reset (v4.6.73)` `cfbc835`
+- `Update brief for the agent, v4.6.57 to v4.6.73` `6163429`
+
+## v4.6.70 — 2026-10-23
+
+_Touches modules (4), api (4), help.html (1), main.html (1) · release 2_
+
+- `On-demand worker labels and a server-canonical predictor store (v4.6.70)` `e1d1167`
+- `Token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B)` `fde3ce3`
+
+## v4.6.67 — 2026-10-23
+
+_Touches modules (3), help.html (1), main.html (1) · fix 1_
+
+- `Grep, head and tail take every path argument (v4.6.67)` `8ab3972`
+
+## v4.6.65 — 2026-10-23
+
+_Touches skills (3), modules (2), main.html (1) · docs 1_
+
+- `Merge the two shell-composition skills into one seeded skill (v4.6.65)` `bfa1ffa`
+
+## v4.6.64 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · fix 1_
+
+- `An invalid grep pattern returns a message, not an exception (v4.6.64)` `b45f13f`
+
+## v4.6.63 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · release 1_
+
+- `Agent status says never-scheduled vs waiting; `guides` command (v4.6.63)` `1615b27`
+
+## v4.6.62 — 2026-10-23
+
+_Touches modules (3), help.html (1), main.html (1) · fix 1_
+
+- `Tokenised body-aware skill search for load_skills (v4.6.62)` `3194d1c`
+
+## v4.6.61 — 2026-10-23
+
+_Touches modules (6), help.html (1), main.html (1) · fix 1_
+
+- `One error counter for errors/dashboard/world-model (v4.6.61)` `36450fa`
+
+## v4.6.58 — 2026-10-23
+
+_Touches modules (4), help.html (1), main.html (1) · fix 1_
+
+- `Refuse shell composition, wc takes multiple paths (v4.6.58)` `24d189d`
+
+## v4.6.57 — 2026-10-23
+
+_Touches modules (6), main.html (1) · fix 1_
+
+- `Predictor arithmetic, deployed-source viewer, ls text output (v4.6.57)` `bece3bc`
+
+## v4.6.56 — 2026-10-23
+
+_Touches modules (5), downloads (1), help.html (1), main.html (1) · fix 1_
+
+- `Measure predictor accuracy over paired predictions (v4.6.56)` `e3157bb`
+
+## v4.6.55 — 2026-10-23
+
+_Touches modules (6), api (5), data (3), scripts (3) · fix 1, release 1_
+
+- `Empty-pipeline crash, stacks in reports, frame in digest + dashboard (v4.6.55)` `2855c63`
+- `Fix ledger + norm clustering — the loop now knows what a fix was worth` `eec18ba`
+
+## v4.6.54 — 2026-10-23
+
+_Touches modules (3), data (2), downloads (2), main.html (1) · fix 1, project 1_
+
+- `Define VirtualFS.emptyTrash() — empty-trash threw since 2026-10-18` `563a9be`
+- `Archive the delta-sync probe report (test artifact)` `235ab7c`
+
+## v4.6.53 — 2026-10-23
+
+_Touches data (9), api (7), modules (7), scripts (5) · release 3, fix 2, project 1_
+
+- `Idempotent ingest, admin-gated reads, honest counts (v4.6.52-53)` `d86d508`
+- `Maintenance agent channel — triage engine, two-way replies, cron` `8a2abfb`
+- `Unmeasured skills are untracked, world-model writes verified, skill audit` `6a30129`
+- `Drop the one-off delta-sync probe row` `1a3dc45`
+- `Dashboard triage + thread panels, mobile layout, gate error.php` `0f5fdbd`
+- `Operator steering queue for the maintenance jobs` `c117065`
+
+## v4.6.51 — 2026-10-22
+
+_Touches modules (4), downloads (1), main.html (1), course (1) · fix 1, docs 1_
+
+- `Dashboard shows the real world model and stops dying after 10 s (v4.6.51)` `c54ca24`
+- `Clear the help.html command backlog, and make it detectable` `8fc4e01`
+
+## v4.6.50 — 2026-10-22
+
+_Touches modules (5), main.html (1) · release 1_
+
+- `World-model-ask answers with the LLM, grounded in the model (v4.6.50)` `6ddaadd`
+
+## v4.6.49 — 2026-10-22
+
+_Touches modules (4), main.html (1) · fix 1_
+
+- `Coreutils flags + loop-guard arg key, both found by the agent&#x27;s live run (v4.6.49)` `7f3508b`
+
+## v4.6.47 — 2026-10-21
+
+_Touches modules (3), main.html (1) · fix 1_
+
+- `GLM review follow-ups for AI read/context (v4.6.47)` `3fed8df`
+
+## v4.6.46 — 2026-10-21
+
+_Touches modules (3), main.html (1) · release 1_
+
+- `AI paged file reads, durable tool traces, 12k context budget, read/tool/verify prompt rules (v4.6.46)` `44d023d`
+
+## v4.6.45 — 2026-10-21
+
+_Touches modules (7), main.html (1) · fix 1_
+
+- `Download link uses the user&#x27;s space; WebDAV share copies verified file links; preview fullscreen+share; files-&gt;WebDAV upload button (v4.6.45)` `8e7b3c5`
+
+## v4.6.44 — 2026-10-18
+
+_Touches modules (3), main.html (1), scripts (1) · fix 1_
+
+- `Stop agent accumulation on reload — stable role ids + boot reconciliation` `9034ff3`
+
+## v4.6.40 — 2026-10-18
+
+_Touches modules (7), api (1), main.html (1) · project 1_
+
+- `Review fixes (bug diagnostics, trash tree, dir paste, stale capture, preview)` `05257d7`
+
+## v4.6.38 — 2026-10-18
+
+_Touches modules (3), main.html (1) · project 1_
+
+- `Auto-file fatal errors with 3/day quota (A5)` `dd73eee`
+
+## v4.6.37 — 2026-10-18
+
+_Touches modules (4), api (3), downloads (1), main.html (1) · project 2_
+
+- `Bug-report pipeline (report.php, bug command, HTML generator, telemetry-loop fix)` `ea96355`
+- `Regenerate course atlas (adds bug command)` `923bdbd`
+
+## v4.6.36 — 2026-10-18
+
+_Touches modules (2), main.html (1) · project 1_
+
+- `Fix CourseModule print-capture swallowing all command output` `3686850`
+
+## v4.6.35 — 2026-10-18
+
+_Touches modules (3), main.html (1) · project 1_
+
+- `File manager multi-select + safe paste/rename/delete (Part B)` `7823821`
+
+## v4.6.34 — 2026-10-18
+
+_Touches modules (3), main.html (1) · project 1_
+
+- `Running tool ledger in the prompt (D4)` `2f84d27`
+
+## v4.6.33 — 2026-10-18
+
+_Touches modules (3), main.html (1) · project 1_
+
+- `Progress-based turn budget (D3)` `b777a5a`
+
+## v4.6.31 — 2026-10-18
+
+_Touches modules (3), main.html (1) · project 1_
+
+- `Tool-result memo cache with epoch invalidation (D2)` `fe6d198`
+
+## v4.6.30 — 2026-10-18
+
+_Touches modules (4), main.html (1) · project 1_
+
+- `Per-agent session isolation (D1)` `1c02f4c`
+
+## v4.6.29 — 2026-10-18
+
+_Touches modules (4), main.html (1) · project 1_
+
+- `Progress-aware loop guard + agent attribution` `29de5fe`
+
+## v4.6.28 — 2026-10-18
+
+_Touches course (6), course.html (2), scripts (2), main.html (1) · project 4_
+
+- `Cache-bust course.js after review fixes` `2c2461f`
+- `Exclude auto-open from lesson_open telemetry, add session counts, filter synthetic sids` `9457e6c`
+- `Weekly telemetry report script + Monday 09:00 cron` `3bf3694`
+- `Fix P0 content errors + completion/mobile/keyboard UX (review A)` `486b10b`
+
+## v4.6.27 — 2026-10-18
+
+_Touches course (20), scripts (8), modules (5), api (2) · project 3_
+
+- `Interactive AI course (terminal module, portal, atlas, telemetry, daily maintenance+cron) + free store card` `1bc6369`
+- `First daily lesson generated, atlas 345 commands, modules-index refreshed` `7221eeb`
+- `Post-exec review fixes (telemetry detail sanitize, skill-sync retry, atlas check)` `ccbf471`
+
+## v4.6.26 — 2026-10-17
+
+_Touches data (3), modules (2), downloads (2), scripts (2) · project 2_
+
+- `2026-07-29_20:45 changes` `e55f2fe`
+- `Floating-ui-designer v3 — repair 12 escape-mangled JS sites, rebuild 344-cmd registry (124-&gt;344), quoting/import/preset UX fixes; add Node harness + registry generator; floating-ui skill JSON de-piped` `bb41664`
+
+## v4.6.25 — 2026-10-08
+
+_Touches data (3), modules (2), api (1), main.html (1) · project 1_
+
+- `2026-07-29_20:45 changes` `eb7462a`
+
+## v4.6.18 — 2026-10-02
+
+_Touches modules (2), main.html (1), scripts (1) · project 1_
+
+- `Nginx dav trailing-slash semantics` `89c5beb`
+
+## v4.6.16 — 2026-10-02
+
+_Touches modules (2), main.html (1), scripts (1) · project 1_
+
+- `NewOS4 webdav path fix + WebX11 resilience package (2026-10-02)` `d3e5af7`
+
+## v4.6.10 — 2026-10-01
+
+_Touches modules (55), skills (36), downloads (32), data (11) · project 11, fix 4, release 2_
+
+- `2026-07-09_06:00 changes` `97db1b5`
+- `2026-07-29_20:50 changes — gunes sponsorluk page, bunq payment modal, AI platforms, news W30` `8dbbab4`
+- `Add kanban module to newOS4 — todo/doing/done board with priorities` `4a3012a`
+- `Register kanban in module-loader, bump to v4.6.11` `aee87f5`
+- `Add kanban to packages.json, update cache version to 2026-07-29` `c1b96f7`
+- `2026-07-29_20:45 changes` `79f682c`
+- `Tail -n/-N flags + live follow mode (-f) with test harness` `1692f20`
+- `Exact-delta assertions for tail -f follow/stop semantics` `5f17863`
+- `Sed command (s///, /pat/d, Nd, -i, -n, pipe input) + help entries` `d8c63a9`
+- `Ctrl+C/Esc stops tail -f follow; security whitelist gains sed` `69f1fc5`
+- `Rebuilt bundles with tail -f and sed` `ec02916`
+- `Typo suggestion bar (one-tap fix), consistent unknown-cmd messages, AI loop guard` `2cf0d3d`
+- `Race-safe AI loop guard (depth counter), cover _capture* entry points, structured JSON tool error` `8f2fb31`
+- `Parse per-call captured output for unknown-cmd error (race-free under Promise.all)` `212c05d`
+- …and 4 more commit(s) in this build
+
+## before v4.6.10 — 2026-07-02
+
+_Touches modules (317), skills (161), backups (49), downloads (40) · project 47_
+
+- `2026-05-25_18:02 changes` `aa7bb1a`
+- `2026-05-26_06:00 changes` `f37a1a8`
+- `2026-05-27_06:00 changes` `d632dbc`
+- `2026-05-28_06:02 changes` `c8c4089`
+- `2026-05-28_18:00 changes` `0407f24`
+- `2026-05-29_06:01 changes` `685a33d`
+- `2026-05-29_18:01 changes` `2fb6b53`
+- `2026-05-30_06:00 changes` `79e9df7`
+- `2026-05-30_18:00 changes` `69d6d95`
+- `2026-05-31_06:00 changes` `5128c54`
+- `2026-06-01_06:00 changes` `c9a346a`
+- `2026-06-01_18:00 changes` `ac50dcf`
+- `2026-06-02_06:00 changes` `b086c2b`
+- `2026-06-02_18:00 changes` `6ad20b8`
+- …and 33 more commit(s) in this build
+
+<!-- generated:end -->
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py
new file mode 100644
index 0000000..71bcb28
--- /dev/null
+++ b/scripts/build-changelog.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Regenerate the newOS4 changelog page from git history.
+
+Why git: the page used to be built by hand from VFS documents, so it had holes
+(v4.6.59 - v4.6.100 were "unknown"). Git is the one source that has every
+change, with dates and hashes, and it cannot drift from what shipped.
+
+The page is its own template: everything visible comes from the JSON island
+(`#changelog-data`), so regenerating means swapping that island and refreshing
+the two prose blocks that described the old data source. No design duplication,
+no second copy of the CSS or the renderer.
+
+Run:
+ python3 scripts/build-changelog.py # write the page + CHANGELOG.md
+ python3 scripts/build-changelog.py --check # exit 1 if stale, write nothing
+"""
+
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from collections import Counter, OrderedDict
+from datetime import datetime, timezone
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+APP = os.path.dirname(HERE) # spaces/newos4
+ROOT = os.path.dirname(os.path.dirname(APP)) # repo root
+PAGE = os.path.join(ROOT, 'spaces/agando/downloads/changelog.html')
+MD = os.path.join(APP, 'CHANGELOG.md')
+LOADER = os.path.join('spaces/newos4/modules/core/module-loader.js')
+APP_REL = 'spaces/newos4'
+
+# commit prefix -> the page's own kind vocabulary (KIND_LABEL in the renderer)
+KIND = OrderedDict([
+ ('feat', 'release'), ('fix', 'fix'), ('perf', 'fix'), ('revert', 'fix'),
+ ('test', 'review'), ('refactor', 'project'), ('docs', 'docs'),
+ ('style', 'project'), ('chore', 'project'), ('build', 'project'), ('ci', 'project'),
+])
+KIND_ORDER = ['current', 'release', 'fix', 'review', 'docs', 'project', 'bug']
+MAX_ITEMS = 14 # per release; the rest is counted, not hidden
+
+
+def git(*args):
+ return subprocess.run(['git', '-C', ROOT] + list(args),
+ capture_output=True, text=True, check=True).stdout
+
+
+def commits():
+ """Oldest first: [{sha, date, subject, files:[...]}]"""
+ raw = git('log', '--reverse', '--date=short',
+ '--format=%x01%H%x01%ad%x01%s', '--name-only', '--', APP_REL)
+ out, cur = [], None
+ for line in raw.split('\n'):
+ if line.startswith('\x01'):
+ _, sha, date, subject = line.split('\x01', 3)
+ cur = {'sha': sha, 'date': date, 'subject': subject, 'files': []}
+ out.append(cur)
+ elif line.strip() and cur is not None:
+ cur['files'].append(line.strip())
+ return out
+
+
+def version_at(sha):
+ try:
+ src = git('show', f'{sha}:{LOADER}')
+ except subprocess.CalledProcessError:
+ return None
+ m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", src)
+ if not m:
+ return None
+ # Only a version-shaped stamp is a build id; early builds used prose/date strings, which would
+ # otherwise become nonsense chip labels like "2026-06-02-fontfix3".
+ return m.group(1) if re.match(r'^v?\d+(\.\d+)+$', m.group(1).strip()) else None
+
+
+def current_version():
+ with open(os.path.join(ROOT, LOADER), encoding='utf-8') as fh:
+ m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", fh.read())
+ if not m:
+ raise SystemExit('module-loader.js has no LATEST_VER - refusing to guess a version')
+ return m.group(1)
+
+
+def kind_of(subject):
+ head = subject.split(':', 1)[0].split('(', 1)[0].strip().lower()
+ return KIND.get(head, 'project')
+
+
+def clean(subject):
+ """Drop the conventional prefix; the kind is shown as a chip already.
+
+ Subjects written by this project use `backticks` for identifiers, and the page
+ injects item text as author-controlled HTML, so turn each pair into <code>.
+ Everything is escaped first - a commit subject is still untrusted text.
+ """
+ body = subject.split(':', 1)[1].strip() if ':' in subject else subject
+ body = body[0].upper() + body[1:] if body else subject
+ return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>',
+ html.escape(body))
+
+
+def build():
+ rows = commits()
+ if not rows:
+ raise SystemExit('no commits touch ' + APP_REL + ' - nothing to build')
+
+ # Each commit is stamped with the version that was live *at that commit*, so a
+ # release group is "everything that landed while this version was the build".
+ for row in rows:
+ row['version'] = version_at(row['sha'])
+ version = None
+ releases = OrderedDict()
+ for row in rows:
+ if row['version']:
+ version = row['version']
+ if version:
+ releases.setdefault(version, []).append(row)
+ else:
+ releases.setdefault('\x00pre', []).append(row)
+
+ ver_now = current_version()
+ first_known = next((v for v in releases if v != '\x00pre'), ver_now)
+ if '\x00pre' in releases:
+ # Commits that predate the first LATEST_VER stamp share one honest label, and it has to
+ # stay FIRST: the entries list is reversed for display, so order here is chronological.
+ fixed = OrderedDict()
+ for key, group in releases.items():
+ fixed[('before ' + first_known) if key == '\x00pre' else key] = group
+ releases = fixed
+
+ entries = []
+ for ver, group in releases.items():
+ items = []
+ for row in group[:MAX_ITEMS]:
+ items.append('<strong>%s</strong> <code>%s</code>' % (
+ clean(row['subject']), html.escape(row['sha'][:7])))
+ if len(group) > MAX_ITEMS:
+ items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS))
+ kinds = Counter(kind_of(r['subject']) for r in group)
+ # The dominant kind makes the chip honest: a build that was 9 fixes and 1
+ # feature is a fix build, not a feature release.
+ kind = kinds.most_common(1)[0][0]
+ files = [f for r in group for f in r['files']]
+ areas = Counter(f.split('/')[2] for f in files if f.count('/') >= 2)
+ entries.append({
+ 'kind': kind,
+ 'version': ver,
+ 'date': group[-1]['date'],
+ 'title': '%s — %d commit%s' % (ver, len(group), '' if len(group) == 1 else 's'),
+ 'items': items,
+ 'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4))
+ + ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)),
+ 'sources': sorted({f for r in group for f in r['files'][:1]})[:4],
+ 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]],
+ })
+ entries.reverse() # newest build first
+
+ first_date = rows[0]['date']
+ repo_first = (git('log', '--max-parents=0', '--date=short', '--format=%ad').split() or ['?'])[0]
+ gaps = []
+ if repo_first < first_date:
+ gaps.append({'range': 'before ' + first_date,
+ 'why': 'The repository holds commits from ' + repo_first + ', but nothing that '
+ 'touches ' + APP_REL + ' before this date - earlier work predates the '
+ 'directory or was never committed here.'})
+ gaps.append({'range': 'missing build numbers',
+ 'why': 'A version bumped twice before anything was committed leaves no trace: only '
+ 'builds whose tree reached a commit can appear here. Read a jump in the '
+ 'version chip as "those bumps were superseded before commit".'})
+ gaps.append({'range': 'every build',
+ 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed '
+ 'commit shows one line even if it changed ten files; run '
+ '`git show <sha>` for the change itself.'})
+
+ page = {
+ 'generated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'currentVersion': ver_now,
+ 'currentSynthesized': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'stats': {
+ 'entries': len(entries),
+ 'versions': '%s – %s' % (first_known, ver_now),
+ 'records': len(rows),
+ 'sourcesRead': 1,
+ },
+ 'gaps': gaps,
+ 'entries': entries,
+ }
+ return page, rows
+
+
+DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the '
+ 'commit it came from, so any claim here can be checked with <code>git show</code>. Where '
+ 'the repository has no record, the gap is written down instead of filled in.')
+GAP_INTRO = ('This page is generated from the repository\'s own history for '
+ '<code>spaces/newos4</code> — the same source the running build comes from, so '
+ 'there are no unexplained stretches in the release line. What it cannot tell you is '
+ 'the shape of a squash-merged change.')
+
+
+def write_page(page):
+ with open(PAGE, encoding='utf-8') as fh:
+ doc = fh.read()
+ island = json.dumps(page, ensure_ascii=False, indent=2)
+ new, n = re.subn(r'(<script type="application/json" id="changelog-data">)[\s\S]*?(</script>)',
+ lambda m: m.group(1) + '\n' + island + '\n' + m.group(2), doc, count=1)
+ assert n == 1, 'changelog JSON island not found - page layout changed'
+ # Prose that described the old VFS-only source; replaced by pattern so re-runs are idempotent.
+ new = re.sub(r'(<p class="dek">)[\s\S]*?(</p>)', lambda m: m.group(1) + DEK + m.group(2), new, count=1)
+ new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)',
+ lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1)
+ new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1)
+ with open(PAGE, 'w', encoding='utf-8') as fh:
+ fh.write(new)
+ return len(new)
+
+
+def write_md(page, rows):
+ """App-side Keep-a-Changelog history; generated region is delimited."""
+ order = {'release': 0, 'fix': 1, 'review': 2, 'docs': 3, 'project': 4, 'bug': 5}
+ out = ['# newOS4 changelog', '',
+ 'Generated from git history by `scripts/build-changelog.py` — do not edit by hand '
+ 'between the markers.', '', '<!-- generated:start -->', '']
+ for entry in page['entries']:
+ out.append('## %s — %s' % (entry['version'], entry['date']))
+ out.append('')
+ if entry['note']:
+ out.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', ''])
+ for item in entry['items']:
+ out.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`'))
+ out.append('')
+ out.extend(['<!-- generated:end -->', ''])
+ body = '\n'.join(out)
+ if os.path.exists(MD):
+ old = open(MD, encoding='utf-8').read()
+ m = re.search(r'<!-- generated:start -->[\s\S]*?<!-- generated:end -->\n?', old)
+ if m:
+ body = old[:m.start()] + body + old[m.end():]
+ with open(MD, 'w', encoding='utf-8') as fh:
+ fh.write(body)
+ return len(body)
+
+
+def main():
+ page, rows = build()
+ if '--check' in sys.argv:
+ with open(PAGE, encoding='utf-8') as fh:
+ doc = fh.read()
+ m = re.search(r'"currentVersion"\s*:\s*"([^"]+)"', doc)
+ have = m.group(1) if m else 'none'
+ if have != page['currentVersion']:
+ print('changelog stale: page says %s, build is %s' % (have, page['currentVersion']))
+ return 1
+ print('changelog current: %s (%d entries)' % (have, len(page['entries'])))
+ return 0
+ size = write_page(page)
+ mdsize = write_md(page, rows)
+ print('changelog: %d entries, %d commits, %s -> page %d bytes, md %d bytes'
+ % (len(page['entries']), len(rows), page['stats']['versions'], size, mdsize))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py
index c28fb8b..8cc7c5a 100644
--- a/scripts/newos4-maintain.py
+++ b/scripts/newos4-maintain.py
@@ -119,6 +119,33 @@ def check_course():
r = run([sys.executable, 'scripts/course-validate.py'], cwd=ROOT)
add('PASS' if r.returncode == 0 else 'FAIL', 'course ' + ('valid' if r.returncode == 0 else 'INVALID: ' + r.stdout[-160:]))
+def check_changelog():
+ """The changelog page must describe the build that is actually running.
+
+ docs/changelog.html was hand-built from VFS notes and had stopped at v4.6.101 while
+ the app was on v4.6.119 -- a docs page that lags the release is worse than no page,
+ because it reads as current. build-changelog.py derives every entry from git, so the
+ only thing left to check is whether it has been run since the last version bump.
+ """
+ script = os.path.join(ROOT, 'scripts/build-changelog.py')
+ if not os.path.exists(script):
+ add('FAIL', 'changelog: scripts/build-changelog.py missing')
+ return
+ r = run([sys.executable, script, '--check'], cwd=ROOT)
+ if r.returncode == 0:
+ add('PASS', 'changelog: ' + (r.stdout.strip() or 'current'))
+ return
+ stale = (r.stdout or r.stderr).strip().splitlines()
+ stale = stale[-1][:150] if stale else 'stale'
+ if FIX:
+ r2 = run([sys.executable, script], cwd=ROOT)
+ if r2.returncode == 0:
+ add('PASS', 'changelog regenerated: ' + (r2.stdout.strip()[:130] or 'ok'))
+ return
+ add('FAIL', 'changelog regen failed: ' + (r2.stderr.strip()[:150] or '?'))
+ return
+ add('FAIL', 'changelog ' + stale)
+
def check_help_coverage():
"""Every command the code registers must appear in help.html (2026-10-22).
@@ -188,7 +215,7 @@ def check_links():
def main():
os.chdir(ROOT)
bad = syntax_failures() # computed once, gates the bundle rebuild
- check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links()
+ check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links(); check_changelog()
fails = [m for l, m in issues if l == 'FAIL']
print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else ''))
for l, m in issues: print(f'{l}: {m}')