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
@@ -0,0 +1,432 @@
1
+ /**
2
+ * `context` — the SessionStart verb: inject the harness's session files and the repo's context
3
+ * docs. Runs once per session, so it is loaded only when the hook entry dispatches it; see
4
+ * `js/hosts/hooks.mjs`'s header for the contract every hook verb holds.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { readText, printOut as out, printErr as err, withDiscardableStderr } from '../lib/fs.mjs';
10
+ import { normcase, toPlatformPath } from '../lib/paths.mjs';
11
+ import { relPosix } from '../lib/text.mjs';
12
+ import { resolvePath, sovereignBypass } from './hosts.mjs';
13
+ import { isFile, isDir, selfAndParents, sortPaths, listDir } from './hooks-prims.mjs';
14
+
15
+ /**
16
+ * `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
17
+ *
18
+ * This is a compared string, not a log line: `cmd_context` prints it verbatim when an
19
+ * eager entry is missing, so `ENOENT: no such file or directory, open '…'` (Node's
20
+ * wording, Node's quoting, no errno) is a whole-line difference in that cell. Only the
21
+ * codes a verb here can actually raise are mapped; anything else falls back to Node's
22
+ * message, which is at least honest about being different.
23
+ */
24
+ const _ERRNO = {
25
+ ENOENT: [2, 'No such file or directory'],
26
+ EACCES: [13, 'Permission denied'],
27
+ EISDIR: [21, 'Is a directory'],
28
+ ENOTDIR: [20, 'Not a directory'],
29
+ EPERM: [1, 'Operation not permitted'],
30
+ };
31
+
32
+ function asOsError(e, filename) {
33
+ const hit = _ERRNO[e && e.code];
34
+ if (!hit) return String((e && e.message) || e);
35
+ // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
36
+ return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
37
+ .replace(/\\"/g, '"')}`;
38
+ }
39
+
40
+ /**
41
+ * `fnmatch.fnmatch(name, pattern)` — case-folded on Windows on BOTH sides, and `*` matches
42
+ * a separator (it is shell-glob semantics, not path-glob: `docs/*.md` really does select
43
+ * `docs/nested/deep.md`, in both implementations).
44
+ */
45
+ function fnmatch(name, pattern) {
46
+ return fnTranslate(normcase(pattern)).test(normcase(name));
47
+ }
48
+
49
+ function fnTranslate(pat) {
50
+ let out = '';
51
+ for (let i = 0; i < pat.length; i += 1) {
52
+ const c = pat[i];
53
+ if (c === '*') { out += '[\\s\\S]*'; continue; }
54
+ if (c === '?') { out += '[\\s\\S]'; continue; }
55
+ if (c === '[') {
56
+ let j = i + 1;
57
+ if (pat[j] === '!') j += 1;
58
+ if (pat[j] === ']') j += 1;
59
+ while (j < pat.length && pat[j] !== ']') j += 1;
60
+ // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
61
+ if (j >= pat.length) { out += '\\['; continue; }
62
+ let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
63
+ if (body.startsWith('!')) body = `^${body.slice(1)}`;
64
+ out += `[${body}]`;
65
+ i = j;
66
+ continue;
67
+ }
68
+ out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+ }
70
+ return new RegExp(`^(?:${out})$`);
71
+ }
72
+
73
+ /**
74
+ * The host whose own root file counts as native — `--host` on the emitted command, `claude`
75
+ * when absent. Module state for the reason `./hooks.mjs`'s gates keep theirs: a hook process
76
+ * answers exactly one call, so it is set once at `cmdContext`'s entry.
77
+ */
78
+ let HOST = 'claude';
79
+
80
+ // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
81
+ const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
82
+ 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
83
+ const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
84
+ const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
85
+ 'target', '.venv', '__pycache__', '.opencode', '.harness']);
86
+
87
+ // The root instruction file a host already loads by itself. Injecting it again pays the
88
+ // whole harness twice per session — and was doing exactly that until 2026-09: every hooked
89
+ // host had its own root in EAGER_ROOT. Other tools' roots stay eager (a repo carrying a
90
+ // hand-written AGENTS.md is worth showing Claude Code); only the host's OWN is dropped.
91
+ // The OpenCode context plugin carries the same rule for AGENT.md/AGENTS.md/CLAUDE.md.
92
+ // OpenClaude's root file is AGENTS.md, or CLAUDE.md when AGENTS.md is absent — resolved
93
+ // per repo in `discoverContext`, since which one it loads depends on what is on disk.
94
+ const NATIVE_ROOT = {
95
+ claude: ['CLAUDE.md'], bob: ['AGENTS.md'],
96
+ openclaude: ['AGENTS.md', 'CLAUDE.md'],
97
+ };
98
+
99
+ // Eager injection budget: a root README of 40k chars was going out whole, every session and
100
+ // every compaction, and cost more than the harness itself. Per-file cut at the last line
101
+ // break under the cap; files that would push the total over the budget are listed lazy.
102
+ const EAGER_FILE_BYTES = 16 * 1024;
103
+ const EAGER_TOTAL_BYTES = 48 * 1024;
104
+
105
+ // The files the root instruction file names for session start, relative to the HARNESS dir
106
+ // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
107
+ // which is why none of them reached a hooked host before 2026-10. Injected here so the read does
108
+ // not depend on the model remembering it. `context.json` is not in the list: it is a manifest,
109
+ // honoured by `resolveContextSets` (the harness dir is its last candidate). The OpenCode
110
+ // context plugin carries the same list.
111
+ const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
112
+ 'notebook/NOTEBOOK.md', 'wiki.jsonc'];
113
+ // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
114
+ // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
115
+ // Hashes, not an import: stubs.mjs pulls in the build's writers, and this module loads on every
116
+ // tool call. tests/unit/claude.test.mjs gates this set, and the plugin's copy, against the seeds.
117
+ const SEED_SHA256 = new Set([
118
+ '5c2e92fb1acde041e02d9ebf158460d8ff7a07cbc2b447859631604610130721', // user-rules.md
119
+ '29e012c3c4349ea62a9082cbd04bb25599a0ec280d62c33f6e10742809471816', // PROFILE.md
120
+ 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc
121
+ '99c786049c260f6baf7aec68a5c0f59907861afe9b867da009440613bdddb907', // MEMORY.md
122
+ '9acb5c9d9cb5dac572104480f05b48cc144e676869bd60e86d8a15b1f6308184', // NOTEBOOK.md
123
+ ]);
124
+ export { SESSION_FILES, SEED_SHA256 };
125
+
126
+ /**
127
+ * The session files present under `hookRoot` and changed from their seed, as `{ rel, abs, text }`
128
+ * (text CRLF-folded, trailing newlines trimmed). `$GENESEED_WIKI` overrides the wiki declaration,
129
+ * as it does on OpenCode. A missing or unreadable file is simply absent — this is best-effort
130
+ * context, never a gate.
131
+ */
132
+ export function sessionFiles(hookRoot) {
133
+ const found = [];
134
+ for (const rel of SESSION_FILES) {
135
+ let abs = path.join(hookRoot, rel);
136
+ if (rel === 'wiki.jsonc' && process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) {
137
+ abs = process.env.GENESEED_WIKI;
138
+ }
139
+ if (!isFile(abs)) continue;
140
+ let text;
141
+ try { text = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'); } catch { continue; }
142
+ if (SEED_SHA256.has(createHash('sha256').update(text).digest('hex'))) continue;
143
+ found.push({ rel, abs, text: text.replace(/\n+$/, '') });
144
+ }
145
+ return found;
146
+ }
147
+
148
+ const CLAUDE_MARKERS = ['.claude', '.bob', '.openclaude'];
149
+ const GENESEED_MANIFEST = '.geneseed-manifest.json';
150
+
151
+ /**
152
+ * `_global_hook_standing_down` — project-bypasses-global.
153
+ *
154
+ * A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
155
+ * of the SAME host sits at or above cwd, the project's hook injects and this one must not
156
+ * double up.
157
+ */
158
+ export function globalHookStandingDown(hookRoot, cwd) {
159
+ const marker = path.basename(hookRoot);
160
+ if (!CLAUDE_MARKERS.includes(marker)) return false;
161
+ for (const d of selfAndParents(cwd)) {
162
+ const cand = path.join(d, marker);
163
+ if (isFile(path.join(cand, GENESEED_MANIFEST))) {
164
+ // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
165
+ // the same install there and two different ones on Linux.
166
+ return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
173
+ function disp(pathStr, root) {
174
+ // `os.path.relpath` raises ValueError across drives and the Python falls back to the
175
+ // path as given; `path.relative` silently returns an absolute path instead, which would
176
+ // print a DIFFERENT string rather than the same one.
177
+ if (path.parse(path.resolve(pathStr)).root.toLowerCase()
178
+ !== path.parse(path.resolve(root)).root.toLowerCase()) {
179
+ return pathStr;
180
+ }
181
+ return relPosix(root, pathStr);
182
+ }
183
+
184
+
185
+ function rglobMd(dir, acc = []) {
186
+ for (const name of listDir(dir)) {
187
+ const full = path.join(dir, name);
188
+ if (isDir(full)) rglobMd(full, acc);
189
+ else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
190
+ }
191
+ return acc;
192
+ }
193
+
194
+ /**
195
+ * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
196
+ * Root entry docs are eager; other root markdown, the doc trees and monorepo package
197
+ * READMEs are lazy.
198
+ */
199
+ export function discoverContext(root, host = HOST) {
200
+ const eager = new Map();
201
+ const lazy = new Map();
202
+ const native = host === 'openclaude' && isFile(path.join(root, 'AGENTS.md'))
203
+ ? ['AGENTS.md'] : NATIVE_ROOT[host] || [];
204
+ for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
205
+ if (!isFile(full)) continue;
206
+ const name = path.basename(full);
207
+ if (native.includes(name)) continue;
208
+ if (EAGER_ROOT.includes(name)) eager.set(full, null);
209
+ else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
210
+ }
211
+ for (const d of LAZY_DIRS) {
212
+ const sub = path.join(root, d);
213
+ if (!isDir(sub)) continue;
214
+ for (const md of sortPaths(rglobMd(sub))) {
215
+ const parts = path.relative(root, md).split(/[\\/]/);
216
+ if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
217
+ if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
218
+ }
219
+ }
220
+ for (const group of ['packages', 'apps']) {
221
+ const base = path.join(root, group);
222
+ if (!isDir(base)) continue;
223
+ for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
224
+ if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
225
+ const readme = path.join(pkg, 'README.md');
226
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
227
+ }
228
+ }
229
+ return [
230
+ [...eager.keys()].map((p) => ({ path: p, description: '' })),
231
+ [...lazy.keys()].filter((p) => !eager.has(p)).map((p) => ({ path: p, description: '' })),
232
+ ];
233
+ }
234
+
235
+ /**
236
+ * `_resolve_context_sets` — an explicit manifest wins, `"extend": true` layers it on top
237
+ * of discovery, and an empty stub falls through to pure discovery.
238
+ *
239
+ * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
240
+ * these keys are absolute paths whose iteration order becomes the printed order.
241
+ */
242
+ export function resolveContextSets(root, hookRoot = null) {
243
+ let manifest = null;
244
+ const env = process.env.GENESEED_CONTEXT;
245
+ if (env && isFile(env)) {
246
+ // `Path(env)`, and the label is printed — so the separators are folded exactly the
247
+ // way `str(Path(...))` folds them, or the source line differs by every slash in it.
248
+ manifest = toPlatformPath(env);
249
+ } else {
250
+ // The harness dir last: a repo's own manifest outranks the one seeded beside the install.
251
+ for (const cand of [path.join(root, '.harness', 'context.json'),
252
+ path.join(root, 'context.json'), ...(hookRoot ? [path.join(hookRoot, 'context.json')] : [])]) {
253
+ if (isFile(cand)) { manifest = cand; break; }
254
+ }
255
+ }
256
+
257
+ if (manifest === null) {
258
+ const [e, l] = discoverContext(root);
259
+ return [e, l, `auto-discovery [${root}]`];
260
+ }
261
+
262
+ let raw;
263
+ try {
264
+ raw = readText(manifest);
265
+ } catch (e) {
266
+ err(`[context] could not read ${manifest}: ${asOsError(e, manifest)}\n`);
267
+ return [[], [], String(manifest)];
268
+ }
269
+ let entries;
270
+ let extend;
271
+ try {
272
+ let data = JSON.parse(raw);
273
+ // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
274
+ // would not have thrown here (`[].context` is merely undefined), which is exactly why
275
+ // the guard is written out rather than left to fall out of the language: without it,
276
+ // the two CLIs disagree about a file a user really does hand-edit.
277
+ data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
278
+ entries = data.context || [];
279
+ if (!Array.isArray(entries)) entries = [];
280
+ extend = Boolean(data.extend);
281
+ } catch {
282
+ // See the Python original for why the decoder's own message is not quoted: the two
283
+ // engines disagree on the wording and on the offset, and V8 supplies no offset at all
284
+ // for the commonest shapes. Reporting the file is what the user needs; reporting
285
+ // WHICH decoder found it is what makes two implementations impossible.
286
+ err(`[context] ${manifest} is not valid JSON — no context was loaded `
287
+ + '(fix the syntax, then re-run).\n');
288
+ return [[], [], String(manifest)];
289
+ }
290
+
291
+ if (!entries.length && !extend) {
292
+ const [e, l] = discoverContext(root);
293
+ return [e, l, `auto-discovery [${root}] (empty ${path.basename(manifest)})`];
294
+ }
295
+
296
+ const recs = new Map();
297
+ const put = (pathStr, load, desc) => {
298
+ const prev = recs.get(pathStr);
299
+ recs.set(pathStr, { path: pathStr, load, description: desc || (prev ? prev.description : '') });
300
+ };
301
+
302
+ if (extend) {
303
+ const [de, dl] = discoverContext(root);
304
+ for (const x of de) put(x.path, 'eager', '');
305
+ for (const x of dl) put(x.path, 'lazy', '');
306
+ }
307
+
308
+ for (const entry of entries) {
309
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
310
+ const raw = typeof entry.path === 'string' ? entry.path.trim() : '';
311
+ if (!raw) continue;
312
+ const load = entry.load === undefined ? 'eager' : entry.load;
313
+ const desc = entry.description === undefined ? '' : entry.description;
314
+ if (raw.includes('*')) {
315
+ // A glob RECLASSIFIES files already known; it never pulls in new ones.
316
+ for (const [pathStr, rec] of [...recs.entries()]) {
317
+ if (fnmatch(disp(pathStr, root), raw)) put(pathStr, load, desc || rec.description);
318
+ }
319
+ continue;
320
+ }
321
+ const abs = path.isAbsolute(raw) ? raw : resolvePath(path.join(root, raw));
322
+ put(abs, load, desc);
323
+ }
324
+
325
+ const eager = [];
326
+ const lazy = [];
327
+ for (const r of recs.values()) {
328
+ (r.load === 'eager' ? eager : lazy).push({ path: r.path, description: r.description });
329
+ }
330
+ return [eager, lazy, String(manifest)];
331
+ }
332
+
333
+ /**
334
+ * Cut `text` at the last line break under EAGER_FILE_BYTES and say where the rest is — one rule
335
+ * for a session file and an eager doc alike. `where` finishes the sentence.
336
+ */
337
+ function capEager(text, where) {
338
+ if (text.length <= EAGER_FILE_BYTES) return text;
339
+ const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
340
+ return `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
341
+ + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB — read ${where}`;
342
+ }
343
+
344
+ export function cmdContext(args) {
345
+ HOST = (args && args.host) || 'claude';
346
+ // Discovery runs against the project root the hook was launched from — Claude runs
347
+ // SessionStart hooks with cwd = repo root — not the harness package dir.
348
+ // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
349
+ // user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
350
+ // throw. Every other caller either builds its argument by `path.join` from an absolute
351
+ // root (so no leading tilde survives) or already sits inside a `try`. Refused input
352
+ // injects nothing, which is the same degrade as "nothing to load" below.
353
+ let root;
354
+ let hookRoot;
355
+ try {
356
+ ({ root, hookRoot } = withDiscardableStderr(() => ({
357
+ root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
358
+ hookRoot: args.root ? resolvePath(args.root) : null,
359
+ })));
360
+ } catch {
361
+ return 0;
362
+ }
363
+ if (hookRoot && sovereignBypass(hookRoot)) return 0;
364
+ if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
365
+ && globalHookStandingDown(hookRoot, root)) return 0;
366
+ const session = hookRoot ? sessionFiles(hookRoot) : [];
367
+ // A session file discovery would also pick up (a `user-rules.md` at the repo root of an
368
+ // install whose harness dir IS the repo root) is injected once, in the session block.
369
+ const seen = new Set(session.map((s) => normcase(path.resolve(s.abs))));
370
+ const fresh = (e) => !seen.has(normcase(path.resolve(path.isAbsolute(e.path)
371
+ ? e.path : path.join(root, e.path))));
372
+ let [eager, lazy, source] = resolveContextSets(root, hookRoot);
373
+ eager = eager.filter(fresh);
374
+ lazy = lazy.filter(fresh);
375
+ if (!session.length && !eager.length && !lazy.length) {
376
+ err(`[context] nothing to load for ${root} `
377
+ + '(no docs discovered, no manifest entries).\n');
378
+ return 0;
379
+ }
380
+
381
+ const lines = [];
382
+ let spent = 0;
383
+ if (session.length) {
384
+ lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
385
+ + '(untouched seeds skipped) ===', '');
386
+ for (const s of session) {
387
+ const text = capEager(s.text, `${s.abs} on demand`);
388
+ spent += text.length;
389
+ lines.push(`----- ${s.rel} -----`, text, '');
390
+ }
391
+ }
392
+ if (eager.length || lazy.length) {
393
+ lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
394
+ }
395
+ const demoted = [];
396
+ for (const entry of eager) {
397
+ const p = entry.path === undefined ? '' : entry.path;
398
+ const desc = entry.description === undefined ? '' : entry.description;
399
+ const target = path.isAbsolute(p) ? p : path.join(root, p);
400
+ let text;
401
+ try {
402
+ text = readText(target).replace(/\n+$/, '');
403
+ } catch (e) {
404
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
405
+ `[context] MISSING eager file: ${asOsError(e, target)}`, '');
406
+ continue;
407
+ }
408
+ text = capEager(text, `${disp(p, root)} on demand for the rest`);
409
+ if (spent + text.length > EAGER_TOTAL_BYTES) {
410
+ demoted.push(entry);
411
+ continue;
412
+ }
413
+ spent += text.length;
414
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
415
+ }
416
+
417
+ if (lazy.length || demoted.length) {
418
+ lines.push('--- Lazy entries (load only when the task needs them) ---');
419
+ for (const entry of [...lazy, ...demoted]) {
420
+ const p = entry.path === undefined ? '' : entry.path;
421
+ const desc = entry.description === undefined ? '' : entry.description;
422
+ const over = demoted.includes(entry)
423
+ ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
424
+ lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
425
+ }
426
+ lines.push('');
427
+ }
428
+
429
+ // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
430
+ out(`${lines.join('\n')}\n`);
431
+ return 0;
432
+ }