geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/web/jobs.mjs CHANGED
@@ -1,26 +1,23 @@
1
1
  /**
2
- * The web console's BACKGROUND JOBS — `rituals/_web_jobs.py`'s `JobManager`, its process-tree
3
- * kill and the `action_commands` table.
2
+ * The web console's BACKGROUND JOBS — `JobManager`, its process-tree kill and the
3
+ * `actionTable`.
4
4
  *
5
5
  * ---------------------------------------------------------------------------------------
6
- * WHY THIS SPAWNS, WHEN NOTHING ELSE IN THE PORT IS ALLOWED TO.
6
+ * WHY THIS SPAWNS, WHEN NOTHING ELSE IN THIS REPO IS ALLOWED TO.
7
7
  *
8
- * `bin/geneseed-cli.mjs` carries a transitive `child_process` ALLOW-LIST
9
- * (`tests/test_hook_cli_parity.py::_ALLOWED_SPAWNS`) whose criterion is not "this verb
10
- * spawns" — `build` spawns on the reference and is refused there — but "there is no
11
- * in-process equivalent, and the spawned thing is not this program". This module spawns THIS
12
- * PROGRAM, which reads like the passthrough the whole port exists to remove, so the argument
13
- * has to be made rather than assumed.
8
+ * `bin/geneseed-cli.mjs` carries a transitive `child_process` ALLOW-LIST (`ALLOWED_SPAWNS`
9
+ * in `tests/unit/hook_cli.test.mjs`) whose criterion is not "this verb spawns" but "there
10
+ * is no in-process equivalent, and the spawned thing is not this program". This module
11
+ * spawns THIS PROGRAM, which reads like the passthrough the ban exists to prevent, so the
12
+ * argument has to be made rather than assumed.
14
13
  *
15
- * ISOLATION IS THE DISCRIMINATOR, and it is a property of the RUNTIME, not of the verbs.
16
- * The reference's daemon is threaded: `start()` hands the work to a `threading.Thread` and
17
- * the HTTP server keeps answering from another. Node's server is single-threaded and every
18
- * ported `cmdX` is synchronous, so calling one in-process would block the event loop for the
19
- * whole run — and the console's only progress mechanism is `web/src/api/jobs.js` polling
20
- * `GET /api/jobs/<id>`. An in-process job therefore freezes the poll that exists to watch it,
21
- * which is worse than not showing progress: the UI cannot tell a running job from a hung
22
- * daemon. And a `process.exit` anywhere in a callee kills the DAEMON instead of the job —
23
- * `cmdRebuildAll` already had to grow a throwing `die` for exactly that reason (P5f).
14
+ * ISOLATION IS THE DISCRIMINATOR. Node's server is single-threaded and every ported `cmdX`
15
+ * is synchronous, so calling one in-process would block the event loop for the whole run —
16
+ * and the console's only progress mechanism is `web/src/api/jobs.js` polling
17
+ * `GET /api/jobs/<id>`. An in-process job therefore freezes the poll that exists to watch
18
+ * it, which is worse than not showing progress: the UI cannot tell a running job from a
19
+ * hung daemon. And a `process.exit` anywhere in a callee kills the DAEMON instead of the
20
+ * job — `cmdRebuildAll` already had to grow a throwing `die` for exactly that reason.
24
21
  *
25
22
  * `worker_threads` IS THE ALTERNATIVE AND IT IS WORSE. It restores concurrency, but a worker
26
23
  * re-imports the world per job (the generator, the renderer, every host module), needs a
@@ -29,54 +26,42 @@
29
26
  * takes the worker down mid-write, which is the same truncation with more machinery. Strictly
30
27
  * more work for strictly less isolation.
31
28
  *
32
- * THE EXIT CODE IS EASIER OUT OF PROCESS, not harder. `_run` reads `p.returncode` and stops
33
- * the chain on the first non-zero. In-process that would be a return value threaded through
34
- * every verb's error paths, several of which currently end at `process.exitCode`.
29
+ * THE EXIT CODE IS EASIER OUT OF PROCESS, not harder. `_run` awaits the child's exit code
30
+ * and stops the chain on the first non-zero. In-process that would be a return value
31
+ * threaded through every verb's error paths, several of which currently end at
32
+ * `process.exitCode`.
35
33
  *
36
- * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build args>`
37
- * for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
38
- * by a different entry. `process.execPath` is the running interpreter by absolute path, so
39
- * stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
34
+ * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build
35
+ * args>` for the three rows that run the GENERATOR, the same program by a different entry.
36
+ * `process.execPath` is the running interpreter by absolute path, so stripping `node` off
37
+ * PATH cannot change what starts. Nothing here can name `python`.
40
38
  *
41
39
  * THE SECOND SPAWN IS `taskkill`, and it is a different argument. `cancel()` must kill the
42
- * job's whole process TREE — the reference's comment says why: killing only the direct child
43
- * leaves ITS children holding the stdout pipe, the read loop stays blocked and the job wedges
44
- * on `running` forever. POSIX gets that in-process (`detached: true` makes a process group and
45
- * `process.kill(-pid)` signals it). Windows has no process-group kill in Node's standard
46
- * library at all, and `taskkill /T` is what the reference reaches for too. It is a machine
47
- * primitive, not this program.
40
+ * job's whole process TREE: killing only the direct child leaves ITS children holding the
41
+ * stdout pipe, the read loop stays blocked and the job wedges on `running` forever. POSIX
42
+ * gets that in-process (`detached: true` makes a process group and `process.kill(-pid)`
43
+ * signals it). Windows has no process-group kill in Node's standard library at all, so
44
+ * `taskkill /T` is a machine primitive here, not this program.
48
45
  *
49
46
  * ---------------------------------------------------------------------------------------
50
- * THE PARTITION — all eight action rows cross, and the declaration outlives them.
51
- *
52
- * `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`, `link` and `unlink` each
53
- * name a verb `bin/geneseed-cli.mjs` runs. `link`/`unlink` were the last two: they ran
54
- * `harness.py link|unlink` until P10b, and a runner that spawned the Python harness would
55
- * have put Python back into a "no Python needed" install.
56
- *
57
- * `NOT_PORTED_ACTIONS` IS EMPTY AND STILL DECLARED, which is the point of it: a ninth action
58
- * added later cannot quietly answer "unknown action", because `tests/unit/web_jobs.test.mjs`
59
- * holds the two sets against `actionCommands`' own keys.
47
+ * THE ACTION SURFACE — `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`,
48
+ * `link` and `unlink` each name a verb `bin/geneseed-cli.mjs` runs, plus the three
49
+ * `INLINE_ACTIONS` the dispatcher answers without the table. `tests/unit/web_jobs.test.mjs`
50
+ * holds the union against a written-out list, so a ninth action cannot appear unchecked.
60
51
  *
61
52
  * ---------------------------------------------------------------------------------------
62
53
  * THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
63
54
  *
64
- * `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not bounce
65
- * the daemon mid-job — which killed the job's own tracking and left the console on `running`
66
- * forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
67
- * P8, and a contract written after the fact is a contract that was broken once first. P8a is
68
- * where `js/maintain/update.mjs` began honouring it, and P8c is where the `update` row that triggers
69
- * it crossed — so it is a live pair now rather than a promise.
55
+ * `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not
56
+ * bounce the daemon mid-job — which would kill the job's own tracking and leave the
57
+ * console on `running` forever. Set here and read by `js/maintain/update.mjs`.
70
58
  *
71
- * `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
72
- * is block-buffered into the pipe and the console looks stuck. THE EQUIVALENT THOUGHT FOR
73
- * NODE, which is the port and not the copy: there is no unbuffering flag to set, because
74
- * Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is queued on
75
- * the stream and flushed by the event loop. The analogous hazard is a child calling
76
- * `process.exit()` with writes still queued, which truncates them; both `bin/` entries set
77
- * `process.exitCode` and return instead, which is what makes the flag unnecessary rather than
78
- * merely inapplicable. So the variable is NOT set: it would be cargo, and the honest
79
- * statement is this paragraph.
59
+ * NO UNBUFFERING FLAG IS SET, and that absence is deliberate rather than an oversight:
60
+ * Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is
61
+ * queued on the stream and flushed by the event loop. The real hazard here is a child
62
+ * calling `process.exit()` with writes still queued, which truncates them; both `bin/`
63
+ * entries set `process.exitCode` and return instead, which is what makes an unbuffering
64
+ * flag unnecessary rather than merely inapplicable.
80
65
  *
81
66
  * `MEM_CAP` truncates a runaway job's output FROM THE FRONT, so a job that prints forever
82
67
  * cannot eat the daemon's memory. `OUTPUT_CAP` is the smaller cap the history FILE keeps.
@@ -84,44 +69,34 @@
84
69
  * ---------------------------------------------------------------------------------------
85
70
  * TWO DIVERGENCES, DECLARED.
86
71
  *
87
- * 1. THE TWO STREAMS ARE TWO PIPES. `stderr=subprocess.STDOUT` merges them in the OPERATING
88
- * SYSTEM, so the reference's interleaving is exact. Node's `stdio` cannot name an
89
- * already-created pipe, so `stdout` and `stderr` are read separately and appended in
90
- * arrival order — identical for a line-buffered child writing to one stream (which is
91
- * every ported row: `rebuild-all` writes stdout only, `diff` writes stderr only), and
92
- * potentially reordered for a child interleaving both within one tick. No cell can see it,
93
- * and it is named here rather than left to be discovered.
94
- * 2. `_append`'s cap slices UTF-16 code UNITS where Python slices code POINTS. It differs only
95
- * past two million characters and only by a lone surrogate at the truncation front. The
96
- * history file's cap does the exact thing (`Array.from`), because it runs once per job
97
- * rather than once per line — doing it here would make a runaway job quadratic, which is
98
- * the failure the cap exists to prevent.
99
- *
100
- * NOT PORTED: `JobManager.wait`. It has no caller in `rituals/` — `tests/test_web.py` is its
101
- * only consumer, twenty times over. Its Node twin is owed the day a Node-side corpus drives
102
- * the manager directly, which is P9's; until then it would be a function with no caller and no
103
- * cell, which is the shape this port keeps being bitten by.
72
+ * 1. THE TWO STREAMS ARE TWO PIPES, read and appended separately in arrival order — exact
73
+ * for a line-buffered child writing to only one stream (which is every row today:
74
+ * `rebuild-all` writes stdout only, `diff` writes stderr only), and potentially
75
+ * reordered for a child interleaving both within one tick. Named here rather than left
76
+ * to be discovered, should a future row ever write to both.
77
+ * 2. `_append`'s cap slices UTF-16 code UNITS rather than code POINTS, unlike `tailChars`
78
+ * (used by the history file's cap via `Array.from`), which differs only past two
79
+ * million characters and only by a lone surrogate at the truncation front. The history
80
+ * cap can afford code-point precision because it runs once per job; doing that here,
81
+ * once per streamed chunk, would make a runaway job quadratic — the exact failure the
82
+ * cap exists to prevent.
104
83
  */
105
84
  import { spawn, spawnSync } from 'node:child_process';
106
85
  import { randomBytes } from 'node:crypto';
107
- import { statSync } from 'node:fs';
108
86
  import { StringDecoder } from 'node:string_decoder';
109
87
  import path from 'node:path';
110
88
 
111
89
  import { ROOT, PACK_ORDER } from '../build/source.mjs';
112
90
  import { setupBuildArgs } from '../build/generate.mjs';
113
91
  import { isDict } from '../hosts/mcp.mjs';
114
- import { readText, writeText } from '../lib/fs.mjs';
92
+ import { readText, writeText, isFile } from '../lib/fs.mjs';
115
93
  import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
116
94
 
117
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
118
-
119
95
  /**
120
- * `_kill_job_tree` — terminate a job's WHOLE process tree.
96
+ * Terminate a job's WHOLE process tree.
121
97
  *
122
- * NEVER THROWS, as the reference's two nested `except`s do not: it runs from `cancel()` and
123
- * from the crash path in `_run`'s `catch`, and an exception in either would leave the child
124
- * alive with the manager believing it dead.
98
+ * NEVER THROWS: it runs from `cancel()` and from the crash path in `_run`'s `catch`, and an
99
+ * exception in either would leave the child alive with the manager believing it dead.
125
100
  */
126
101
  export function killJobTree(child) {
127
102
  try {
@@ -131,30 +106,25 @@ export function killJobTree(child) {
131
106
  timeout: 15000, windowsHide: true, stdio: 'ignore',
132
107
  });
133
108
  } else {
134
- // `os.killpg(p.pid, SIGTERM)`. The group exists because `_run` spawns with
135
- // `detached: true`, which is `start_new_session=True`.
109
+ // The process GROUP exists because `_run` spawns with `detached: true`.
136
110
  process.kill(-child.pid, 'SIGTERM');
137
111
  }
138
112
  } catch {
139
113
  try {
140
114
  child.kill();
141
- } catch { /* `except OSError: pass` */ }
115
+ } catch { /* last resort already failed; nothing more to try */ }
142
116
  }
143
117
  }
144
118
 
145
119
  /**
146
- * `Popen(..., text=True, encoding="utf-8", errors="replace")` — the decode Python's text mode
147
- * performs and Node's pipes do not.
120
+ * Decodes a child's raw stdout/stderr bytes the way a text-mode read would: UTF-8, and
121
+ * newlines folded regardless of the platform.
148
122
  *
149
- * TWO THINGS, and the second is the one a port drops. `StringDecoder` holds a partial UTF-8
150
- * sequence across a chunk boundary (a naive `buf.toString()` per chunk turns one multi-byte
151
- * character split across two reads into two replacement characters — the exact defect P5i
152
- * found when a helper reached its second owner). And Python decodes in UNIVERSAL NEWLINES
153
- * mode, so `\r\n` and a lone `\r` are both `\n` before the caller ever sees them — while Node
154
- * hands back the bytes. The reference's children are Python processes writing CRLF on Windows;
155
- * the twin's are Node processes doing the same. Without the fold the job output would carry
156
- * CRLF on one side and LF on the other, which is P5b's eighth coverage hole with the
157
- * normalising transport removed.
123
+ * TWO THINGS. `StringDecoder` holds a partial UTF-8 sequence across a chunk boundary — a
124
+ * naive `buf.toString()` per chunk would turn one multi-byte character split across two
125
+ * reads into two replacement characters. And CRLF / a lone CR are both folded to LF before
126
+ * the caller ever sees them: spawned children write CRLF on Windows, and without the fold
127
+ * the job output would carry CRLF on one platform and LF on the other.
158
128
  *
159
129
  * The pending `\r` is held rather than translated on the spot: a `\r\n` split across two
160
130
  * chunks would otherwise become two newlines.
@@ -186,25 +156,26 @@ function textDecoder(sink) {
186
156
  };
187
157
  }
188
158
 
189
- /** `s[-n:]` by CODE POINT, which is what Python slices. */
159
+ /** Last `n` CODE POINTS (not UTF-16 units) of a string. */
190
160
  const tailChars = (s, n) => (s.length <= n ? s : Array.from(s).slice(-n).join(''));
191
161
 
192
- /** The signals `killJobTree` sends, for the `-SIGNUM` return code Python reports. */
162
+ /** The signals `killJobTree` sends, mapped for the `-SIGNUM` return code convention. */
193
163
  const SIGNUM = { SIGTERM: 15, SIGKILL: 9, SIGINT: 2, SIGHUP: 1 };
194
164
 
195
165
  /**
196
- * `_web_jobs.JobManager` — one mutating action at a time, in the background, output captured.
166
+ * One mutating action at a time, in the background, output captured.
167
+ *
168
+ * A CLASS rather than a factory-of-a-plain-object like `webState`, because this one has
169
+ * real state and eleven methods over it; `webState` is a factory because its only
170
+ * behaviour is two cached getters.
197
171
  *
198
- * A CLASS rather than P6b's factory-of-a-plain-object, because this one has real state and
199
- * eleven methods over it; `webState` is a factory because its only behaviour is two cached
200
- * getters.
172
+ * NO LOCK GUARDS `_jobs`, `_busy` and `_procs`, and that absence is deliberate: Node has
173
+ * one thread and no pre-emption inside a synchronous block, so every critical section here
174
+ * is already atomic — a mutex would be a no-op object claiming a hazard that cannot occur.
175
+ * What matters instead is which sequences must not be interrupted, and none of them await.
201
176
  *
202
- * THE LOCK IS GONE AND ITS ABSENCE IS THE PORT. `self._lock` guards `_jobs`, `_busy` and
203
- * `_procs` against the run thread and the request threads. Node has one thread and no
204
- * pre-emption inside a synchronous block, so every critical section here is already atomic —
205
- * a mutex would be a no-op object whose presence claimed a hazard that cannot occur. What the
206
- * lock's placement DOES carry over is which sequences must not be interrupted, and none of
207
- * them awaits.
177
+ * DELIBERATELY NO `wait()`: nothing here calls it, and an untested, caller-less method is
178
+ * exactly the shape this repo keeps getting bitten by.
208
179
  */
209
180
  export class JobManager {
210
181
  static HISTORY_MAX = 20;
@@ -242,14 +213,14 @@ export class JobManager {
242
213
  if (!this._historyPath) return;
243
214
  const jobs = [...this._jobs.values()].filter((j) => j.status !== 'running')
244
215
  .map((j) => ({ ...j }));
245
- // `sort(key=...)` is STABLE in both languages, which matters: two jobs started inside the
246
- // same clock tick keep insertion order rather than swapping between the two runtimes.
216
+ // STABLE sort matters: two jobs started inside the same clock tick keep insertion
217
+ // order rather than swapping unpredictably.
247
218
  jobs.sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
248
219
  const kept = jobs.slice(-JobManager.HISTORY_MAX)
249
220
  .map((j) => ({ ...j, output: tailChars(String(j.output), JobManager.OUTPUT_CAP) }));
250
221
  try {
251
222
  writeText(this._historyPath, jsonDumpsCompact(kept, { bareInts: true }));
252
- } catch { /* `except OSError: pass` — a console that cannot persist still runs */ }
223
+ } catch { /* a console that cannot persist its history still runs */ }
253
224
  }
254
225
 
255
226
  /** Last `n` jobs, oldest first — the order the console appends in. */
@@ -260,9 +231,8 @@ export class JobManager {
260
231
  }
261
232
 
262
233
  /**
263
- * `start(action, *cmds, on_done=None)` — `cmds` is a LIST OF ARGVS, run in order, stopping
264
- * at the first failure. `null` when a job is already running, which the HTTP layer maps
265
- * to 409.
234
+ * `cmds` is a LIST OF ARGVS, run in order, stopping at the first failure. `null` when a
235
+ * job is already running, which the HTTP layer maps to 409.
266
236
  */
267
237
  start(action, cmds, onDone = null) {
268
238
  if (this._busy) return null;
@@ -277,8 +247,8 @@ export class JobManager {
277
247
  started: Date.now() / 1000,
278
248
  duration: null,
279
249
  });
280
- // The reference's `threading.Thread(...).start()`: hand the work to the scheduler and
281
- // return the id NOW, so the 202 goes out before the child has produced a byte.
250
+ // Hand the work to the scheduler and return the id NOW, so the 202 goes out before the
251
+ // child has produced a byte.
282
252
  void this._run(jid, cmds, onDone);
283
253
  return jid;
284
254
  }
@@ -303,9 +273,9 @@ export class JobManager {
303
273
  p = spawn(cmd[0], cmd.slice(1), {
304
274
  cwd: ROOT,
305
275
  stdio: ['ignore', 'pipe', 'pipe'],
306
- // `harness.NO_WINDOW`: no console window for a child of a windowless daemon.
276
+ // No console window for a child of a windowless daemon.
307
277
  windowsHide: true,
308
- // `start_new_session=True` on POSIX — the process GROUP `killJobTree` signals.
278
+ // POSIX only: the process GROUP `killJobTree` signals.
309
279
  detached: process.platform !== 'win32',
310
280
  env: { ...process.env, GENESEED_WEB_JOB: '1' },
311
281
  });
@@ -323,16 +293,15 @@ export class JobManager {
323
293
  this._append(jid, `\n[web] job crashed: ${e && e.message ? e.message : e}`);
324
294
  rc = 1;
325
295
  // Don't orphan the child on a crash of OUR side: kill its tree, or it lingers.
326
- // `p.pid` is undefined when the spawn itself failed, which is the `p is not None`
327
- // arm the reference cannot reach at all (its `Popen` raises before the assignment).
296
+ // `p.pid` is undefined when the spawn itself failed, which this check guards against.
328
297
  if (p !== null && p.pid && p.exitCode === null && p.signalCode === null) killJobTree(p);
329
298
  } finally {
330
299
  this._procs.delete(jid);
331
300
  const j = this._jobs.get(jid);
332
301
  j.status = rc === 0 ? 'done' : 'failed';
333
302
  j.returncode = rc;
334
- // `round(x, 1)`. ponytail: JS rounds a .x5 tie away from zero where Python rounds to
335
- // even — unreachable for a wall-clock delta, and the field is a display value.
303
+ // ponytail: `Math.round` breaks a .x5 tie away from zero rather than to even —
304
+ // unreachable for a wall-clock delta, and the field is a display value only.
336
305
  j.duration = Math.round((Date.now() / 1000 - Number(j.started)) * 10) / 10;
337
306
  this._busy = false;
338
307
  this._saveHistory();
@@ -345,12 +314,11 @@ export class JobManager {
345
314
  }
346
315
 
347
316
  /**
348
- * `for line in p.stdout: ...` then `p.wait()` — read to EOF, then the exit code.
317
+ * Read to EOF, then the exit code.
349
318
  *
350
- * BOTH `close` AND the two stream ENDS are awaited, not just `exit`: `exit` fires when the
351
- * process dies and `close` when its stdio have also closed, and resolving on the former
352
- * would let a job report `done` with its last lines still in the pipe. That is the same
353
- * ordering the reference gets for free by draining `p.stdout` to EOF before `p.wait()`.
319
+ * Resolves on `close`, not `exit`: `exit` fires when the process dies and `close` only
320
+ * once its stdio have also closed, and resolving on the former would let a job report
321
+ * `done` with its last lines still sitting in the pipe.
354
322
  */
355
323
  _pump(jid, p) {
356
324
  return new Promise((resolve, reject) => {
@@ -363,10 +331,9 @@ export class JobManager {
363
331
  p.on('close', (code, signal) => {
364
332
  out.end();
365
333
  err.end();
366
- // `p.returncode` for a signalled child is `-SIGNUM`, and Node splits the exit code
367
- // and the signal into two arguments with the code `null` when a signal won. Only
368
- // `cancel()` produces one, and only on POSIX — which no cell can reach, so the map
369
- // covers what this program SENDS rather than every signal that exists.
334
+ // Node splits the exit code and the signal into two arguments, with the code
335
+ // `null` when a signal won. Only `cancel()` produces one, and only on POSIX, so the
336
+ // map above covers what this program SENDS rather than every signal that exists.
370
337
  resolve(code === null ? -(SIGNUM[signal] ?? 1) : code);
371
338
  });
372
339
  });
@@ -392,57 +359,29 @@ export class JobManager {
392
359
  }
393
360
 
394
361
  /**
395
- * `_web_jobs.action_commands` — action name -> a list of argvs, each a separate step, stopping
396
- * at the first failure.
362
+ * Action name -> a list of argvs, each a separate step, stopping at the first failure.
397
363
  *
398
- * THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside it
399
- * (the rule `POST_ROUTES` established one file over, for the reason M23 gave).
364
+ * THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside
365
+ * it — the same rule `POST_ROUTES` establishes one file over.
400
366
  *
401
367
  * `build` renders the DEPLOYED install in its detected theme + emit + footprint + posture +
402
- * mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays all
403
- * five. `update` and `export` self-resolve the deployed theme downstream and take no args.
368
+ * mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays
369
+ * all five. `update` and `export` self-resolve the deployed theme downstream and take no
370
+ * args.
404
371
  *
405
- * THE ARGV HEAD IS THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
406
- * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`,
407
- * which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
408
- * normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
409
- * each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
410
- * bug would live.
372
+ * THE ARGV HEAD IS `process.execPath` + `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`
373
+ * for the rows that run the generator) — the running interpreter by absolute path and this
374
+ * repo's own CLI, never a name any other runtime could shadow.
411
375
  */
412
376
  const NODE = () => process.execPath;
413
377
  const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
414
378
  const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
415
379
 
416
380
  /**
417
- * The rows whose VERB has not crossed. **EMPTY SINCE P10b, AND STILL DECLARED.**
418
- *
419
- * P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
420
- * that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
421
- * phase that also had to decide whether those two verbs should exist under npm at all
422
- * (`js/hosts/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
423
- * the console's Settings buttons POST here, and only a ported verb can answer them).
424
- *
425
- * WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
426
- * `tests/test_web_jobs.py` reads this set against `action_commands`' own keys, and an empty
427
- * set is the strongest form that check ever takes: it asserts that EVERY action the reference
428
- * dispatches on is answered by the Node runner. Deleting the declaration would delete that
429
- * assertion, and the next unported action would come back as a silent 404 — the thing the
430
- * 501 exists to prevent.
431
- *
432
- * A 501 AND NOT THE 404 THE TABLE WOULD PRODUCE. `action_commands` returns `None` for an
433
- * unknown action and the reference maps that to `{"error": "unknown action <x>"}` at 404 — so
434
- * a real-but-unported action falling through would answer the same thing a typo gets. With
435
- * this set empty the 501 branch is now UNREACHABLE by construction, which is exactly what the
436
- * dispatcher probe in `tests/test_web_server.py` asserts: every real action answers 202 and
437
- * only an invented one answers 404.
438
- */
439
- export const NOT_PORTED_ACTIONS = new Set();
440
-
441
- /**
442
- * The three actions `_post_routes` answers INLINE, without consulting the table: `restore` is
443
- * synchronous and returns a result rather than a job id, and `install`/`deploy` resolve their
444
- * argv from the request body first. Declared so the `ast` cross-check covers every `action ==`
445
- * the reference dispatches on, not only the table's keys.
381
+ * The three actions the dispatcher answers INLINE, without consulting the table above:
382
+ * `restore` is synchronous and returns a result rather than a job id, and `install`/`deploy`
383
+ * resolve their argv from the request body first. Declared so the surface check covers every
384
+ * action the dispatcher answers, not only the table's keys.
446
385
  */
447
386
  export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
448
387
 
@@ -472,10 +411,9 @@ function actionTable({
472
411
  // Rebuild EVERY active install in place (each in its own theme+emit). The per-install
473
412
  // resolution lives in the rebuild-all subcommand, so the web layer threads no theme/emit.
474
413
  'build-all': [[NODE(), CLI(), 'rebuild-all']],
475
- // P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
476
- // argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
477
- // the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
478
- // job, set by `_run` above and read by `js/maintain/update.mjs` — P6g's contract with P8, honoured.
414
+ // `update` is the action name here; `upgrade` is the VERB it runs. The daemon bounce
415
+ // this job must not do itself is `GENESEED_WEB_JOB`'s job, set by `_run` above and read
416
+ // by `js/maintain/update.mjs`.
479
417
  update: [[NODE(), CLI(), 'upgrade']],
480
418
  export: [[NODE(), CLI(), 'diff', '--out']],
481
419
  // Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
@@ -488,10 +426,10 @@ function actionTable({
488
426
 
489
427
  export function actionCommands(action, opts = {}) {
490
428
  const table = actionTable(opts);
491
- // `dict.get(action)` — `None` for anything the table does not name, which the HTTP layer
492
- // maps to `{"error": "unknown action <x>"}` at 404.
429
+ // `null` for anything the table does not name, which the HTTP layer maps to
430
+ // `{"error": "unknown action <x>"}` at 404.
493
431
  return Object.hasOwn(table, action) ? table[action] : null;
494
432
  }
495
433
 
496
- /** `action_commands`' own keys — the table IS the declaration, so this cannot drift from it. */
434
+ /** `actionTable`'s own keys — the table IS the declaration, so this cannot drift from it. */
497
435
  export const PORTED_ACTIONS = Object.keys(actionTable());