geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -24,8 +24,16 @@
24
24
  * for the marker filenames they need ("literals keep the hook dependency-free"), and
25
25
  * `bin/build-driver.mjs` could not be imported anyway, since its last statement runs the
26
26
  * generator. What that rule does NOT license is a second copy of a shared resolver under a
27
- * different name — see the primitives section below, where P5d found one that had silently
28
- * diverged from its twin.
27
+ * different name — see `./hooks-prims.mjs`, where P5d found one that had silently diverged
28
+ * from its twin.
29
+ *
30
+ * ONE MODULE PER VERB, BY FREQUENCY. THIS file is the gates — `git-gate`, `rule-gate`,
31
+ * `tool-gate`, and the `ask`/ledger/`guardGate` machinery they share — because they run on
32
+ * EVERY tool call. `context` (once per session) is `./hooks-context.mjs`, `learn` (on Stop) is
33
+ * `./hooks-learn.mjs` — the one module here that spawns — and the memory store's file format
34
+ * is `./memory-files.mjs`, which does not, so the CLI and the web console can read the store
35
+ * without reaching `child_process`. `bin/geneseed-hook.mjs` imports the verb's module only
36
+ * once it knows the verb, so a gate call parses no context or learn code at all.
29
37
  *
30
38
  * THE CONTRACT EVERY VERB HOLDS. Exit 0 on every path, and signal through stdout. A hook
31
39
  * that fails is a tool call that fails, so an unreadable payload, a missing file, a
@@ -39,547 +47,13 @@
39
47
  * and every file left behind. Absolute rather than compared is what let it survive the deletion
40
48
  * of the implementation it was once compared against.
41
49
  */
42
- import { existsSync, statSync, readFileSync, readdirSync, mkdirSync,
43
- appendFileSync, writeFileSync }
44
- from 'node:fs';
50
+ import { readFileSync, appendFileSync, writeFileSync } from 'node:fs';
45
51
  import path from 'node:path';
46
- import { spawnSync } from 'node:child_process';
47
- import { createHash } from 'node:crypto';
48
- import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
49
- import { jsonDumpsCompact } from '../lib/json.mjs';
50
- import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
51
- import { NO_WINDOW } from '../lib/proc.mjs';
52
- // `harness status` reports the memory store, and `bin/geneseed-cli.mjs` cannot import THIS
53
- // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
54
- // spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
55
- // with it went this file's private `opencodeConfigDir`, whose only caller it was.
56
- import {
57
- GATE_LEDGER, resolveMemoryDir, resolvePath, sovereignBypass,
58
- } from './hosts.mjs';
52
+ import { printOut as out, printErr as err } from '../lib/fs.mjs';
53
+ import { normcase } from '../lib/paths.mjs';
54
+ import { GATE_LEDGER, resolvePath, sovereignBypass } from './hosts.mjs';
59
55
  import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
60
-
61
- const ROOT = path.resolve(import.meta.dirname, '../..');
62
-
63
- // ---- Python path and text primitives -------------------------------------------------
64
- // Small, and each one is here because a JS-idiomatic equivalent differs observably.
65
- //
66
- // THE RULE, CORRECTED IN P5d. It used to read "duplicating four helpers is cheaper than
67
- // coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
68
- // exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
69
- // implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
70
- // `toPlatformPath`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
71
- // where `PurePath` keeps it, fixed `toPlatformPath`, and gated it with a 25-path corpus — and
72
- // none of that reached this file, because a corpus finds what it is pointed at and nothing
73
- // named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
74
- // `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
75
- // down for a repo Python still gates. `context/bypass-does-not-match-a-dotdot-entry` is
76
- // the cell.
77
- //
78
- // So the line is not "duplicate rather than couple" — it is "one owner for anything that
79
- // reproduces a language primitive, wherever it is used". `fs.mjs` carries no
80
- // `child_process` and costs nothing at hook latency. What stays local is what is genuinely
81
- // only the hook's (`fnmatch`, the transcript readers, `asOsError`'s errno table).
82
- //
83
- // AND `expanduser` WAS THE SECOND VIOLATION, closed here. This file carried its own, which
84
- // returned a `~user` path UNCHANGED while `js/hosts/hosts.mjs`'s single owner REFUSES the form —
85
- // an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
86
- // was quietly exempt from because it had a private copy. It now imports the owner. The
87
- // refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
88
- // call. For the two GATES that containment is not enough, and `guardGate` below is the
89
- // contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
90
- // found nothing" would be the SAME observation to Claude — a crash would be a silent allow.
91
- // A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` comes from
92
- // `js/hosts/hosts.mjs`, which this module already imports — it had kept a private copy.
93
-
94
- // NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
95
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
96
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
97
-
98
- /** `(p, *p.parents)` — the path and every ancestor, nearest first. */
99
- function selfAndParents(p) {
100
- const out = [p];
101
- let cur = p;
102
- for (;;) {
103
- const up = path.dirname(cur);
104
- if (up === cur) return out;
105
- out.push(up);
106
- cur = up;
107
- }
108
- }
109
-
110
- /**
111
- * `str.splitlines()`.
112
- *
113
- * `split('\n')` is not it: Python breaks on eleven boundaries, and a transcript or a
114
- * memory file carrying a form feed or U+2028 would be split by one implementation and not
115
- * the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
116
- * `""` is none).
117
- */
118
- function splitLines(text) {
119
- if (!text) return [];
120
- const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
121
- if (parts.length && parts[parts.length - 1] === '') parts.pop();
122
- return parts;
123
- }
124
-
125
- /** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
126
- function splitWords(s) {
127
- return s.split(/\s+/).filter(Boolean);
128
- }
129
-
130
- /** `s[-n:]` in CODE POINTS. JS slices UTF-16 units and can cut a surrogate pair in half. */
131
- function tail(s, n) {
132
- const cps = Array.from(s);
133
- return cps.length <= n ? s : cps.slice(-n).join('');
134
- }
135
-
136
- /**
137
- * `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
138
- *
139
- * This is a compared string, not a log line: `cmd_context` prints it verbatim when an
140
- * eager entry is missing, so `ENOENT: no such file or directory, open '…'` (Node's
141
- * wording, Node's quoting, no errno) is a whole-line difference in that cell. Only the
142
- * codes a verb here can actually raise are mapped; anything else falls back to Node's
143
- * message, which is at least honest about being different.
144
- */
145
- const _ERRNO = {
146
- ENOENT: [2, 'No such file or directory'],
147
- EACCES: [13, 'Permission denied'],
148
- EISDIR: [21, 'Is a directory'],
149
- ENOTDIR: [20, 'Not a directory'],
150
- EPERM: [1, 'Operation not permitted'],
151
- };
152
-
153
- function asOsError(e, filename) {
154
- const hit = _ERRNO[e && e.code];
155
- if (!hit) return String((e && e.message) || e);
156
- // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
157
- return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
158
- .replace(/\\"/g, '"')}`;
159
- }
160
-
161
- /**
162
- * `fnmatch.fnmatch(name, pattern)` — case-folded on Windows on BOTH sides, and `*` matches
163
- * a separator (it is shell-glob semantics, not path-glob: `docs/*.md` really does select
164
- * `docs/nested/deep.md`, in both implementations).
165
- */
166
- function fnmatch(name, pattern) {
167
- return fnTranslate(normcase(pattern)).test(normcase(name));
168
- }
169
-
170
- function fnTranslate(pat) {
171
- let out = '';
172
- for (let i = 0; i < pat.length; i += 1) {
173
- const c = pat[i];
174
- if (c === '*') { out += '[\\s\\S]*'; continue; }
175
- if (c === '?') { out += '[\\s\\S]'; continue; }
176
- if (c === '[') {
177
- let j = i + 1;
178
- if (pat[j] === '!') j += 1;
179
- if (pat[j] === ']') j += 1;
180
- while (j < pat.length && pat[j] !== ']') j += 1;
181
- // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
182
- if (j >= pat.length) { out += '\\['; continue; }
183
- let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
184
- if (body.startsWith('!')) body = `^${body.slice(1)}`;
185
- out += `[${body}]`;
186
- i = j;
187
- continue;
188
- }
189
- out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190
- }
191
- return new RegExp(`^(?:${out})$`);
192
- }
193
-
194
- /** stdin, decoded as UTF-8 with Python's universal newlines. Absent stdin reads empty. */
195
- function readStdin() {
196
- try {
197
- return readFileSync(0, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
198
- } catch {
199
- return '';
200
- }
201
- }
202
-
203
- // The two output funnels, with Python's newline translation reproduced. They moved to
204
- // `js/lib/fs.mjs` in P5c — beside `writeText`, which is the same rule for FILES — when
205
- // `js/inspect/excludes.mjs` became the second caller; see that docblock for why the translation
206
- // exists and which gate can see it.
207
- const out = printOut;
208
- const err = printErr;
209
-
210
- // ======================================================================================
211
- // context — project-context discovery
212
- // ======================================================================================
213
-
214
- // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
215
- const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
216
- 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
217
- const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
218
- const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
219
- 'target', '.venv', '__pycache__', '.opencode', '.harness']);
220
-
221
- // The root instruction file a host already loads by itself. Injecting it again pays the
222
- // whole harness twice per session — and was doing exactly that until 2026-09: every hooked
223
- // host had its own root in EAGER_ROOT. Other tools' roots stay eager (a repo carrying a
224
- // hand-written AGENTS.md is worth showing Claude Code); only the host's OWN is dropped.
225
- // The OpenCode context plugin carries the same rule for AGENT.md/AGENTS.md/CLAUDE.md.
226
- // OpenClaude's root file is AGENTS.md, or CLAUDE.md when AGENTS.md is absent — resolved
227
- // per repo in `discoverContext`, since which one it loads depends on what is on disk.
228
- const NATIVE_ROOT = {
229
- claude: ['CLAUDE.md'], bob: ['AGENTS.md'],
230
- openclaude: ['AGENTS.md', 'CLAUDE.md'],
231
- };
232
-
233
- // Eager injection budget: a root README of 40k chars was going out whole, every session and
234
- // every compaction, and cost more than the harness itself. Per-file cut at the last line
235
- // break under the cap; files that would push the total over the budget are listed lazy.
236
- const EAGER_FILE_BYTES = 16 * 1024;
237
- const EAGER_TOTAL_BYTES = 48 * 1024;
238
-
239
- // The files the root instruction file names for session start, relative to the HARNESS dir
240
- // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
241
- // which is why none of them reached a hooked host before 2026-10. Injected here so the read does
242
- // not depend on the model remembering it. `context.json` is not in the list: it is a manifest,
243
- // honoured by `resolveContextSets` (the harness dir is its last candidate). The OpenCode
244
- // context plugin carries the same list.
245
- const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
246
- 'notebook/NOTEBOOK.md', 'wiki.jsonc'];
247
- // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
248
- // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
249
- // Hashes, not an import: stubs.mjs pulls in the build's writers, and this module loads on every
250
- // tool call. tests/unit/claude.test.mjs gates this set, and the plugin's copy, against the seeds.
251
- const SEED_SHA256 = new Set([
252
- '5c2e92fb1acde041e02d9ebf158460d8ff7a07cbc2b447859631604610130721', // user-rules.md
253
- '29e012c3c4349ea62a9082cbd04bb25599a0ec280d62c33f6e10742809471816', // PROFILE.md
254
- 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc
255
- '99c786049c260f6baf7aec68a5c0f59907861afe9b867da009440613bdddb907', // MEMORY.md
256
- '9acb5c9d9cb5dac572104480f05b48cc144e676869bd60e86d8a15b1f6308184', // NOTEBOOK.md
257
- ]);
258
- export { SESSION_FILES, SEED_SHA256 };
259
-
260
- /**
261
- * The session files present under `hookRoot` and changed from their seed, as `{ rel, abs, text }`
262
- * (text CRLF-folded, trailing newlines trimmed). `$GENESEED_WIKI` overrides the wiki declaration,
263
- * as it does on OpenCode. A missing or unreadable file is simply absent — this is best-effort
264
- * context, never a gate.
265
- */
266
- export function sessionFiles(hookRoot) {
267
- const found = [];
268
- for (const rel of SESSION_FILES) {
269
- let abs = path.join(hookRoot, rel);
270
- if (rel === 'wiki.jsonc' && process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) {
271
- abs = process.env.GENESEED_WIKI;
272
- }
273
- if (!isFile(abs)) continue;
274
- let text;
275
- try { text = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'); } catch { continue; }
276
- if (SEED_SHA256.has(createHash('sha256').update(text).digest('hex'))) continue;
277
- found.push({ rel, abs, text: text.replace(/\n+$/, '') });
278
- }
279
- return found;
280
- }
281
-
282
- const CLAUDE_MARKERS = ['.claude', '.bob', '.openclaude'];
283
- const GENESEED_MANIFEST = '.geneseed-manifest.json';
284
-
285
- /**
286
- * `_global_hook_standing_down` — project-bypasses-global.
287
- *
288
- * A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
289
- * of the SAME host sits at or above cwd, the project's hook injects and this one must not
290
- * double up.
291
- */
292
- export function globalHookStandingDown(hookRoot, cwd) {
293
- const marker = path.basename(hookRoot);
294
- if (!CLAUDE_MARKERS.includes(marker)) return false;
295
- for (const d of selfAndParents(cwd)) {
296
- const cand = path.join(d, marker);
297
- if (isFile(path.join(cand, GENESEED_MANIFEST))) {
298
- // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
299
- // the same install there and two different ones on Linux.
300
- return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
301
- }
302
- }
303
- return false;
304
- }
305
-
306
- // `sovereignBypass` moved to `./hosts.mjs` so `status` can read it without importing this
307
- // module's `child_process` binding; re-exported because the tests and the excludes writer's
308
- // docblock name it here.
309
- export { sovereignBypass };
310
-
311
- /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
312
- function disp(pathStr, root) {
313
- // `os.path.relpath` raises ValueError across drives and the Python falls back to the
314
- // path as given; `path.relative` silently returns an absolute path instead, which would
315
- // print a DIFFERENT string rather than the same one.
316
- if (path.parse(path.resolve(pathStr)).root.toLowerCase()
317
- !== path.parse(path.resolve(root)).root.toLowerCase()) {
318
- return pathStr;
319
- }
320
- return path.relative(root, pathStr).split(path.sep).join('/');
321
- }
322
-
323
- /** `Path.glob`/`rglob` order: `sorted()` over paths, which is case-folded on Windows. */
324
- function sortPaths(list) {
325
- return list.slice().sort(comparePaths);
326
- }
327
-
328
- function listDir(dir) {
329
- try {
330
- return readdirSync(dir);
331
- } catch {
332
- return [];
333
- }
334
- }
335
-
336
- function rglobMd(dir, acc = []) {
337
- for (const name of listDir(dir)) {
338
- const full = path.join(dir, name);
339
- if (isDir(full)) rglobMd(full, acc);
340
- else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
341
- }
342
- return acc;
343
- }
344
-
345
- /**
346
- * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
347
- * Root entry docs are eager; other root markdown, the doc trees and monorepo package
348
- * READMEs are lazy.
349
- */
350
- export function discoverContext(root, host = HOST) {
351
- const eager = new Map();
352
- const lazy = new Map();
353
- const native = host === 'openclaude' && isFile(path.join(root, 'AGENTS.md'))
354
- ? ['AGENTS.md'] : NATIVE_ROOT[host] || [];
355
- for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
356
- if (!isFile(full)) continue;
357
- const name = path.basename(full);
358
- if (native.includes(name)) continue;
359
- if (EAGER_ROOT.includes(name)) eager.set(full, null);
360
- else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
361
- }
362
- for (const d of LAZY_DIRS) {
363
- const sub = path.join(root, d);
364
- if (!isDir(sub)) continue;
365
- for (const md of sortPaths(rglobMd(sub))) {
366
- const parts = path.relative(root, md).split(/[\\/]/);
367
- if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
368
- if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
369
- }
370
- }
371
- for (const group of ['packages', 'apps']) {
372
- const base = path.join(root, group);
373
- if (!isDir(base)) continue;
374
- for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
375
- if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
376
- const readme = path.join(pkg, 'README.md');
377
- if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
378
- }
379
- }
380
- return [
381
- [...eager.keys()].map((p) => ({ path: p, description: '' })),
382
- [...lazy.keys()].filter((p) => !eager.has(p)).map((p) => ({ path: p, description: '' })),
383
- ];
384
- }
385
-
386
- /**
387
- * `_resolve_context_sets` — an explicit manifest wins, `"extend": true` layers it on top
388
- * of discovery, and an empty stub falls through to pure discovery.
389
- *
390
- * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
391
- * these keys are absolute paths whose iteration order becomes the printed order.
392
- */
393
- export function resolveContextSets(root, hookRoot = null) {
394
- let manifest = null;
395
- const env = process.env.GENESEED_CONTEXT;
396
- if (env && isFile(env)) {
397
- // `Path(env)`, and the label is printed — so the separators are folded exactly the
398
- // way `str(Path(...))` folds them, or the source line differs by every slash in it.
399
- manifest = toPlatformPath(env);
400
- } else {
401
- // The harness dir last: a repo's own manifest outranks the one seeded beside the install.
402
- for (const cand of [path.join(root, '.harness', 'context.json'),
403
- path.join(root, 'context.json'), ...(hookRoot ? [path.join(hookRoot, 'context.json')] : [])]) {
404
- if (isFile(cand)) { manifest = cand; break; }
405
- }
406
- }
407
-
408
- if (manifest === null) {
409
- const [e, l] = discoverContext(root);
410
- return [e, l, `auto-discovery [${root}]`];
411
- }
412
-
413
- let raw;
414
- try {
415
- raw = readText(manifest);
416
- } catch (e) {
417
- err(`[context] could not read ${manifest}: ${asOsError(e, manifest)}\n`);
418
- return [[], [], String(manifest)];
419
- }
420
- let entries;
421
- let extend;
422
- try {
423
- let data = JSON.parse(raw);
424
- // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
425
- // would not have thrown here (`[].context` is merely undefined), which is exactly why
426
- // the guard is written out rather than left to fall out of the language: without it,
427
- // the two CLIs disagree about a file a user really does hand-edit.
428
- data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
429
- entries = data.context || [];
430
- if (!Array.isArray(entries)) entries = [];
431
- extend = Boolean(data.extend);
432
- } catch {
433
- // See the Python original for why the decoder's own message is not quoted: the two
434
- // engines disagree on the wording and on the offset, and V8 supplies no offset at all
435
- // for the commonest shapes. Reporting the file is what the user needs; reporting
436
- // WHICH decoder found it is what makes two implementations impossible.
437
- err(`[context] ${manifest} is not valid JSON — no context was loaded `
438
- + '(fix the syntax, then re-run).\n');
439
- return [[], [], String(manifest)];
440
- }
441
-
442
- if (!entries.length && !extend) {
443
- const [e, l] = discoverContext(root);
444
- return [e, l, `auto-discovery [${root}] (empty ${path.basename(manifest)})`];
445
- }
446
-
447
- const recs = new Map();
448
- const put = (pathStr, load, desc) => {
449
- const prev = recs.get(pathStr);
450
- recs.set(pathStr, { path: pathStr, load, description: desc || (prev ? prev.description : '') });
451
- };
452
-
453
- if (extend) {
454
- const [de, dl] = discoverContext(root);
455
- for (const x of de) put(x.path, 'eager', '');
456
- for (const x of dl) put(x.path, 'lazy', '');
457
- }
458
-
459
- for (const entry of entries) {
460
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
461
- const raw = typeof entry.path === 'string' ? entry.path.trim() : '';
462
- if (!raw) continue;
463
- const load = entry.load === undefined ? 'eager' : entry.load;
464
- const desc = entry.description === undefined ? '' : entry.description;
465
- if (raw.includes('*')) {
466
- // A glob RECLASSIFIES files already known; it never pulls in new ones.
467
- for (const [pathStr, rec] of [...recs.entries()]) {
468
- if (fnmatch(disp(pathStr, root), raw)) put(pathStr, load, desc || rec.description);
469
- }
470
- continue;
471
- }
472
- const abs = path.isAbsolute(raw) ? raw : resolvePath(path.join(root, raw));
473
- put(abs, load, desc);
474
- }
475
-
476
- const eager = [];
477
- const lazy = [];
478
- for (const r of recs.values()) {
479
- (r.load === 'eager' ? eager : lazy).push({ path: r.path, description: r.description });
480
- }
481
- return [eager, lazy, String(manifest)];
482
- }
483
-
484
- export function cmdContext(args) {
485
- setHost(args);
486
- // Discovery runs against the project root the hook was launched from — Claude runs
487
- // SessionStart hooks with cwd = repo root — not the harness package dir.
488
- // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
489
- // user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
490
- // throw. Every other caller either builds its argument by `path.join` from an absolute
491
- // root (so no leading tilde survives) or already sits inside a `try`. Refused input
492
- // injects nothing, which is the same degrade as "nothing to load" below.
493
- let root;
494
- let hookRoot;
495
- try {
496
- ({ root, hookRoot } = withDiscardableStderr(() => ({
497
- root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
498
- hookRoot: args.root ? resolvePath(args.root) : null,
499
- })));
500
- } catch {
501
- return 0;
502
- }
503
- if (hookRoot && sovereignBypass(hookRoot)) return 0;
504
- if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
505
- && globalHookStandingDown(hookRoot, root)) return 0;
506
- const session = hookRoot ? sessionFiles(hookRoot) : [];
507
- // A session file discovery would also pick up (a `user-rules.md` at the repo root of an
508
- // install whose harness dir IS the repo root) is injected once, in the session block.
509
- const seen = new Set(session.map((s) => normcase(path.resolve(s.abs))));
510
- const fresh = (e) => !seen.has(normcase(path.resolve(path.isAbsolute(e.path)
511
- ? e.path : path.join(root, e.path))));
512
- let [eager, lazy, source] = resolveContextSets(root, hookRoot);
513
- eager = eager.filter(fresh);
514
- lazy = lazy.filter(fresh);
515
- if (!session.length && !eager.length && !lazy.length) {
516
- err(`[context] nothing to load for ${root} `
517
- + '(no docs discovered, no manifest entries).\n');
518
- return 0;
519
- }
520
-
521
- const lines = [];
522
- let spent = 0;
523
- if (session.length) {
524
- lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
525
- + '(untouched seeds skipped) ===', '');
526
- for (const s of session) {
527
- let text = s.text;
528
- if (text.length > EAGER_FILE_BYTES) {
529
- const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
530
- text = `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
531
- + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB \u2014 read ${s.abs} on demand`;
532
- }
533
- spent += text.length;
534
- lines.push(`----- ${s.rel} -----`, text, '');
535
- }
536
- }
537
- if (eager.length || lazy.length) {
538
- lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
539
- }
540
- const demoted = [];
541
- for (const entry of eager) {
542
- const p = entry.path === undefined ? '' : entry.path;
543
- const desc = entry.description === undefined ? '' : entry.description;
544
- const target = path.isAbsolute(p) ? p : path.join(root, p);
545
- let text;
546
- try {
547
- text = readText(target).replace(/\n+$/, '');
548
- } catch (e) {
549
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
550
- `[context] MISSING eager file: ${asOsError(e, target)}`, '');
551
- continue;
552
- }
553
- if (text.length > EAGER_FILE_BYTES) {
554
- const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
555
- text = `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
556
- + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB \u2014 read ${disp(p, root)} `
557
- + 'on demand for the rest';
558
- }
559
- if (spent + text.length > EAGER_TOTAL_BYTES) {
560
- demoted.push(entry);
561
- continue;
562
- }
563
- spent += text.length;
564
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
565
- }
566
-
567
- if (lazy.length || demoted.length) {
568
- lines.push('--- Lazy entries (load only when the task needs them) ---');
569
- for (const entry of [...lazy, ...demoted]) {
570
- const p = entry.path === undefined ? '' : entry.path;
571
- const desc = entry.description === undefined ? '' : entry.description;
572
- const over = demoted.includes(entry)
573
- ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
574
- lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
575
- }
576
- lines.push('');
577
- }
578
-
579
- // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
580
- out(`${lines.join('\n')}\n`);
581
- return 0;
582
- }
56
+ import { selfAndParents, readStdin } from './hooks-prims.mjs';
583
57
 
584
58
  // ======================================================================================
585
59
  // git-gate — Doctrine process 5's tool-boundary backstop
@@ -595,10 +69,14 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
595
69
  // says a rule in a prompt is a request and a rule at a boundary is a constraint; before this
596
70
  // regex the harness enforced one doctrine rule and no law. `checkout --` is listed because it
597
71
  // reverts to the INDEX and eats unstaged work; `checkout <branch>` is not. `clean -f` matches
598
- // any flag cluster carrying `f` (`-fd`, `-xf`) and never `-n`. `push --force*` trips this gate
599
- // rather than process 5's so the stronger reason is the one the user reads.
72
+ // any flag cluster carrying `f` (`-fd`, `-xf`), in any position (`clean -d -f`), and never `-n`.
73
+ // `reset`, `clean` and `push` take their flags ANYWHERE after the verb (`reset -q --hard`,
74
+ // `push -f origin main`), so each reads `[^\n]*` up to the flag rather than requiring it next.
75
+ // A forced push is `--force*`, a `-f` cluster, or a `+refspec` (`push origin +main`) — the last
76
+ // is a force push with no flag at all. `push --force*` trips this gate rather than process 5's
77
+ // so the stronger reason is the one the user reads.
600
78
  const DESTRUCTIVE_GIT_RE =
601
- /\bgit\b[^\n]*\b(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\s+[^\n]*--force)/;
79
+ /\bgit\b[^\n]*\b(?:reset\b[^\n]*\s--hard\b|clean\b[^\n]*\s-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\b[^\n]*(?:\s--force|\s-[a-zA-Z]*f\b|\s\+\S))/;
602
80
 
603
81
  // `loopExempt` — a WHITELIST, not the blacklist this replaced. A false ask costs one prompt; a
604
82
  // false allow publishes. So the exemption holds only when the ENTIRE command is built from
@@ -682,22 +160,28 @@ export function loopExempt(command, cwd) {
682
160
  || SEG_READONLY_RE.test(seg) || pushSegmentOk(seg, branch));
683
161
  }
684
162
 
685
- /** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
686
- function askDecision(reason) {
687
- return `${jsonDumpsCompact({
688
- hookSpecificOutput: {
689
- hookEventName: 'PreToolUse',
690
- permissionDecision: 'ask',
691
- permissionDecisionReason: reason,
692
- },
693
- })}\n`;
163
+ /**
164
+ * The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling: `, ` and
165
+ * `: ` separators, and `ensure_ascii` — every code unit past `~` as a lowercase `\uXXXX`, which is
166
+ * why a reason's em dash reaches stdout as `—`.
167
+ *
168
+ * A TEMPLATE, NOT `js/lib/json.mjs`'s `jsonDumpsCompact`. The keys are fixed and the one value
169
+ * is a string, so the general serializer bought nothing here — and it cost every tool call a
170
+ * 378-line module parse, being the only thing on the gate path that needed it.
171
+ * `tests/unit/hook_gates.test.mjs` holds the two byte-identical over non-ASCII reasons.
172
+ */
173
+ export function askDecision(reason) {
174
+ const value = JSON.stringify(reason)
175
+ .replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
176
+ return '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", '
177
+ + `"permissionDecisionReason": ${value}}}\n`;
694
178
  }
695
179
 
696
180
  /**
697
181
  * The host reading this process's verdict — `--host` on the emitted command, `claude` when
698
182
  * absent. Module state rather than a parameter because a hook process answers exactly one
699
- * call: set once at the verb's entry (`guardGate`, `cmdContext`), read where the output
700
- * dialect is chosen.
183
+ * call: set once at the gate's entry (`guardGate`), read where the output dialect is chosen.
184
+ * `./hooks-context.mjs` keeps its own for the same reason.
701
185
  */
702
186
  let HOST = 'claude';
703
187
  const setHost = (args) => { HOST = (args && args.host) || 'claude'; };
@@ -867,9 +351,11 @@ function ruleGateTarget(p, root) {
867
351
 
868
352
  // Law I's boundary half. High-precision vendor prefixes only — a generic "looks like entropy"
869
353
  // scan would fire on every hash and lockfile, and a gate that cries wolf gets allow-listed.
870
- // `.env*` is exempt because Law I names it as the place a secret may live.
354
+ // `.env*` is exempt because Law I names it as the place a secret may live. `sk-ant-` takes `_`:
355
+ // Anthropic keys are base64url, and a key whose first `_` fell inside the first 20 characters
356
+ // slipped through. GitHub's classic `ghp_` tokens really are alphanumeric only.
871
357
  const SECRET_RE =
872
- /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
358
+ /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
873
359
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
874
360
 
875
361
  function ruleGate(args) {
@@ -879,11 +365,14 @@ function ruleGate(args) {
879
365
 
880
366
  function ruleDecide(args, payload) {
881
367
  const ti = (payload && payload.tool_input) || {};
882
- const p = ti.file_path || ti.path || '';
368
+ // NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
369
+ const p = ti.file_path || ti.path || ti.notebook_path || '';
883
370
  if (typeof p !== 'string' || !p) return 0;
884
- // Write carries `content`, Edit carries `new_string`; either can plant a credential.
885
- const body = [ti.content, ti.new_string]
886
- .find((v) => typeof v === 'string') || '';
371
+ // Write carries `content`, Edit `new_string`, NotebookEdit `new_source`, MultiEdit an
372
+ // `edits[]` of `new_string`s — every one can plant a credential, so every one is scanned.
373
+ const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
374
+ const body = [ti.content, ti.new_string, ti.new_source, ...edits]
375
+ .filter((v) => typeof v === 'string').join('\n');
887
376
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
888
377
  return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
889
378
  + 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
@@ -915,419 +404,3 @@ function toolGate(args) {
915
404
  return typeof ti.command === 'string' ? gitDecide(args, payload) : ruleDecide(args, payload);
916
405
  }
917
406
  export const cmdToolGate = guardGate(toolGate, 'tool-gate');
918
-
919
- // ======================================================================================
920
- // learn — distil notes/transcripts into deduped memory entries
921
- // ======================================================================================
922
-
923
- const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
924
- const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
925
- const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/;
926
- const MAX_AGENT_BULLETS = 100;
927
- const MAX_NOTES_CHARS = 16000;
928
- // Host payloads name the finished subagent inconsistently; try each.
929
- const SUBAGENT_NAME_FIELDS = ['agent_name', 'agent_type', 'subagent_type', 'agent',
930
- 'agent_id'];
931
-
932
- const PLUGIN_LEARN = path.join(ROOT, 'adapters', 'opencode', 'plugins', 'geneseed-learn.js');
933
-
934
- /**
935
- * `_load_learn_prompt_head` / `_load_agent_lesson_prompt`.
936
- *
937
- * The single source of truth for both prompts is the OpenCode plugin — the artifact that
938
- * ships to the primary runtime — and both CLIs extract the template literal from its
939
- * SOURCE rather than keeping a copy. Reading it means this port cannot drift from the
940
- * plugin either; a copied constant here would be a third place to edit.
941
- */
942
- function pluginLiteral(name, fallback) {
943
- try {
944
- const m = new RegExp(`const ${name} = \`([\\s\\S]*?)\``).exec(readText(PLUGIN_LEARN));
945
- if (m) return m[1];
946
- } catch { /* unreadable plugin must never crash a Stop hook */ }
947
- return fallback;
948
- }
949
-
950
- export const LEARN_PROMPT_HEAD = pluginLiteral('LEARN_PROMPT_HEAD',
951
- 'Distil at most one durable, reusable memory from the notes below. '
952
- + 'When in doubt, output exactly: NOTHING.');
953
- const AGENT_LESSON_PROMPT = pluginLiteral('AGENT_LESSON_PROMPT',
954
- 'Output AT MOST ONE line: a durable lesson about how this agent should '
955
- + 'operate on a future dispatch. Else output exactly: NOTHING.');
956
-
957
- /** `resolve_agent_name` — a safe agent slug from a raw field value, or null. */
958
- export function resolveAgentName(raw) {
959
- const name = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
960
- return name && AGENT_NAME_RE.test(name) ? name : null;
961
- }
962
-
963
- /** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
964
- export function appendAgentLesson(memDir, agent, lesson) {
965
- const d = path.join(memDir, 'agents');
966
- mkdirSync(d, { recursive: true });
967
- const f = path.join(d, `${agent}.md`);
968
- let bullets = [];
969
- if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
970
- const now = new Date();
971
- const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
972
- + `${String(now.getDate()).padStart(2, '0')}`;
973
- bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
974
- bullets = bullets.slice(-MAX_AGENT_BULLETS);
975
- writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
976
- return f;
977
- }
978
-
979
- /** The lifecycle-hook payload if stdin is one, else `{}`. */
980
- function hookMeta(raw) {
981
- const s = raw.trim();
982
- if (s.slice(0, 1) !== '{') return {};
983
- try { return JSON.parse(s); } catch { return {}; }
984
- }
985
-
986
- /** Flatten a message `content` field (string, or a list of blocks) to text. */
987
- function contentText(content) {
988
- if (typeof content === 'string') return content;
989
- if (Array.isArray(content)) {
990
- const parts = [];
991
- for (const block of content) {
992
- if (typeof block === 'string') parts.push(block);
993
- else if (block && typeof block === 'object' && block.type === 'text') {
994
- parts.push(block.text === undefined ? '' : block.text);
995
- }
996
- }
997
- return parts.join('\n');
998
- }
999
- return '';
1000
- }
1001
-
1002
- /** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
1003
- function flattenTranscript(p) {
1004
- let raw;
1005
- try {
1006
- // `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
1007
- raw = readText(p);
1008
- } catch {
1009
- return '';
1010
- }
1011
- const acc = [];
1012
- for (let line of splitLines(raw)) {
1013
- line = line.trim();
1014
- if (!line) continue;
1015
- let obj;
1016
- try { obj = JSON.parse(line); } catch { continue; }
1017
- const msg = obj.message || {};
1018
- const role = msg.role || obj.role || obj.type;
1019
- if (role !== 'user' && role !== 'assistant') continue;
1020
- const text = contentText(msg.content === undefined ? obj.content : msg.content).trim();
1021
- if (text) acc.push(`${role}: ${text}`);
1022
- }
1023
- return acc.join('\n\n');
1024
- }
1025
-
1026
- /**
1027
- * `_read_notes` — a lifecycle-hook payload with a `transcript_path` is read and flattened
1028
- * (this is what makes wiring `learn` to a Stop hook work with no redirection); anything
1029
- * else is used as-is.
1030
- */
1031
- /**
1032
- * EXPORTED FOR THE UNIT TIER, and the rule this follows is written down once here for the three
1033
- * `learn` helpers below it.
1034
- *
1035
- * `docs/specs`'s P3 rule is: drive a property through the PUBLIC entry by default, and export a
1036
- * decision only when the property is invisible through that face. The `learn` verb's WIRING is
1037
- * already gated — 78 cells in the CLI matrix drive it end to end — so what a unit test would add
1038
- * there is nothing. What no cell can see is these three functions' behaviour on inputs a cell
1039
- * cannot produce: a model that answers NOTHING, a duplicate slug, a `README.md` beside the
1040
- * store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
1041
- * reaches none of them.
1042
- */
1043
- export function readNotes(raw) {
1044
- const s = raw.trim();
1045
- if (!s) return '';
1046
- if (s[0] === '{') {
1047
- let payload;
1048
- try { payload = JSON.parse(s); } catch { return raw; }
1049
- const tp = payload && payload.transcript_path;
1050
- if (tp) return flattenTranscript(tp);
1051
- return raw;
1052
- }
1053
- return raw;
1054
- }
1055
-
1056
- // `resolveMemoryDir` moved to `js/hosts/hosts.mjs` in P5d — see the import at the top of this
1057
- // file. Re-exported here so the hook entry's own import list does not change.
1058
- export { resolveMemoryDir };
1059
-
1060
- /**
1061
- * `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
1062
- *
1063
- * Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
1064
- * exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
1065
- * this port treats as a coverage event rather than a tidiness one — the fields it produces
1066
- * are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
1067
- * what gate them.
1068
- */
1069
- export function frontmatter(md) {
1070
- const m = FRONTMATTER_RE.exec(md);
1071
- if (!m) return [new Map(), md];
1072
- const fm = new Map();
1073
- for (const line of splitLines(m[1])) {
1074
- const at = line.indexOf(':');
1075
- if (at >= 0) {
1076
- // `str.strip('"')` strips the character from BOTH ends, however many there are.
1077
- fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
1078
- }
1079
- }
1080
- return [fm, m[2]];
1081
- }
1082
-
1083
- const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
1084
-
1085
- /** `Path.stem` for a filename. */
1086
- const stem = (name) => path.basename(name, path.extname(name));
1087
-
1088
- /** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
1089
- function globMd(dir) {
1090
- return listDir(dir).filter((n) => normcase(n).endsWith('.md')
1091
- && isFile(path.join(dir, n)));
1092
- }
1093
-
1094
- /** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
1095
- export function existingSlugs(memDir) {
1096
- const skip = new Set(['memory', 'readme']);
1097
- return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
1098
- }
1099
-
1100
- function buildLearnPrompt(notes, existing) {
1101
- const parts = [LEARN_PROMPT_HEAD, ''];
1102
- if (existing.size) {
1103
- parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
1104
- + 'slugs (skip updates too; only genuinely new facts):');
1105
- parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
1106
- parts.push('');
1107
- }
1108
- parts.push('NOTES:', notes);
1109
- return parts.join('\n');
1110
- }
1111
-
1112
- /**
1113
- * `_write_memories` — split the model output into files, write each NEW one, and append a
1114
- * pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
1115
- * twice writes it once.
1116
- */
1117
- export function writeMemories(modelOutput, memDir, existing) {
1118
- const written = [];
1119
- const indexLines = [];
1120
- for (let chunk of modelOutput.split(FILE_SEP_RE)) {
1121
- chunk = chunk.trim();
1122
- if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
1123
- const [fm] = frontmatter(chunk);
1124
- const name = fmGet(fm, 'name').trim();
1125
- if (!name || existing.has(name)) continue;
1126
- writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
1127
- existing.add(name);
1128
- written.push(name);
1129
- const desc = fmGet(fm, 'description').trim();
1130
- indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
1131
- }
1132
- if (indexLines.length) {
1133
- const index = path.join(memDir, 'MEMORY.md');
1134
- const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
1135
- : '# Memory Index\n';
1136
- writeText(index, `${current}${indexLines.join('\n')}\n`);
1137
- }
1138
- return written;
1139
- }
1140
-
1141
- /**
1142
- * `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
1143
- *
1144
- * ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
1145
- * `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
1146
- * them, so the three rules about MEMORY.md's format live together rather than one of them
1147
- * living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
1148
- *
1149
- * The P6f plan asked whether P5's `learn` had already brought it across. It had not —
1150
- * nothing in `js/` referenced it — which is the third time a plan's "already ported" column
1151
- * was answered by reading rather than by trusting.
1152
- *
1153
- * SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
1154
- * so a hand-written index in a different shape still loses its entry. And the file is only
1155
- * REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
1156
- * touching the user's mtime.
1157
- */
1158
- export function memoryDropIndex(memDir, name) {
1159
- const idx = path.join(memDir, 'MEMORY.md');
1160
- let lines;
1161
- try {
1162
- lines = splitLines(readText(idx));
1163
- } catch {
1164
- return; // the reference's `except OSError: return`
1165
- }
1166
- const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
1167
- if (keep.length === lines.length) return;
1168
- try {
1169
- writeText(idx, `${keep.join('\n')}\n`);
1170
- } catch { /* the reference swallows OSError here too */ }
1171
- }
1172
-
1173
- /**
1174
- * `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
1175
- * orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
1176
- * the user's to keep).
1177
- */
1178
- export function consolidateMemory(memDir) {
1179
- const skip = new Set(['memory', 'readme']);
1180
- const facts = new Map();
1181
- for (const name of sortPaths(globMd(memDir))) {
1182
- const s = stem(name);
1183
- if (skip.has(s.toLowerCase())) continue;
1184
- try {
1185
- facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
1186
- } catch { continue; }
1187
- }
1188
- const index = path.join(memDir, 'MEMORY.md');
1189
- const oldSlugs = new Set();
1190
- if (existsSync(index)) {
1191
- for (const line of splitLines(readText(index))) {
1192
- const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
1193
- if (m) oldSlugs.add(m[1]);
1194
- }
1195
- }
1196
- const lines = ['# Memory Index', ''];
1197
- for (const [slug, desc] of facts) {
1198
- lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
1199
- }
1200
- writeText(index, `${lines.join('\n')}\n`);
1201
- const seen = new Map();
1202
- const dups = [];
1203
- for (const [slug, desc] of facts) {
1204
- if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
1205
- if (!seen.has(desc)) seen.set(desc, slug);
1206
- }
1207
- const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
1208
- const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
1209
- return { added, pruned, duplicates: dups };
1210
- }
1211
-
1212
- /**
1213
- * The model CLI, run as `$GENESEED_LLM`'s words plus the prompt.
1214
- *
1215
- * `encoding="utf-8"` on the Python side and `'utf8'` here, never the console code page: a
1216
- * distilled memory carrying an accent or an em dash is WRITTEN to the store, so a
1217
- * mis-decode outlives the process. `maxBuffer` is lifted off Node's 1 MB default, which
1218
- * would truncate a long reply into a silently-half-parsed set of memory files.
1219
- */
1220
- function runLlm(llm, prompt) {
1221
- const argv = splitWords(llm);
1222
- // `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
1223
- // which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
1224
- // otherwise pops a console for as long as it takes to answer.
1225
- const proc = spawnSync(argv[0], [...argv.slice(1), prompt],
1226
- { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW });
1227
- // Universal newlines, because Python reads the pipe in TEXT mode: a model CLI that is
1228
- // itself a Python script emits CRLF on Windows, `subprocess` folds it to `\n`, and
1229
- // `write_text` expands it again on the way into the memory file. Node hands back the raw
1230
- // bytes, so without this the stored fact is written with `\r\r\n` on every line.
1231
- const universal = (s) => (s === null || s === undefined ? ''
1232
- : String(s).replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
1233
- return {
1234
- stdout: universal(proc.stdout),
1235
- stderr: universal(proc.stderr),
1236
- // A spawn that never started is Python's FileNotFoundError, which propagates there;
1237
- // here it surfaces as a non-zero code with the reason on stderr rather than a stack.
1238
- returncode: proc.error ? 1 : (proc.status === null ? 1 : proc.status),
1239
- error: proc.error,
1240
- };
1241
- }
1242
-
1243
- /**
1244
- * `_learn_agent_lesson` — the SubagentStop path: at most one per-agent lesson into
1245
- * `memory/agents/<name>.md`. Skips silently when the host does not name the subagent:
1246
- * parity of mechanism, degrading to a no-op, never a crash.
1247
- */
1248
- function learnAgentLesson(meta, notes, args) {
1249
- let agent = null;
1250
- for (const field of SUBAGENT_NAME_FIELDS) {
1251
- agent = resolveAgentName(meta[field]);
1252
- if (agent) break;
1253
- }
1254
- if (!agent) return 0;
1255
- const memDir = resolveMemoryDir(args.memory);
1256
- if (!memDir) return 0;
1257
- const prompt = [AGENT_LESSON_PROMPT, '', 'NOTES:', notes].join('\n');
1258
- const llm = process.env.GENESEED_LLM;
1259
- if (!llm) {
1260
- err('[learn] $GENESEED_LLM unset \u2014 printing agent-lesson prompt.\n\n');
1261
- out(`${prompt}\n`);
1262
- return 0;
1263
- }
1264
- const proc = runLlm(llm, prompt);
1265
- const text = proc.stdout.trim();
1266
- if (text && text.toUpperCase() !== 'NOTHING') {
1267
- const lesson = splitLines(text)[0].replace(/^[-*]+/, '').trim();
1268
- const n = Array.from(lesson).length; // code points, as `len()` counts them
1269
- if (n >= 10 && n <= 300) {
1270
- err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
1271
- }
1272
- }
1273
- if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
1274
- return proc.returncode;
1275
- }
1276
-
1277
- export function cmdLearn(args) {
1278
- if (args.consolidate) {
1279
- const memDir = resolveMemoryDir(args.memory);
1280
- if (!memDir) {
1281
- err('[learn] no memory store found \u2014 nothing to consolidate.\n');
1282
- return 0;
1283
- }
1284
- const report = consolidateMemory(memDir);
1285
- err(`[learn] consolidated ${memDir}: +${report.added.length} indexed, `
1286
- + `-${report.pruned.length} pruned, `
1287
- + `${report.duplicates.length} duplicate description(s)\n`);
1288
- for (const [a, b] of report.duplicates) err(` duplicate: ${a} <-> ${b}\n`);
1289
- return 0;
1290
- }
1291
-
1292
- // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
1293
- // folder the global install must not learn.
1294
- if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
1295
-
1296
- const raw = args.file ? readText(args.file) : readStdin();
1297
- let notes = readNotes(raw);
1298
- if (!notes.trim()) {
1299
- err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
1300
- return 0;
1301
- }
1302
- notes = tail(notes, MAX_NOTES_CHARS); // keep the tail: most recent, most durable
1303
-
1304
- const meta = hookMeta(raw);
1305
- if (meta.hook_event_name === 'SubagentStop') return learnAgentLesson(meta, notes, args);
1306
-
1307
- const memDir = resolveMemoryDir(args.memory);
1308
- const existing = memDir ? existingSlugs(memDir) : new Set();
1309
- const prompt = buildLearnPrompt(notes, existing);
1310
-
1311
- const llm = process.env.GENESEED_LLM;
1312
- if (!llm) {
1313
- err('[learn] $GENESEED_LLM unset \u2014 printing prompt instead.\n\n');
1314
- out(`${prompt}\n`);
1315
- return 0;
1316
- }
1317
-
1318
- const proc = runLlm(llm, prompt);
1319
- const output = proc.stdout;
1320
- if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
1321
- const written = writeMemories(output, memDir, existing);
1322
- if (written.length) {
1323
- err(`[learn] wrote ${written.length} memory file(s) to `
1324
- + `${memDir}: ${written.join(', ')}\n`);
1325
- } else {
1326
- err('[learn] nothing new to store (all duplicates).\n');
1327
- }
1328
- } else {
1329
- out(output);
1330
- }
1331
- if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
1332
- return proc.returncode;
1333
- }