geneseed 4.4.1 → 4.6.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 +37 -0
- package/README.md +6 -6
- package/SHIPPED.md +1 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
- package/adapters/opencode/plugins/geneseed-context.js +195 -52
- package/adapters/opencode/plugins/geneseed-guard.js +42 -29
- package/adapters/opencode/plugins/geneseed-learn.js +1 -1
- package/docs/concepts/enforcement-table.md +9 -3
- package/docs/concepts/hooks.md +3 -0
- package/docs/extending.md +1 -1
- package/docs/guides/install-clone.md +1 -1
- package/docs/guides/install.md +2 -2
- package/docs/reference/plugin-context.md +2 -0
- package/docs/reference/troubleshoot-doctor.md +31 -0
- package/docs/reference/troubleshoot-install.md +5 -5
- package/docs/understand/enforced-vs-asked.md +1 -0
- package/harness.config.json +1 -1
- package/js/README.md +8 -5
- package/js/build/bundle.mjs +11 -2
- package/js/build/driver.mjs +1 -10
- package/js/build/emit-claude.mjs +7 -22
- package/js/build/emit-common.mjs +16 -9
- package/js/build/emit-opencode.mjs +5 -11
- package/js/build/generate.mjs +1 -1
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +1 -4
- package/js/hosts/hooks-done.mjs +186 -0
- package/js/hosts/hooks-learn.mjs +9 -4
- package/js/hosts/hooks.mjs +27 -8
- package/js/hosts/hosts.mjs +2 -0
- package/js/hosts/installs.mjs +47 -11
- package/js/hosts/native.mjs +5 -13
- package/js/hosts/opencode.mjs +37 -22
- package/js/hosts/owned.mjs +19 -5
- package/js/hosts/settings.mjs +17 -11
- package/js/hosts/shim.mjs +24 -10
- package/js/hosts/usage.mjs +4 -3
- package/js/inspect/checks-authoring.mjs +9 -7
- package/js/inspect/checks-constitution.mjs +2 -2
- package/js/inspect/checks-probe.mjs +292 -0
- package/js/inspect/checks-repo.mjs +13 -54
- package/js/inspect/cost.mjs +5 -6
- package/js/inspect/doctor.mjs +29 -3
- package/js/inspect/scan.mjs +9 -5
- package/js/inspect/scorecard-svg.mjs +3 -3
- package/js/inspect/scorecard-yaml.mjs +407 -0
- package/js/inspect/scorecard.mjs +7 -405
- package/js/inspect/status.mjs +14 -13
- package/js/lib/node-floor.mjs +4 -3
- package/js/maintain/migrate.mjs +2 -3
- package/js/maintain/uninstall.mjs +20 -26
- package/js/web/actions.mjs +2 -2
- package/js/web/api.mjs +36 -24
- package/js/web/gates-api.mjs +5 -8
- package/js/web/usage-api.mjs +14 -17
- package/package.json +2 -2
- package/src/skills/rule.md +11 -2
- package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
- package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
- package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
- package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
- package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
- package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
- package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
- package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
- package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
- package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
- package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
- package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
- package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
- package/web/dist/assets/index-BmbWeoTM.js +2 -0
- package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
- package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
- package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
- package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
- package/web/dist/assets/index-CdpLpPQN.js +0 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The done gate — Verify Before Asserting's boundary half, at the moment a turn ends (#289).
|
|
3
|
+
*
|
|
4
|
+
* Every other gate sits before an act. This one sits at Stop: a turn that edited files under a
|
|
5
|
+
* project which lists its proof in `.geneseed/done-checks.txt` may not end until every listed
|
|
6
|
+
* check has run AND passed after the last edit. The gate never runs a check itself — the hook
|
|
7
|
+
* path starts no process of its own (ADR 0002) — it reads the turn's own tool calls in the transcript.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT RIDES `learn` AND NOT A VERB OR A FLAG OF ITS OWN (ADR 0010). The machine-wide shim is
|
|
10
|
+
* last-writer-wins: an older `geneseed-hook` would refuse a new verb or flag, and the Stop
|
|
11
|
+
* command's `|| exit 0` would then skip learning too, silently. Arming on a project FILE keeps
|
|
12
|
+
* every emitted settings file byte-identical, so a stale shim only means "no done gate".
|
|
13
|
+
*
|
|
14
|
+
* WHY STDOUT JSON AND NOT EXIT 2. The emitted Stop command ends in `|| exit 0`, which swallows
|
|
15
|
+
* a 2. `{"decision":"block"}` on exit 0 is the one signal that survives the existing wiring.
|
|
16
|
+
* Allowing prints NOTHING — on Stop a stray stdout byte is read as a verdict.
|
|
17
|
+
*
|
|
18
|
+
* WHAT COUNTS (shortcut: write tools only; a shell `sed -i` or redirect is not an edit here —
|
|
19
|
+
* seeing those would need `git status`, a spawn. Same limit as `protected-checks.txt`):
|
|
20
|
+
* - an edit is a Write/Edit/NotebookEdit/MultiEdit whose target is under the project root and
|
|
21
|
+
* whose result is not an error (a denied write changed nothing);
|
|
22
|
+
* - a pass is a Bash/PowerShell call whose command contains the declared line UNPIPED (a pipe
|
|
23
|
+
* takes the exit code of its last stage: `npm test | tail` exits 0 on a red suite) and whose
|
|
24
|
+
* result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
|
|
25
|
+
* 14 000+ real Claude Code results, 2026-10-17).
|
|
26
|
+
*
|
|
27
|
+
* At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
|
|
28
|
+
* gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
|
|
33
|
+
import { within } from '../lib/paths.mjs';
|
|
34
|
+
import { gitRootOf } from './gitref.mjs';
|
|
35
|
+
import { DONE_LIST, recordGate } from './hooks.mjs';
|
|
36
|
+
import { splitLines } from './hooks-prims.mjs';
|
|
37
|
+
|
|
38
|
+
const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
|
|
39
|
+
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
40
|
+
|
|
41
|
+
/** The declared checks, one per line, `#` comments; null when the project has no list. */
|
|
42
|
+
export function readDoneChecks(root) {
|
|
43
|
+
const file = path.join(root, DONE_LIST);
|
|
44
|
+
if (!existsSync(file)) return null;
|
|
45
|
+
try {
|
|
46
|
+
return splitLines(readText(file)).map((l) => l.trim().replace(/\s+/g, ' '))
|
|
47
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when `command` runs `line` as a whole command with no pipe after it in the same segment.
|
|
55
|
+
* A segment ends at `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. The
|
|
56
|
+
* check must open a segment (the start, or right after one of those separators) and be followed
|
|
57
|
+
* by the end, whitespace or a separator, never by more of a word (`npm run lint:fix` does not
|
|
58
|
+
* prove `npm run lint`); a match inside a quoted string (`-m "ran npm test"`) is text, not a run.
|
|
59
|
+
* Whitespace is collapsed on both sides. shortcut: the shell is not parsed — a prefix such as
|
|
60
|
+
* `sudo`/`time`/`env X=1`, a subshell, or a `|` inside a quoted argument after the check all read
|
|
61
|
+
* as "not run". Every approximation here refuses a pass, so the gate errs toward a reminder.
|
|
62
|
+
*/
|
|
63
|
+
export function runsUnpiped(command, line) {
|
|
64
|
+
const cmd = String(command).replace(/[ \t]+/g, ' ');
|
|
65
|
+
for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
|
|
66
|
+
const next = cmd[i + line.length];
|
|
67
|
+
if (next !== undefined && !/[\s;&|]/.test(next)) continue;
|
|
68
|
+
const head = cmd.slice(0, i);
|
|
69
|
+
if (!/(^|&&|\|\||;|\n) ?$/.test(head)) continue;
|
|
70
|
+
let quote = '', esc = false;
|
|
71
|
+
for (const ch of head) {
|
|
72
|
+
if (esc) esc = false;
|
|
73
|
+
else if (ch === '\\' && quote !== "'") esc = true;
|
|
74
|
+
else if (quote) { if (ch === quote) quote = ''; }
|
|
75
|
+
else if (ch === '"' || ch === "'") quote = ch;
|
|
76
|
+
}
|
|
77
|
+
if (quote) continue;
|
|
78
|
+
const rest = cmd.slice(i + line.length);
|
|
79
|
+
let piped = false;
|
|
80
|
+
for (let j = 0; j < rest.length; j += 1) {
|
|
81
|
+
const two = rest.slice(j, j + 2);
|
|
82
|
+
if (two === '&&' || two === '||' || rest[j] === ';' || rest[j] === '\n') break;
|
|
83
|
+
if (rest[j] === '|') { piped = true; break; }
|
|
84
|
+
}
|
|
85
|
+
if (!piped) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A user line that opens a turn: the user typed it, it is not a tool result or a meta note. */
|
|
91
|
+
function isPrompt(obj) {
|
|
92
|
+
if (obj.type !== 'user' || obj.isMeta || obj.isSidechain) return false;
|
|
93
|
+
const c = obj.message && obj.message.content;
|
|
94
|
+
if (typeof c === 'string') return true;
|
|
95
|
+
return Array.isArray(c) && c.length > 0 && !c.some((b) => b && b.type === 'tool_result');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The decision, over transcript TEXT: the checks that have not passed since the turn's last
|
|
100
|
+
* edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
|
|
101
|
+
* unit tier — a transcript is text no CLI cell can vary cheaply.
|
|
102
|
+
*/
|
|
103
|
+
export function doneDecide(transcript, checks, root, cwd = root) {
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const line of splitLines(transcript)) {
|
|
106
|
+
if (!line.trim()) continue;
|
|
107
|
+
try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
|
|
108
|
+
}
|
|
109
|
+
let start = 0;
|
|
110
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
111
|
+
if (isPrompt(entries[i])) { start = i + 1; break; }
|
|
112
|
+
}
|
|
113
|
+
const calls = []; // tool_use blocks, in order
|
|
114
|
+
const results = new Map(); // tool_use_id -> is_error
|
|
115
|
+
for (const obj of entries.slice(start)) {
|
|
116
|
+
if (obj.isSidechain) continue;
|
|
117
|
+
const content = obj.message && obj.message.content;
|
|
118
|
+
if (!Array.isArray(content)) continue;
|
|
119
|
+
for (const b of content) {
|
|
120
|
+
if (!b || typeof b !== 'object') continue;
|
|
121
|
+
if (b.type === 'tool_use') calls.push(b);
|
|
122
|
+
else if (b.type === 'tool_result') results.set(b.tool_use_id, b.is_error === true);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let lastEdit = -1;
|
|
126
|
+
calls.forEach((c, i) => {
|
|
127
|
+
if (!EDIT_TOOLS.has(c.name) || results.get(c.id) === true) return;
|
|
128
|
+
const p = c.input && (c.input.file_path || c.input.notebook_path || c.input.path);
|
|
129
|
+
if (typeof p === 'string' && p && within(path.resolve(cwd, p), root)) lastEdit = i;
|
|
130
|
+
});
|
|
131
|
+
if (lastEdit === -1) return [];
|
|
132
|
+
const passed = new Set();
|
|
133
|
+
calls.slice(lastEdit + 1).forEach((c) => {
|
|
134
|
+
const command = SHELL_TOOLS.has(c.name) && c.input && c.input.command;
|
|
135
|
+
// A call with no result yet (the host has not flushed it) is not a pass.
|
|
136
|
+
if (typeof command !== 'string' || results.get(c.id) !== false) return;
|
|
137
|
+
for (const check of checks) if (runsUnpiped(command, check)) passed.add(check);
|
|
138
|
+
});
|
|
139
|
+
return checks.filter((c) => !passed.has(c));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
|
|
144
|
+
* goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
|
|
145
|
+
* `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
|
|
146
|
+
* ledger lands in the same root its sovereignty check read.
|
|
147
|
+
*/
|
|
148
|
+
export function doneGate(meta, args, install) {
|
|
149
|
+
try {
|
|
150
|
+
const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
|
|
151
|
+
const root = gitRootOf(cwd);
|
|
152
|
+
const checks = root ? readDoneChecks(root) : null;
|
|
153
|
+
if (!checks || !checks.length) return false;
|
|
154
|
+
// Bob's Stop payload carries no transcript (learn's I2): nothing to judge, and it says so.
|
|
155
|
+
if (args.host === 'bob' || typeof meta.transcript_path !== 'string') {
|
|
156
|
+
err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
let transcript;
|
|
160
|
+
try {
|
|
161
|
+
transcript = readText(meta.transcript_path);
|
|
162
|
+
} catch {
|
|
163
|
+
// No ask tier on Stop: blocking on an unreadable transcript would trap the user.
|
|
164
|
+
err('[done-gate] transcript unreadable — turn not judged.\n');
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const missing = doneDecide(transcript, checks, root, cwd);
|
|
168
|
+
if (!missing.length) return false;
|
|
169
|
+
if (meta.stop_hook_active === true) {
|
|
170
|
+
recordGate(install, 'done-gate-cap', 'law-3');
|
|
171
|
+
err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
recordGate(install, 'done-gate', 'law-3');
|
|
175
|
+
out(`${JSON.stringify({
|
|
176
|
+
decision: 'block',
|
|
177
|
+
reason: 'Geneseed (Verify Before Asserting) — this turn edited files, and these '
|
|
178
|
+
+ `checks from ${DONE_LIST} have not passed since the last edit: ${missing.join('; ')}. `
|
|
179
|
+
+ 'Run them unpiped, or say plainly that they were not run and why.',
|
|
180
|
+
})}\n`);
|
|
181
|
+
return true;
|
|
182
|
+
} catch (e) {
|
|
183
|
+
err(`[done-gate] skipped: ${e && e.message}\n`);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
|
|
14
14
|
} from './hosts.mjs';
|
|
15
15
|
import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
|
|
16
|
+
import { doneGate } from './hooks-done.mjs';
|
|
16
17
|
import {
|
|
17
18
|
existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
|
|
18
19
|
} from './memory-files.mjs';
|
|
@@ -292,12 +293,12 @@ export function cmdLearn(args) {
|
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
// The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
|
|
295
|
-
// folder the global install must not learn.
|
|
296
|
-
|
|
296
|
+
// folder the global install must not learn. The done gate's ledger lives in the same root.
|
|
297
|
+
const install = args.memory ? path.dirname(toPlatformPath(args.memory)) : null;
|
|
298
|
+
if (install && sovereignBypass(install)) return 0;
|
|
297
299
|
// Same install root, and a project install of this host beside it learns on its own Stop: a
|
|
298
300
|
// second LLM call, and the global store learning facts the project owns (Claude B4).
|
|
299
|
-
if (
|
|
300
|
-
hookProjectDir(), args.host, 'learn')) return 0;
|
|
301
|
+
if (install && globalHookStandingDown(install, hookProjectDir(), args.host, 'learn')) return 0;
|
|
301
302
|
|
|
302
303
|
const raw = args.file ? readText(args.file) : readStdin();
|
|
303
304
|
const meta = hookMeta(raw);
|
|
@@ -309,6 +310,10 @@ export function cmdLearn(args) {
|
|
|
309
310
|
const hookEvent = typeof meta.hook_event_name === 'string' ? meta.hook_event_name
|
|
310
311
|
: (typeof meta.event === 'string' ? meta.event : null);
|
|
311
312
|
|
|
313
|
+
// The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
|
|
314
|
+
// means the turn goes on, so there is nothing to distil yet: the next Stop will.
|
|
315
|
+
if (hookEvent === 'Stop' && !args.file && doneGate(meta, args, install)) return 0;
|
|
316
|
+
|
|
312
317
|
// I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
|
|
313
318
|
// to distil — and the bare envelope itself is not notes text (that would waste a model call
|
|
314
319
|
// on `{"event":"Stop","session_id":"s"}`). Scoped to `--host bob`: every other host's Stop/
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -255,8 +255,12 @@ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5', 'ops-2']);
|
|
|
255
255
|
* information a trimmed file loses. The re-read happens on the ask path only.
|
|
256
256
|
*/
|
|
257
257
|
const MAX_LEDGER_LINES = 1000;
|
|
258
|
+
// Exported for the done gate (`./hooks-done.mjs`), which records from Stop, not from `ask`.
|
|
259
|
+
export function recordGate(root, verb, rule) { ledger(root, verb, rule); }
|
|
258
260
|
function ledger(root, verb, rule) {
|
|
259
|
-
|
|
261
|
+
// `doctor --probe` asks on purpose; its asks are not the user's. Read here, on the rare ask
|
|
262
|
+
// path only: the variable silences the RECORD, never the decision.
|
|
263
|
+
if (!root || process.env.GENESEED_GATE_PROBE) return;
|
|
260
264
|
try {
|
|
261
265
|
const file = path.join(root, GATE_LEDGER);
|
|
262
266
|
appendFileSync(file, `${JSON.stringify({
|
|
@@ -441,6 +445,8 @@ export function rootScan(command) {
|
|
|
441
445
|
const m = /^-path[=:](.+)$/i.exec(t);
|
|
442
446
|
return m ? m[1] : t;
|
|
443
447
|
});
|
|
448
|
+
// For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
|
|
449
|
+
// POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
|
|
444
450
|
if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
|
|
445
451
|
|| (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
|
|
446
452
|
|| (ps && PS_RECURSE_RE.test(a)))) continue;
|
|
@@ -448,8 +454,6 @@ export function rootScan(command) {
|
|
|
448
454
|
}
|
|
449
455
|
return null;
|
|
450
456
|
}
|
|
451
|
-
// For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
|
|
452
|
-
// POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
|
|
453
457
|
const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
|
|
454
458
|
// A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
|
|
455
459
|
// (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
|
|
@@ -523,21 +527,25 @@ const SECRET_RE =
|
|
|
523
527
|
/\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-----/;
|
|
524
528
|
const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
|
|
525
529
|
|
|
530
|
+
export const SENSOR_LIST = '.geneseed/protected-checks.txt';
|
|
531
|
+
// The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
|
|
532
|
+
// protected-checks list: emptying it would disarm the gate it feeds.
|
|
533
|
+
export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
526
534
|
/**
|
|
527
535
|
* External Gate's boundary half: the checks an agent is judged by must sit outside what it can
|
|
528
536
|
* edit, or a failing check is one edit away from a passing one. A project opts in by listing
|
|
529
537
|
* them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
|
|
530
538
|
* its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
|
|
531
539
|
* writing into another reads that other repo's list. The list protects itself. Returns the entry
|
|
532
|
-
* the path falls under, or null.
|
|
540
|
+
* the path falls under, or null. shortcut: a write tool only — a shell `sed -i` or redirect is
|
|
533
541
|
* not seen here; CI running the checks is the boundary that holds against that.
|
|
534
542
|
*/
|
|
535
|
-
export const SENSOR_LIST = '.geneseed/protected-checks.txt';
|
|
536
543
|
function protectedCheck(p) {
|
|
537
544
|
try {
|
|
538
545
|
const target = resolvePath(p);
|
|
539
546
|
const root = gitRootOf(path.dirname(target));
|
|
540
547
|
if (!root) return null;
|
|
548
|
+
if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
|
|
541
549
|
const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
|
|
542
550
|
// `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
|
|
543
551
|
// separator of the prefix included.
|
|
@@ -555,6 +563,14 @@ function ruleGate(args) {
|
|
|
555
563
|
return ruleDecide(args, readPayload());
|
|
556
564
|
}
|
|
557
565
|
|
|
566
|
+
// The REPLACE halves of a Roo-style `apply_diff` (`<<<<<<< SEARCH … ======= <new> >>>>>>> REPLACE`).
|
|
567
|
+
// A diff without those markers is a shape we do not know, so it is returned whole: fail closed.
|
|
568
|
+
function diffReplaced(diff) {
|
|
569
|
+
if (typeof diff !== 'string') return diff;
|
|
570
|
+
const halves = [...diff.matchAll(/^=======\r?\n([\s\S]*?)^>>>>>>> REPLACE/gm)].map((m) => m[1]);
|
|
571
|
+
return halves.length ? halves.join('\n') : diff;
|
|
572
|
+
}
|
|
573
|
+
|
|
558
574
|
function ruleDecide(args, payload) {
|
|
559
575
|
const ti = (payload && payload.tool_input) || {};
|
|
560
576
|
// NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
|
|
@@ -567,9 +583,11 @@ function ruleDecide(args, payload) {
|
|
|
567
583
|
// cheap, harmless guard in case that ever changes.
|
|
568
584
|
// `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
|
|
569
585
|
// field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
|
|
570
|
-
// required, same as every field above.
|
|
586
|
+
// required, same as every field above. Only WRITTEN text is scanned: `search` and a diff's
|
|
587
|
+
// SEARCH halves are text leaving the file (like Claude's `old_string`), so a secret there is
|
|
588
|
+
// being removed — blocking it would stop the agent cleaning up a leak.
|
|
571
589
|
const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
|
|
572
|
-
const body = [ti.content, ti.new_string, ti.new_source, ti.diff, ti.replace,
|
|
590
|
+
const body = [ti.content, ti.new_string, ti.new_source, diffReplaced(ti.diff), ti.replace, ...edits]
|
|
573
591
|
.filter((v) => typeof v === 'string').join('\n');
|
|
574
592
|
if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
|
|
575
593
|
return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
|
|
@@ -579,7 +597,8 @@ function ruleDecide(args, payload) {
|
|
|
579
597
|
const sensor = protectedCheck(p);
|
|
580
598
|
if (sensor) {
|
|
581
599
|
return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
|
|
582
|
-
+ `check (${sensor
|
|
600
|
+
+ `check (${sensor === DONE_LIST ? 'the done gate list itself'
|
|
601
|
+
: `${sensor}, listed in ${SENSOR_LIST}`}). A check the agent can edit is not a `
|
|
583
602
|
+ 'check: fix the code, not the check, unless the user asks for this edit.');
|
|
584
603
|
}
|
|
585
604
|
const target = ruleGateTarget(p, args.root);
|
package/js/hosts/hosts.mjs
CHANGED
|
@@ -55,6 +55,8 @@ export function sovereignBypass(root) {
|
|
|
55
55
|
} catch {
|
|
56
56
|
return false;
|
|
57
57
|
}
|
|
58
|
+
// Nothing to match, so no reason to pay for the cwd and `.git` reads below on every hook call.
|
|
59
|
+
if (!entries.length) return false;
|
|
58
60
|
let heres;
|
|
59
61
|
try {
|
|
60
62
|
heres = [normcase(resolvePath(process.cwd()))];
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import { registryRoots } from '../inspect/registry.mjs';
|
|
|
44
44
|
import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
|
|
45
45
|
import { formatRepr, isDict } from '../lib/json.mjs';
|
|
46
46
|
import { comparePaths } from '../lib/paths.mjs';
|
|
47
|
-
import { claudeHookShell } from './shim.mjs';
|
|
47
|
+
import { claudeHookShell, hookShimPath } from './shim.mjs';
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
|
|
@@ -567,10 +567,10 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
|
|
|
567
567
|
* name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
|
|
568
568
|
* else the install left out. Silent unless the caller passes `notify` — status, diff and the
|
|
569
569
|
* console call this on every read; the replays that rebuild an install (`rebuild-all`,
|
|
570
|
-
* `upgrade`, `migrate`) pass it. `host`
|
|
571
|
-
* `
|
|
570
|
+
* `upgrade`, `migrate`) pass it. `host` comes second, the same `(d, host)` order as its
|
|
571
|
+
* siblings `excludedRulesOfDir`/`doctrinesOfDir`, so the marker readers call alike.
|
|
572
572
|
*/
|
|
573
|
-
export function excludedSkillsOfDir(d,
|
|
573
|
+
export function excludedSkillsOfDir(d, host = null, notify = null) {
|
|
574
574
|
return firstCarrier(d, (carrierPath) => {
|
|
575
575
|
const text = readMaybe(carrierPath);
|
|
576
576
|
if (text === null) return undefined;
|
|
@@ -615,9 +615,6 @@ export function emitHostScopeOf(root) {
|
|
|
615
615
|
return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
-
// `DISABLED_STASH` moved to `hosts.mjs`: the hook stand-down reads it and must not import this.
|
|
619
|
-
export { DISABLED_STASH };
|
|
620
|
-
|
|
621
618
|
/**
|
|
622
619
|
* `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
|
|
623
620
|
*
|
|
@@ -733,12 +730,12 @@ export function installTargets() {
|
|
|
733
730
|
|
|
734
731
|
/**
|
|
735
732
|
* Each Claude install whose emitted hook FORM no longer matches this machine's hook shell
|
|
736
|
-
* (`claudeHookShell
|
|
733
|
+
* (`claudeHookShell`) — the "Git Bash removed after the emit" gap, which otherwise
|
|
737
734
|
* reports nothing: hooks signal through stdout and Claude treats a parse error as non-blocking.
|
|
738
735
|
*
|
|
739
736
|
* Bash form with Git Bash now gone is a PROBLEM: `context`/`learn` fail open under PowerShell
|
|
740
|
-
* (no `onFailure`, so a launch failure there is still non-blocking)
|
|
741
|
-
* gates fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
|
|
737
|
+
* (no `onFailure`, so a launch failure there is still non-blocking) while the two gates,
|
|
738
|
+
* being gates, fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
|
|
742
739
|
* NotebookEdit call until `rebuild-all` re-emits the PowerShell form) — worse for the user
|
|
743
740
|
* either way, so the message below still reports it as drift needing a rebuild; it does not
|
|
744
741
|
* distinguish the two outcomes. PowerShell form with Git Bash now present is a `[note]`: the hooks work,
|
|
@@ -763,13 +760,52 @@ export function hookShellProblems(targets = installTargets(), platform = process
|
|
|
763
760
|
out.push(was === 'bash'
|
|
764
761
|
? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
|
|
765
762
|
+ 'Code runs them under PowerShell where context/learn fail open (no effect) and '
|
|
766
|
-
+ 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all'
|
|
763
|
+
+ 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all, '
|
|
764
|
+
+ 'then confirm with: geneseed doctor --probe'
|
|
767
765
|
: `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
|
|
768
766
|
+ 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
|
|
769
767
|
}
|
|
770
768
|
return out;
|
|
771
769
|
}
|
|
772
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Every active Claude-STYLE install (`CLAUDE_STYLE`: claude, bob, openclaude — the hosts that
|
|
773
|
+
* share this one machine-wide shim) whose LIVE, recorded hooks still name `shimPath` by exact
|
|
774
|
+
* string, keyed by its own config dir.
|
|
775
|
+
*
|
|
776
|
+
* Split out of `shimProblems` (`js/inspect/checks-repo.mjs`) for the case `shimDeadPaths`
|
|
777
|
+
* cannot see: that function reads paths back OUT of the shim body, so it has nothing to read once the shim file is gone
|
|
778
|
+
* outright (deleted, not moved-and-regenerated) — a plain `isFile` check upstream then calls
|
|
779
|
+
* that clean, the same as a source checkout that never emitted. `liveRecordedHooks` is already
|
|
780
|
+
* "the manifest claims it AND the settings file still runs it" (`js/hosts/hosts.mjs`), so a
|
|
781
|
+
* disabled or never-wired install cannot appear here — only installs for which the missing
|
|
782
|
+
* shim is a live, standing failure. It also keeps only handlers whose `command` is a string,
|
|
783
|
+
* so the match below needs no type guard of its own.
|
|
784
|
+
*
|
|
785
|
+
* MATCHES THE EXACT PATH, not merely a command that mentions the shim's filename
|
|
786
|
+
* (`geneseed-hook`): the shim path is baked per `GENESEED_HOME`, so a machine with more than
|
|
787
|
+
* one Geneseed home (a relocated one, or a sandboxed test run beside a real install) must not
|
|
788
|
+
* let an install naming a DIFFERENT, perfectly healthy shim answer for this one.
|
|
789
|
+
*
|
|
790
|
+
* LIVES HERE, beside the `claudeCfg` and `installTargets` it is built on, because neither
|
|
791
|
+
* reader owns the install walk: doctor's `shimProblems`, and `status`'s `gateSummary`
|
|
792
|
+
* (`js/inspect/status.mjs`), which folds the same answer into its `dead` field — the one
|
|
793
|
+
* `statusLines` renders as a `DEAD` gates row. Two readers of one fact, not two copies.
|
|
794
|
+
*
|
|
795
|
+
* `targets` is a parameter, the same convention `hookShellProblems` uses, so a test can hand
|
|
796
|
+
* it a sandboxed `[host, scope, root]` triple instead of walking this machine's real config
|
|
797
|
+
* dirs and registry — the live default every production caller takes.
|
|
798
|
+
*/
|
|
799
|
+
export function installsReferencingShim(shimPath = hookShimPath(), targets = installTargets()) {
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const [host, scope, root] of targets) {
|
|
802
|
+
if (!CLAUDE_STYLE.includes(host)) continue;
|
|
803
|
+
const cfg = claudeCfg(root, scope, host);
|
|
804
|
+
if (liveRecordedHooks(cfg).some(([, h]) => h.command.includes(shimPath))) out.push(cfg);
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
773
809
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
774
810
|
|
|
775
811
|
/**
|
package/js/hosts/native.mjs
CHANGED
|
@@ -34,6 +34,11 @@ import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from
|
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
36
|
import { claimLeaf, ownedState } from './owned.mjs';
|
|
37
|
+
// `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
|
|
38
|
+
// (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
|
|
39
|
+
// mutating it away leaves the parity gate green — correctly, there is no reachable behaviour to
|
|
40
|
+
// detect. Kept because a spec author adding a leading blank line is one commit from needing it.
|
|
41
|
+
import { lstripNewlines } from './settings.mjs';
|
|
37
42
|
|
|
38
43
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
39
44
|
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
@@ -704,16 +709,3 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
704
709
|
}
|
|
705
710
|
return { nAgents, nSkills, written, userOnlySkills };
|
|
706
711
|
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* `str.lstrip("\n")` — only newlines, not all whitespace.
|
|
710
|
-
*
|
|
711
|
-
* Currently a no-op on every input the generator produces: measured, no file under `src/`
|
|
712
|
-
* and no rendered spec in any of the 14 themes begins with a newline. Mutating it away
|
|
713
|
-
* leaves the parity gate green, and that is correct rather than a hole — there is no
|
|
714
|
-
* reachable behaviour to detect. Kept because it is what the Python does, and a spec
|
|
715
|
-
* author adding a leading blank line is one commit away from making it matter.
|
|
716
|
-
*/
|
|
717
|
-
function lstripNewlines(text) {
|
|
718
|
-
return text.replace(/^\n+/, '');
|
|
719
|
-
}
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
|
|
|
28
28
|
import { comparePaths } from '../lib/paths.mjs';
|
|
29
29
|
import { sourceReleaseVersion } from '../build/version.mjs';
|
|
30
30
|
import { aliasesOf } from '../build/source.mjs';
|
|
31
|
+
import { lstripNewlines } from './settings.mjs';
|
|
31
32
|
import {
|
|
32
33
|
firstBlockquote, isUserInvokedOnly, skillDescription, stripSkillBodyLinks, pointSideFiles,
|
|
33
34
|
pushOverrideLines,
|
|
@@ -295,7 +296,7 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
|
|
|
295
296
|
|| !statSync(cfg.primaryAgentSrc).isFile()) {
|
|
296
297
|
return null;
|
|
297
298
|
}
|
|
298
|
-
const body = readText(cfg.primaryAgentSrc)
|
|
299
|
+
const body = lstripNewlines(readText(cfg.primaryAgentSrc));
|
|
299
300
|
const desc = 'Primary orchestrator — works by the harness Rules and delegates to the '
|
|
300
301
|
+ 'capability subagents.';
|
|
301
302
|
const fm = [`description: ${jsonDumps(desc)}`, 'mode: primary', 'color: primary'];
|
|
@@ -309,19 +310,43 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
|
|
|
309
310
|
return dest;
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
313
|
+
/**
|
|
314
|
+
* The top-level skills of `items` (`src/skills/<stem>.md`, no `_` partials) as ordered
|
|
315
|
+
* `[stem, text]` pairs — the one parse both command writers below share.
|
|
316
|
+
*/
|
|
317
|
+
function topLevelSkills(cfg, items) {
|
|
318
|
+
/** @type {[string, string][]} */
|
|
319
|
+
const found = [];
|
|
317
320
|
for (const { text, src } of items) {
|
|
318
321
|
if (text === null) continue;
|
|
319
322
|
const sp = path.relative(cfg.src, src).split(path.sep);
|
|
320
323
|
if (sp.length === 2 && sp[0] === 'skills' && sp[1].endsWith('.md')
|
|
321
324
|
&& !sp[1].startsWith('_')) {
|
|
322
|
-
|
|
325
|
+
found.push([sp[1].slice(0, -3), text]);
|
|
323
326
|
}
|
|
324
327
|
}
|
|
328
|
+
return found;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Both command writers' default `skillDirOf`: the skill's own directory beside `commandDir`. */
|
|
332
|
+
const siblingSkillDir = (commandDir) => (n) =>
|
|
333
|
+
path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/');
|
|
334
|
+
|
|
335
|
+
/** A command has no base directory, so a side-file pointer names the skill's own. */
|
|
336
|
+
function commandBody(text, stem, skillDir) {
|
|
337
|
+
return stripSkillBodyLinks(pointSideFiles(lstripNewlines(text), stem, skillDir));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function writeCommandFile(dest, desc, body) {
|
|
341
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
342
|
+
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** `_build_emit._write_command_layer` — the opt-in /slash commands. */
|
|
346
|
+
export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
|
|
347
|
+
skillDirOf = siblingSkillDir(commandDir)) {
|
|
348
|
+
if (!truthyEnv('GENESEED_COMMANDS')) return [];
|
|
349
|
+
const byName = new Map(topLevelSkills(cfg, items));
|
|
325
350
|
const written = [];
|
|
326
351
|
for (const [name, skill] of Object.entries(COMMAND_SET)) {
|
|
327
352
|
// A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
|
|
@@ -333,10 +358,7 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
|
|
|
333
358
|
const dest = path.join(commandDir, `${name}.md`);
|
|
334
359
|
if (!claim(dest)) continue;
|
|
335
360
|
const desc = firstBlockquote(text);
|
|
336
|
-
|
|
337
|
-
const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
|
|
338
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
339
|
-
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
361
|
+
writeCommandFile(dest, desc, commandBody(text, skill, skillDirOf(skill)));
|
|
340
362
|
written.push(dest);
|
|
341
363
|
}
|
|
342
364
|
return written;
|
|
@@ -358,22 +380,15 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
|
|
|
358
380
|
* (Task 4) — it keeps its SKILL.md and does not need a second, command-shaped copy.
|
|
359
381
|
*/
|
|
360
382
|
export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
|
|
361
|
-
skillDirOf = (
|
|
362
|
-
alsoUserOnly = false) {
|
|
383
|
+
skillDirOf = siblingSkillDir(commandDir), alsoUserOnly = false) {
|
|
363
384
|
const written = [];
|
|
364
|
-
for (const
|
|
365
|
-
|
|
366
|
-
const sp = path.relative(cfg.src, src).split(path.sep);
|
|
367
|
-
if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
|
|
368
|
-
const stem = sp[1].slice(0, -3);
|
|
369
|
-
const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
|
|
385
|
+
for (const [stem, text] of topLevelSkills(cfg, items)) {
|
|
386
|
+
const body = commandBody(text, stem, skillDirOf(stem));
|
|
370
387
|
const names = alsoUserOnly && isUserInvokedOnly(text) ? [...aliasesOf(text), stem] : aliasesOf(text);
|
|
371
388
|
for (const name of names) {
|
|
372
389
|
const dest = path.join(commandDir, `${name}.md`);
|
|
373
390
|
if (!claim(dest)) continue;
|
|
374
|
-
|
|
375
|
-
const desc = name === stem ? skillDescription(text) : `Alias of ${stem}.`;
|
|
376
|
-
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
391
|
+
writeCommandFile(dest, name === stem ? skillDescription(text) : `Alias of ${stem}.`, body);
|
|
377
392
|
written.push(dest);
|
|
378
393
|
}
|
|
379
394
|
}
|
package/js/hosts/owned.mjs
CHANGED
|
@@ -144,11 +144,25 @@ export function hashesOf(doc) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* `
|
|
150
|
-
*
|
|
151
|
-
*
|
|
147
|
+
* An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
|
|
148
|
+
* (`opencodeLayer` records them; a deny the user wrote, even on a Geneseed skill, is never in
|
|
149
|
+
* it) — or `[]` when the manifest, or the key, is absent or malformed, which sweeps nothing.
|
|
150
|
+
* Unlike the Law IV `permission.bash` gates (permanent, never taken back — see
|
|
151
|
+
* `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
|
|
152
|
+
* is no skill left to hide, and the name is the user's to reuse. One home because rebuild's
|
|
153
|
+
* deny sweep and uninstall must agree on what Geneseed owns.
|
|
154
|
+
*/
|
|
155
|
+
export function recordedDenies(doc) {
|
|
156
|
+
const d = doc && doc.skill_denies;
|
|
157
|
+
return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Where one owned file stands against its recorded hash — the table rebuild, prune and
|
|
162
|
+
* uninstall branch on so none of them clobbers a user's edit to a Geneseed-owned file.
|
|
163
|
+
* `'unknown'` is deliberately distinct from `'unmodified'`: a manifest written before hashes
|
|
164
|
+
* were recorded has no entry for this rel, and must not be read as "proven untouched" — a
|
|
165
|
+
* manifest without `hashes` keeps the pre-hash behaviour exactly.
|
|
152
166
|
*/
|
|
153
167
|
export function ownedState(root, rel, hashes) {
|
|
154
168
|
if (!Object.hasOwn(hashes, rel)) return 'unknown';
|