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.
- package/CHANGELOG.md +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/hosts/hooks.mjs
CHANGED
|
@@ -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
|
|
28
|
-
*
|
|
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 {
|
|
43
|
-
appendFileSync, writeFileSync }
|
|
44
|
-
from 'node:fs';
|
|
50
|
+
import { readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
45
51
|
import path from 'node:path';
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
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`)
|
|
599
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
700
|
-
*
|
|
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-
|
|
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
|
-
|
|
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
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
}
|