geneseed 4.9.1 → 4.9.2
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 +19 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +7 -1
- package/adapters/opencode/plugins/geneseed-guard.js +18 -10
- package/adapters/opencode/plugins/geneseed-learn.js +7 -1
- package/docs/concepts/hooks.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +30 -9
- package/js/build/args.mjs +9 -8
- package/js/build/driver.mjs +21 -21
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +4 -5
- package/js/build/source.mjs +18 -22
- package/js/hosts/gitref.mjs +5 -3
- package/js/hosts/hooks-done.mjs +21 -9
- package/js/hosts/hooks-learn.mjs +23 -10
- package/js/hosts/hooks-prims.mjs +25 -0
- package/js/hosts/hooks.mjs +6 -1
- package/js/hosts/installs.mjs +5 -4
- package/js/hosts/native.mjs +3 -1
- package/js/hosts/owned.mjs +34 -6
- package/js/hosts/settings.mjs +5 -4
- package/js/inspect/cost.mjs +37 -5
- package/js/inspect/diff.mjs +132 -61
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +73 -2
- package/js/maintain/setup.mjs +31 -7
- package/js/maintain/uninstall.mjs +1 -1
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +36 -3
- package/js/web/api.mjs +12 -5
- package/js/web/handler.mjs +4 -16
- package/js/web/jobs.mjs +5 -3
- package/js/web/state.mjs +3 -0
- package/package.json +1 -1
- package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-D7Igwuoo.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-53rp05hx.js} +1 -1
- package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
- package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-BM56RCT8.js} +3 -3
- package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
- package/web/dist/assets/{Installs-CWuUU4I3.js → Installs-Dh1tB1NK.js} +2 -2
- package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
- package/web/dist/assets/Laws-iEheocNI.js +3 -0
- package/web/dist/assets/Library-d4_uOGmC.js +1 -0
- package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-BbYeWl70.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-Dki9Q6W3.js} +1 -1
- package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
- package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-CAl6oFwG.js} +1 -1
- package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
- package/web/dist/assets/index-BZgEGRbL.js +2 -0
- package/web/dist/index.html +1 -1
- package/web/src/pages/Laws.jsx +60 -353
- package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
- package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
- package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
- package/web/dist/assets/Laws-R5zZCmya.js +0 -3
- package/web/dist/assets/Library-u36V7oSD.js +0 -1
- package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
- package/web/dist/assets/drawable-CE-arUnF.js +0 -1
- package/web/dist/assets/index-3lnPT_1J.js +0 -2
package/js/hosts/hooks-done.mjs
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
* result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
|
|
29
29
|
* 14 000+ real Claude Code results, 2026-10-17).
|
|
30
30
|
*
|
|
31
|
+
* READ FROM THE END (constant-cost Stop). Only the current turn matters, so the transcript is read
|
|
32
|
+
* backward in growing windows (`tailReader`) until the turn's opening prompt is inside one; a
|
|
33
|
+
* turn longer than every window falls back to the whole file. Never judged on a truncated turn:
|
|
34
|
+
* a window without a prompt answers "read more", not "no edit".
|
|
35
|
+
*
|
|
31
36
|
* At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
|
|
32
37
|
* gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
|
|
33
38
|
*/
|
|
@@ -37,7 +42,7 @@ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
|
|
|
37
42
|
import { within } from '../lib/paths.mjs';
|
|
38
43
|
import { gitRootOf } from './gitref.mjs';
|
|
39
44
|
import { DONE_LIST, recordGate } from './hooks.mjs';
|
|
40
|
-
import { splitLines } from './hooks-prims.mjs';
|
|
45
|
+
import { splitLines, tailReader } from './hooks-prims.mjs';
|
|
41
46
|
|
|
42
47
|
const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
|
|
43
48
|
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
@@ -102,18 +107,24 @@ function isPrompt(obj) {
|
|
|
102
107
|
/**
|
|
103
108
|
* The decision, over transcript TEXT: the checks that have not passed since the turn's last
|
|
104
109
|
* edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
|
|
105
|
-
* unit tier — a transcript is text no CLI cell can vary cheaply.
|
|
110
|
+
* unit tier — a transcript is text no CLI cell can vary cheaply. `whole` false means the text
|
|
111
|
+
* is only the file's tail: with no prompt in it the turn may start earlier, so the answer is
|
|
112
|
+
* null ("read more") — a turn judged from its middle could miss its edit and wrongly pass.
|
|
106
113
|
*/
|
|
107
|
-
export function doneDecide(transcript, checks, root, cwd = root) {
|
|
114
|
+
export function doneDecide(transcript, checks, root, cwd = root, whole = true) {
|
|
108
115
|
const entries = [];
|
|
109
116
|
for (const line of splitLines(transcript)) {
|
|
110
117
|
if (!line.trim()) continue;
|
|
111
118
|
try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
|
|
112
119
|
}
|
|
113
|
-
let start =
|
|
120
|
+
let start = -1;
|
|
114
121
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
115
122
|
if (isPrompt(entries[i])) { start = i + 1; break; }
|
|
116
123
|
}
|
|
124
|
+
if (start === -1) {
|
|
125
|
+
if (!whole) return null;
|
|
126
|
+
start = 0;
|
|
127
|
+
}
|
|
117
128
|
const calls = []; // tool_use blocks, in order
|
|
118
129
|
const results = new Map(); // tool_use_id -> is_error
|
|
119
130
|
for (const obj of entries.slice(start)) {
|
|
@@ -147,9 +158,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
|
|
|
147
158
|
* The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
|
|
148
159
|
* goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
|
|
149
160
|
* `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
|
|
150
|
-
* ledger lands in the same root its sovereignty check read.
|
|
161
|
+
* ledger lands in the same root its sovereignty check read. `read` is `cmdLearn`'s per-Stop
|
|
162
|
+
* transcript reader, so the distillation after an allow reuses the windows read here.
|
|
151
163
|
*/
|
|
152
|
-
export function doneGate(meta, args, install) {
|
|
164
|
+
export function doneGate(meta, args, install, read = tailReader()) {
|
|
153
165
|
try {
|
|
154
166
|
const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
|
|
155
167
|
const root = gitRootOf(cwd);
|
|
@@ -160,15 +172,15 @@ export function doneGate(meta, args, install) {
|
|
|
160
172
|
err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
|
|
161
173
|
return false;
|
|
162
174
|
}
|
|
163
|
-
let
|
|
175
|
+
let missing;
|
|
164
176
|
try {
|
|
165
|
-
|
|
177
|
+
missing = read(meta.transcript_path,
|
|
178
|
+
(text, whole) => doneDecide(text, checks, root, cwd, whole));
|
|
166
179
|
} catch {
|
|
167
180
|
// No ask tier on Stop: blocking on an unreadable transcript would trap the user.
|
|
168
181
|
err('[done-gate] transcript unreadable — turn not judged.\n');
|
|
169
182
|
return false;
|
|
170
183
|
}
|
|
171
|
-
const missing = doneDecide(transcript, checks, root, cwd);
|
|
172
184
|
if (!missing.length) return false;
|
|
173
185
|
if (meta.stop_hook_active === true) {
|
|
174
186
|
recordGate(install, 'done-gate-cap', 'law-3');
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
12
12
|
import {
|
|
13
13
|
globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
|
|
14
14
|
} from './hosts.mjs';
|
|
15
|
-
import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
|
|
15
|
+
import { readStdin, splitLines, splitWords, tailReader } from './hooks-prims.mjs';
|
|
16
16
|
import { doneGate } from './hooks-done.mjs';
|
|
17
17
|
import {
|
|
18
18
|
existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
|
|
@@ -94,15 +94,26 @@ function contentText(content) {
|
|
|
94
94
|
return '';
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Render a Claude-Code-style JSONL transcript into `role: text` notes, read from the END: the
|
|
99
|
+
* window grows until its notes hold twice MAX_NOTES_CHARS UTF-16 units (so at least
|
|
100
|
+
* MAX_NOTES_CHARS code points) or it is the whole file. The window's notes are a suffix of the
|
|
101
|
+
* whole file's, so the tail `cmdLearn` keeps is the one a full read would give — and a tail of
|
|
102
|
+
* tool output, which flattens to nothing, just reads further back.
|
|
103
|
+
*/
|
|
104
|
+
function flattenTranscript(p, read) {
|
|
100
105
|
try {
|
|
101
106
|
// `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
|
|
102
|
-
|
|
107
|
+
return read(p, (raw, whole) => {
|
|
108
|
+
const flat = flattenText(raw);
|
|
109
|
+
return whole || flat.length >= 2 * MAX_NOTES_CHARS ? flat : null;
|
|
110
|
+
});
|
|
103
111
|
} catch {
|
|
104
112
|
return '';
|
|
105
113
|
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function flattenText(raw) {
|
|
106
117
|
const acc = [];
|
|
107
118
|
for (let line of splitLines(raw)) {
|
|
108
119
|
line = line.trim();
|
|
@@ -152,11 +163,11 @@ function flattenTranscript(p) {
|
|
|
152
163
|
* store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
|
|
153
164
|
* reaches none of them.
|
|
154
165
|
*/
|
|
155
|
-
export function readNotes(raw, meta = hookMeta(raw)) {
|
|
166
|
+
export function readNotes(raw, meta = hookMeta(raw), read = tailReader()) {
|
|
156
167
|
if (!raw.trim()) return '';
|
|
157
168
|
const tp = meta.agent_transcript_path || meta.transcript_path;
|
|
158
169
|
if (!tp) return raw;
|
|
159
|
-
const flat = flattenTranscript(tp);
|
|
170
|
+
const flat = flattenTranscript(tp, read);
|
|
160
171
|
const lastMsg = typeof meta.last_assistant_message === 'string'
|
|
161
172
|
? meta.last_assistant_message.trim() : '';
|
|
162
173
|
if (!flat) return lastMsg || flat;
|
|
@@ -314,7 +325,9 @@ export function cmdLearn(args) {
|
|
|
314
325
|
// means the turn goes on, so there is nothing to distil yet: the next Stop will.
|
|
315
326
|
// `--no-done-gate` (the install switched Law III's gate off) skips the check, never the
|
|
316
327
|
// distillation below.
|
|
317
|
-
|
|
328
|
+
// One reader for this Stop: an allowed turn's distillation reuses the windows the gate read.
|
|
329
|
+
const read = tailReader();
|
|
330
|
+
if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install, read)) return 0;
|
|
318
331
|
|
|
319
332
|
// I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
|
|
320
333
|
// to distil — and the bare envelope itself is not notes text (that would waste a model call
|
|
@@ -334,7 +347,7 @@ export function cmdLearn(args) {
|
|
|
334
347
|
|
|
335
348
|
// B3: a real hook call with `$GENESEED_LLM` unset has nothing to send a prompt to — stdout on
|
|
336
349
|
// Stop/SubagentStop/PreCompact reaches only the debug log, never the user (docs `hooks.md`) —
|
|
337
|
-
// so reading and flattening the transcript (
|
|
350
|
+
// so reading and flattening the transcript (a 2 MB tail or more, every turn) just to print it nowhere
|
|
338
351
|
// is pure waste. Checked BEFORE `readNotes`, which is what does that read. A manual
|
|
339
352
|
// invocation keeps printing the prompt on an unset `$GENESEED_LLM` — that is the documented,
|
|
340
353
|
// deliberate behaviour (docs/reference/env-context.md) for someone running `learn` by hand to
|
|
@@ -345,7 +358,7 @@ export function cmdLearn(args) {
|
|
|
345
358
|
return 0;
|
|
346
359
|
}
|
|
347
360
|
|
|
348
|
-
let notes = readNotes(raw, meta);
|
|
361
|
+
let notes = readNotes(raw, meta, read);
|
|
349
362
|
if (!notes.trim()) {
|
|
350
363
|
err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
|
|
351
364
|
return 0;
|
package/js/hosts/hooks-prims.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { statSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { comparePaths } from '../lib/paths.mjs';
|
|
13
|
+
import { readTail } from '../lib/fs.mjs';
|
|
13
14
|
|
|
14
15
|
// ---- Python path and text primitives -------------------------------------------------
|
|
15
16
|
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
@@ -75,6 +76,30 @@ export function splitLines(text) {
|
|
|
75
76
|
return parts;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** The first window a Stop reads from a transcript's end; real transcripts reach 50 MB. */
|
|
80
|
+
export const TAIL_BYTES = 2 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A transcript reader for ONE hook invocation: `read(file, judge)` hands `judge(text, whole)`
|
|
84
|
+
* the file's last `bytes`, then a window four times larger, until `judge` answers (anything
|
|
85
|
+
* but null) or the window holds the whole file — where it must answer. Each window is read once
|
|
86
|
+
* per reader, so the done gate and `learn` share one read when both run on the same Stop.
|
|
87
|
+
* A read error (missing, unreadable) throws to the caller, uncached. Never a module-level cache:
|
|
88
|
+
* the host appends between Stops, and a test rewrites the same path.
|
|
89
|
+
*/
|
|
90
|
+
export function tailReader(bytes = TAIL_BYTES) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
return (file, judge) => {
|
|
93
|
+
for (let n = bytes; ; n *= 4) {
|
|
94
|
+
const key = `${n}:${file}`;
|
|
95
|
+
if (!seen.has(key)) seen.set(key, readTail(file, n));
|
|
96
|
+
const { text, start } = seen.get(key);
|
|
97
|
+
const answer = judge(text, start === 0);
|
|
98
|
+
if (answer !== null || start === 0) return answer;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
79
104
|
export function splitWords(s) {
|
|
80
105
|
return s.split(/\s+/).filter(Boolean);
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -525,8 +525,13 @@ export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
|
525
525
|
* not seen here; CI running the checks is the boundary that holds against that.
|
|
526
526
|
*/
|
|
527
527
|
function protectedCheck(p) {
|
|
528
|
+
return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p));
|
|
529
|
+
}
|
|
530
|
+
// One reading of the target. `protectedCheck` takes two — the path as written and the path with
|
|
531
|
+
// links followed — so a link from outside into a protected folder and a protected folder that
|
|
532
|
+
// links out of the repo are both caught: following links never loosens the gate.
|
|
533
|
+
function protectedIn(target) {
|
|
528
534
|
try {
|
|
529
|
-
const target = resolvePath(p);
|
|
530
535
|
const root = gitRootOf(path.dirname(target));
|
|
531
536
|
if (!root) return null;
|
|
532
537
|
if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -609,9 +609,6 @@ export function gatesOfDir(d, host = null) {
|
|
|
609
609
|
return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, gateOfDir(d, g.marker, host)]));
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
/** Law IV's switch alone (#319's reader, kept for its callers). */
|
|
613
|
-
export const deletionGateOfDir = (d, host = null) => gateOfDir(d, 'Deletion gate', host);
|
|
614
|
-
|
|
615
612
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
616
613
|
|
|
617
614
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
@@ -838,7 +835,11 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
|
|
|
838
835
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
839
836
|
|
|
840
837
|
/**
|
|
841
|
-
* `_harness_setup._installed_defaults` —
|
|
838
|
+
* `_harness_setup._installed_defaults` — eight keys: `theme`, `posture`, `mode`, `emit`,
|
|
839
|
+
* `footprint`, `doctrines`, `excludeRules`, `excludeSkills`. NOT the gate switches: a walk
|
|
840
|
+
* across every install on the machine would carry one host's `off` into another's re-run, and
|
|
841
|
+
* for a gate that disarms a boundary its owner never switched off — `setup` reads them off the
|
|
842
|
+
* one install it is re-running (`targetGates` in js/maintain/setup.mjs).
|
|
842
843
|
*
|
|
843
844
|
* It carried only `theme` and `emit` for four phases because those are the two `status`
|
|
844
845
|
* prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
|
package/js/hosts/native.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
|
33
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
|
+
import { resolvePath } from './hosts.mjs';
|
|
36
37
|
import { claimLeaf, ownedState } from './owned.mjs';
|
|
37
38
|
// `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
|
|
38
39
|
// (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
|
|
@@ -497,8 +498,9 @@ export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
|
|
|
497
498
|
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
498
499
|
let headerPrinted = false;
|
|
499
500
|
const editRefused = new Set();
|
|
501
|
+
const rroot = cfg != null ? resolvePath(cfg) : undefined; // once per emit, not per leaf
|
|
500
502
|
const claim = (dest) => {
|
|
501
|
-
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
|
|
503
|
+
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest), rroot)) return false;
|
|
502
504
|
if (oldSet === null || cfg == null || !existsSync(dest)) return true;
|
|
503
505
|
const rel = relPosix(cfg, dest);
|
|
504
506
|
if (oldSet.has(rel)) {
|
package/js/hosts/owned.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
|
|
27
27
|
import { resolvePath } from './hosts.mjs';
|
|
28
28
|
import { within } from '../lib/paths.mjs';
|
|
29
|
-
import { isFile } from '../lib/fs.mjs';
|
|
29
|
+
import { isFile, statOrNull, textWrites } from '../lib/fs.mjs';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
@@ -68,9 +68,13 @@ export function ownedWithin(base, rels) {
|
|
|
68
68
|
* `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
|
|
69
69
|
* ordinary way through a linked root still resolves inside it and stays allowed — only the
|
|
70
70
|
* LEAF itself being a link is refused.
|
|
71
|
+
*
|
|
72
|
+
* `rroot` is `resolvePath(root)`, passed in by a caller that claims many leaves under one root
|
|
73
|
+
* (`claimer`, once per emit) — re-resolving it per leaf was ~2.6 s of `doctor --all`. Only the
|
|
74
|
+
* ROOT is hoisted: the lstat and the leaf's own `resolvePath` below stay per call, since they
|
|
75
|
+
* are the boundary.
|
|
71
76
|
*/
|
|
72
|
-
export function safeLeaf(root, dest) {
|
|
73
|
-
const rroot = resolvePath(root);
|
|
77
|
+
export function safeLeaf(root, dest, rroot = resolvePath(root)) {
|
|
74
78
|
const st = lstatSync(dest, { throwIfNoEntry: false });
|
|
75
79
|
if (st) {
|
|
76
80
|
if (st.isSymbolicLink()) return false;
|
|
@@ -86,8 +90,8 @@ export function safeLeaf(root, dest) {
|
|
|
86
90
|
* callers that already have it (every one of them, since it is what they are about to push
|
|
87
91
|
* into `owned`) pass it straight through instead of re-deriving it from `dest`.
|
|
88
92
|
*/
|
|
89
|
-
export function claimLeaf(root, dest, rel) {
|
|
90
|
-
if (safeLeaf(root, dest)) return true;
|
|
93
|
+
export function claimLeaf(root, dest, rel, rroot = undefined) {
|
|
94
|
+
if (safeLeaf(root, dest, rroot)) return true;
|
|
91
95
|
process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
|
|
92
96
|
+ 'regular files it owns\n');
|
|
93
97
|
return false;
|
|
@@ -107,6 +111,19 @@ export function fileHash(p) {
|
|
|
107
111
|
return createHash('sha256').update(folded, 'latin1').digest('hex');
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `fileHash` of a file whose whole content is `written`, computed from the string instead of
|
|
116
|
+
* read back — `written` must be EXACTLY what went to disk as utf8 (`writeText`'s translated
|
|
117
|
+
* text, never the text before its `\n` -> `os.EOL`: an embedded `\r\n` becomes `\r\r\n` on
|
|
118
|
+
* Windows, which folds to `\r\n`, not `\n`). CR and LF are single bytes that never occur
|
|
119
|
+
* inside a multi-byte utf8 sequence, so folding the string then encoding equals encoding then
|
|
120
|
+
* folding.
|
|
121
|
+
*/
|
|
122
|
+
export function textHash(written) {
|
|
123
|
+
return createHash('sha256').update(Buffer.from(written.replaceAll('\r\n', '\n'), 'utf8'))
|
|
124
|
+
.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
/**
|
|
111
128
|
* One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
|
|
112
129
|
* containment boundary every other owned-rel walk goes through) and a file that still exists —
|
|
@@ -114,12 +131,23 @@ export function fileHash(p) {
|
|
|
114
131
|
* rather than hashed. Keys land in the same sorted order the manifest writers already give
|
|
115
132
|
* `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
|
|
116
133
|
* tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
|
|
134
|
+
*
|
|
135
|
+
* A file this emit wrote through `writeText` is hashed from the text it recorded
|
|
136
|
+
* (`textWrites`, `js/lib/fs.mjs`) rather than read back, while its size and mtime still match
|
|
137
|
+
* the stat taken at write time; anything else — a copied binary, an unrecorded rewrite — is read.
|
|
138
|
+
* shortcut: a same-size rewrite by a non-`writeText` writer inside one mtime tick would keep the
|
|
139
|
+
* stale record; no emit does that today (measured across golden's cells, 0 mismatches) — route
|
|
140
|
+
* such a writer through `writeText` or drop its entry if one ever appears.
|
|
117
141
|
*/
|
|
118
142
|
export function ownedHashes(root, owned) {
|
|
119
143
|
const hashes = {};
|
|
120
144
|
for (const rel of ownedWithin(root, owned).sort()) {
|
|
121
145
|
const p = path.join(root, rel);
|
|
122
|
-
|
|
146
|
+
const st = statOrNull(p);
|
|
147
|
+
if (!st?.isFile()) continue;
|
|
148
|
+
const w = textWrites.map?.get(path.resolve(p));
|
|
149
|
+
hashes[rel] = w && w.size === st.size && w.mtimeMs === st.mtimeMs ? textHash(w.text)
|
|
150
|
+
: fileHash(p);
|
|
123
151
|
}
|
|
124
152
|
return hashes;
|
|
125
153
|
}
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -726,7 +726,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
|
|
|
726
726
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
727
727
|
*/
|
|
728
728
|
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
|
|
729
|
-
|
|
729
|
+
gates = {}) {
|
|
730
|
+
// One object keyed as `GATE_SWITCHES` keys it; only an explicit `false` disarms a gate.
|
|
731
|
+
const { secretsGate = true, doneGate = true, deletionGate = true } = gates ?? {};
|
|
730
732
|
const run = hookPrefix(hookOpts);
|
|
731
733
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
732
734
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
|
|
@@ -932,15 +934,14 @@ function readSettingsForMerge(p, consequence) {
|
|
|
932
934
|
* global that never stood down for a project install.
|
|
933
935
|
*/
|
|
934
936
|
export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
|
|
935
|
-
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p),
|
|
936
|
-
gates = {}) {
|
|
937
|
+
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), gates = {}) {
|
|
937
938
|
const prior = (priorHooks || []).filter(isDict);
|
|
938
939
|
const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
|
|
939
940
|
if (!loaded) return [p, prior];
|
|
940
941
|
const [config, hadComments] = loaded;
|
|
941
942
|
let hooks = get(config, 'hooks');
|
|
942
943
|
if (!isDict(hooks)) hooks = {};
|
|
943
|
-
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host,
|
|
944
|
+
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, gates);
|
|
944
945
|
const canonFlat = [];
|
|
945
946
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
946
947
|
for (const g of groups) canonFlat.push({ event, group: g });
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
|
|
10
10
|
* `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
|
|
11
11
|
* which the smoke test does not, so the two agree on any carrier without one.
|
|
12
|
-
* - `skills` the name + description every native skill lists
|
|
13
|
-
*
|
|
12
|
+
* - `skills` the name + description of every native skill the MODEL lists. A user-only skill
|
|
13
|
+
* (and every alias, which native.mjs writes user-only) stays on disk but out of
|
|
14
|
+
* the catalogue, by each host's own mechanism (`userOnly`): the frontmatter's
|
|
15
|
+
* `disable-model-invocation` on Claude Code and OpenClaude; on OpenCode, which
|
|
16
|
+
* ignores that key, the `permission.skill` deny in `opencode.json`; on Bob no
|
|
17
|
+
* SKILL.md exists for one, so there is nothing to skip.
|
|
14
18
|
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
15
19
|
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
16
20
|
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
@@ -21,6 +25,8 @@ import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
|
21
25
|
import { carriersFor } from '../hosts/installs.mjs';
|
|
22
26
|
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
23
27
|
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
28
|
+
import { loadJsonObject, opencodeTarget } from '../hosts/settings.mjs';
|
|
29
|
+
import { isDict } from '../lib/json.mjs';
|
|
24
30
|
import { isFile, readText } from '../lib/fs.mjs';
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -44,14 +50,39 @@ function fmValue(raw) {
|
|
|
44
50
|
return raw;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
53
|
+
/**
|
|
54
|
+
* name + description of one spec file; `fallbackName` for agents, whose name is the file stem.
|
|
55
|
+
* `hidden(fm)` answers true for a file the host keeps out of the model's catalogue: it counts 0.
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @param {string} fallbackName
|
|
58
|
+
* @param {(fm: Map<string, string>) => boolean} [hidden]
|
|
59
|
+
*/
|
|
60
|
+
function listedChars(file, fallbackName, hidden = () => false) {
|
|
49
61
|
let fm;
|
|
50
62
|
try { [fm] = frontmatter(readText(file)); } catch { return 0; }
|
|
63
|
+
if (hidden(fm)) return 0;
|
|
51
64
|
const name = fmValue(fm.get('name')) || fallbackName;
|
|
52
65
|
return name.length + fmValue(fm.get('description')).length;
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How `host` hides a skill from the model, mirroring `writeNativeLayer` (js/hosts/native.mjs).
|
|
70
|
+
* OpenCode reads the deny where `mcpConfigFor` finds its config (`opencode.json(c)` at the
|
|
71
|
+
* root); a blanket non-object `permission.skill` is the user's own policy and hides nothing
|
|
72
|
+
* by name, so nothing is skipped. Bob documents no such key: nothing is skipped either.
|
|
73
|
+
*/
|
|
74
|
+
function userOnlyTest(host, root) {
|
|
75
|
+
if (host === 'claude' || host === 'openclaude') {
|
|
76
|
+
return (_name, fm) => fm.get('disable-model-invocation') === 'true';
|
|
77
|
+
}
|
|
78
|
+
if (host === 'opencode') {
|
|
79
|
+
const { data } = loadJsonObject(opencodeTarget(path.join(root, 'opencode.json')));
|
|
80
|
+
const skill = isDict(data) && isDict(data.permission) ? data.permission.skill : null;
|
|
81
|
+
return (name) => isDict(skill) && skill[name] === 'deny';
|
|
82
|
+
}
|
|
83
|
+
return () => false;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
57
88
|
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
@@ -64,9 +95,10 @@ export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
|
64
95
|
|
|
65
96
|
let skills = 0;
|
|
66
97
|
const skillsDir = path.join(dir, 'skills');
|
|
98
|
+
const userOnly = userOnlyTest(host, root);
|
|
67
99
|
for (const name of listDir(skillsDir)) {
|
|
68
100
|
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
69
|
-
if (isFile(f)) skills += listedChars(f, name);
|
|
101
|
+
if (isFile(f)) skills += listedChars(f, name, (fm) => userOnly(name, fm));
|
|
70
102
|
}
|
|
71
103
|
let agents = 0;
|
|
72
104
|
if (host !== 'bob') {
|