geneseed 4.5.0 → 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 +19 -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 +108 -61
- package/adapters/opencode/plugins/geneseed-guard.js +33 -27
- package/adapters/opencode/plugins/geneseed-learn.js +1 -1
- package/docs/guides/install-clone.md +1 -1
- package/docs/guides/install.md +2 -2
- package/docs/reference/troubleshoot-install.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +5 -4
- 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/hosts/hooks-context.mjs +1 -4
- package/js/hosts/hooks-done.mjs +23 -6
- package/js/hosts/hooks-learn.mjs +5 -5
- package/js/hosts/hooks.mjs +19 -9
- package/js/hosts/hosts.mjs +2 -0
- package/js/hosts/installs.mjs +45 -10
- 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 +4 -3
- package/js/hosts/usage.mjs +4 -3
- package/js/inspect/checks-authoring.mjs +9 -7
- package/js/inspect/checks-probe.mjs +14 -17
- package/js/inspect/checks-repo.mjs +13 -54
- package/js/inspect/cost.mjs +5 -6
- package/js/inspect/doctor.mjs +2 -2
- 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/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
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';
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -145,7 +145,8 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
|
|
|
145
145
|
* `-u origin`/`--set-upstream origin`/any remote through its leading `*`; the second key covers
|
|
146
146
|
* the `refs/heads/` spelling the same way.
|
|
147
147
|
*
|
|
148
|
-
* ORDER IS LOAD-BEARING. `
|
|
148
|
+
* ORDER IS LOAD-BEARING. `PUSH_CHAIN` (which `defaultPermission` and `OWNED_BASH` both read)
|
|
149
|
+
* puts these AFTER `git push*`'s ask (so a loop
|
|
149
150
|
* push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
|
|
150
151
|
* — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
|
|
151
152
|
* `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
|
|
@@ -222,18 +223,22 @@ const LOOP_PUSH_ASK_FLAG_EXTRA = [
|
|
|
222
223
|
];
|
|
223
224
|
const LOOP_PUSH_ASK_TRAILING = ['git push * HEAD:loop/* ?*', 'git push * HEAD:refs/heads/loop/* ?*'];
|
|
224
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The tiers after `git push*`, in the `findLast` order the docblocks above depend on — ONE
|
|
228
|
+
* ordered table, so `defaultPermission` and `OWNED_BASH` cannot disagree about it.
|
|
229
|
+
*/
|
|
230
|
+
const PUSH_CHAIN = [
|
|
231
|
+
[LOOP_PUSH_ALLOW, 'allow'], [LOOP_PUSH_ASK_EXTRA, 'ask'], [LOOP_PUSH_ALLOW_FLAG, 'allow'],
|
|
232
|
+
[LOOP_PUSH_ASK_FLAG_EXTRA, 'ask'], [LOOP_PUSH_ASK_TRAILING, 'ask'], [LAW_IV_BASH, 'ask'],
|
|
233
|
+
];
|
|
234
|
+
|
|
225
235
|
function defaultPermission(doctrines = null, excluded = []) {
|
|
226
236
|
const bash = { 'rm -rf *': 'ask' };
|
|
227
237
|
if (consentRuleOn(doctrines, excluded)) {
|
|
228
238
|
bash['git commit*'] = 'ask';
|
|
229
239
|
bash['git push*'] = 'ask';
|
|
230
240
|
}
|
|
231
|
-
for (const k of
|
|
232
|
-
for (const k of LOOP_PUSH_ASK_EXTRA) bash[k] = 'ask';
|
|
233
|
-
for (const k of LOOP_PUSH_ALLOW_FLAG) bash[k] = 'allow';
|
|
234
|
-
for (const k of LOOP_PUSH_ASK_FLAG_EXTRA) bash[k] = 'ask';
|
|
235
|
-
for (const k of LOOP_PUSH_ASK_TRAILING) bash[k] = 'ask';
|
|
236
|
-
for (const k of LAW_IV_BASH) bash[k] = 'ask';
|
|
241
|
+
for (const [keys, verdict] of PUSH_CHAIN) for (const k of keys) bash[k] = verdict;
|
|
237
242
|
return { bash };
|
|
238
243
|
}
|
|
239
244
|
|
|
@@ -245,8 +250,7 @@ function defaultPermission(doctrines = null, excluded = []) {
|
|
|
245
250
|
* the pack returns, and to name it when this build no longer wants it but will not remove it.
|
|
246
251
|
*/
|
|
247
252
|
const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*',
|
|
248
|
-
...
|
|
249
|
-
...LOOP_PUSH_ASK_TRAILING, ...LAW_IV_BASH];
|
|
253
|
+
...PUSH_CHAIN.flatMap(([keys]) => keys)];
|
|
250
254
|
|
|
251
255
|
/**
|
|
252
256
|
* Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
|
|
@@ -626,7 +630,8 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
|
|
|
626
630
|
// skill reconcile above never ran either — the unwired skill denies are folded into THIS
|
|
627
631
|
// one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
|
|
628
632
|
// dict to even look at `perm.skill`, which this case by definition is not).
|
|
629
|
-
const
|
|
633
|
+
const wanted = defaultPermission(doctrines, excluded).bash;
|
|
634
|
+
const names = [...OWNED_BASH.filter((k) => has(wanted, k)),
|
|
630
635
|
...(!permIsObject ? skillNames : [])];
|
|
631
636
|
process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
|
|
632
637
|
+ `${opaque}, so Geneseed cannot wire its own gates into it — including `
|
|
@@ -1236,7 +1241,8 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
|
|
|
1236
1241
|
return stripNewlines(inner.slice(0, inner.indexOf(end)));
|
|
1237
1242
|
}
|
|
1238
1243
|
|
|
1239
|
-
|
|
1244
|
+
/** `str.lstrip("\n")`; the one copy — `native.mjs` and `opencode.mjs` import it from here. */
|
|
1245
|
+
export function lstripNewlines(s) {
|
|
1240
1246
|
return s.replace(/^\n+/, '');
|
|
1241
1247
|
}
|
|
1242
1248
|
|
package/js/hosts/shim.mjs
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* `js/build/driver.mjs`, and through it the whole generator, for the two-line
|
|
10
10
|
* `hookRunnerEntry`, which now lives here beside the `hookPrefix` that consumes its answer.
|
|
11
11
|
*
|
|
12
|
-
* Keep this module's imports to `node:` builtins, `js/lib/fs.mjs
|
|
13
|
-
*
|
|
12
|
+
* Keep this module's imports to `node:` builtins, `js/lib/fs.mjs`, `./hosts.mjs` and
|
|
13
|
+
* `./hooks-prims.mjs` (whose only import, `js/lib/paths.mjs`, `./hosts.mjs` already loads):
|
|
14
|
+
* being cheap to load is its whole reason to exist.
|
|
14
15
|
*/
|
|
15
16
|
import {
|
|
16
17
|
chmodSync, existsSync, mkdirSync, realpathSync, renameSync, statSync, writeFileSync,
|
|
@@ -19,6 +20,7 @@ import path from 'node:path';
|
|
|
19
20
|
import os from 'node:os';
|
|
20
21
|
import { readText, isOsError } from '../lib/fs.mjs';
|
|
21
22
|
import { expanduser } from './hosts.mjs';
|
|
23
|
+
import { isFile } from './hooks-prims.mjs';
|
|
22
24
|
|
|
23
25
|
/** The checkout this module ships in — `js/build/source.mjs`'s `ROOT`, computed locally so the
|
|
24
26
|
* shim does not import the source loader. */
|
|
@@ -295,7 +297,6 @@ export function claudeHookShell(env = process.env, platform = process.platform)
|
|
|
295
297
|
*/
|
|
296
298
|
export function claudeGitBashPath(env = process.env) {
|
|
297
299
|
const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
|
|
298
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
299
300
|
const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
|
|
300
301
|
if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
|
|
301
302
|
return forced;
|
package/js/hosts/usage.mjs
CHANGED
|
@@ -26,8 +26,9 @@ const NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/;
|
|
|
26
26
|
/**
|
|
27
27
|
* `{kind, name}` for a skill or subagent call, else null. Reads the Claude shape (`tool_name` /
|
|
28
28
|
* `tool_input`: `Skill` → `skill`, `Agent` or the older `Task` → `subagent_type`), OpenCode's
|
|
29
|
-
* (`skill` → `name`, `task` → `subagent_type
|
|
30
|
-
* tool whose name contains "skill"): Bob's skill
|
|
29
|
+
* (`skill` → `name`, `task` → `subagent_type`; V2 hooks see the pre-alias `subagent` → `agent`)
|
|
30
|
+
* and, best-effort, Bob's (`tool` / `input`, any tool whose name contains "skill"): Bob's skill
|
|
31
|
+
* tool name is unverified, no Bob runs here.
|
|
31
32
|
*/
|
|
32
33
|
export function usageOf(payload) {
|
|
33
34
|
if (!payload || typeof payload !== 'object') return null;
|
|
@@ -38,7 +39,7 @@ export function usageOf(payload) {
|
|
|
38
39
|
if (!tool.startsWith('mcp') && tool.includes('skill')) {
|
|
39
40
|
kind = 'skill';
|
|
40
41
|
name = ti.skill ?? ti.name ?? ti.skill_name;
|
|
41
|
-
} else if (tool === 'agent' || tool === 'task') {
|
|
42
|
+
} else if (tool === 'agent' || tool === 'task' || tool === 'subagent') {
|
|
42
43
|
kind = 'agent';
|
|
43
44
|
name = ti.subagent_type ?? ti.agent;
|
|
44
45
|
}
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { PLUGIN_SRC, SRC } from '../build/source.mjs';
|
|
17
|
+
import { HOSTS } from '../hosts/hosts.mjs';
|
|
17
18
|
import { descBlockProblem, firstBlockquote } from '../hosts/native.mjs';
|
|
18
19
|
import { readText } from '../lib/fs.mjs';
|
|
20
|
+
import { blankCodeSpans } from '../lib/text.mjs';
|
|
19
21
|
import { which } from '../lib/paths.mjs';
|
|
20
22
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
21
23
|
import {
|
|
@@ -25,15 +27,15 @@ import {
|
|
|
25
27
|
import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
|
|
26
28
|
import { countTableProblems } from './checks-counts.mjs';
|
|
27
29
|
import { catalogProblems } from '../loop/catalog.mjs';
|
|
28
|
-
import {
|
|
30
|
+
import { globSorted, isDir, isFile, rglob } from './scan.mjs';
|
|
29
31
|
import { spawnSync } from 'node:child_process';
|
|
30
32
|
|
|
31
|
-
// The
|
|
32
|
-
//
|
|
33
|
-
// reference, status and update modules,
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
const HARNESS_IDS = '
|
|
33
|
+
// The harness ids `js/web/docs.mjs`'s `HARNESS_OPEN_RE` accepts — its `HARNESSES`, keyed by the
|
|
34
|
+
// same host ids as `HOSTS`. Not imported from there: `js/web/docs.mjs` pulls in the web daemon's
|
|
35
|
+
// CLI reference, status and update modules, a weight every `doctor --all` would pay for one id
|
|
36
|
+
// list. `HOSTS` is the ids' own home and already in doctor's import closure. Alternation order
|
|
37
|
+
// does not matter: the pattern is anchored at both ends.
|
|
38
|
+
const HARNESS_IDS = HOSTS.map((h) => h.host).join('|');
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* The comment bodies (trimmed) that are known to be FUNCTIONAL — consumed by the render, a
|
|
@@ -25,8 +25,7 @@
|
|
|
25
25
|
* `GENESEED_GATE_PROBE=1` keeps the probe's asks out of the gate ledger (`ledger()` in
|
|
26
26
|
* `js/hosts/hooks.mjs`), so `status` never counts a doctor run as a refusal.
|
|
27
27
|
*/
|
|
28
|
-
import { existsSync
|
|
29
|
-
import os from 'node:os';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
30
29
|
import path from 'node:path';
|
|
31
30
|
import { pathToFileURL } from 'node:url';
|
|
32
31
|
import { liveRecordedHooks } from '../hosts/hosts.mjs';
|
|
@@ -34,7 +33,7 @@ import { claudeCfg, installState, installTargets } from '../hosts/installs.mjs';
|
|
|
34
33
|
import { SHIM_MARK, claudeGitBashPath } from '../hosts/shim.mjs';
|
|
35
34
|
import { which } from '../lib/paths.mjs';
|
|
36
35
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
37
|
-
import { NOTE, withTempDir } from './scan.mjs';
|
|
36
|
+
import { NOTE, withTempDir, withTempDirAsync } from './scan.mjs';
|
|
38
37
|
import { spawnSync } from 'node:child_process';
|
|
39
38
|
|
|
40
39
|
/**
|
|
@@ -43,7 +42,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
43
42
|
* runs everything through `tool-gate --host bob`. OpenCode's guard is an in-process plugin with
|
|
44
43
|
* no command: `guardProbe` below imports it instead.
|
|
45
44
|
*/
|
|
46
|
-
|
|
45
|
+
const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
|
|
47
46
|
|
|
48
47
|
export const BAD_COMMAND = 'git reset --hard HEAD';
|
|
49
48
|
export const GOOD_COMMAND = 'git status';
|
|
@@ -51,7 +50,7 @@ export const GOOD_COMMAND = 'git status';
|
|
|
51
50
|
const PROBE_TIMEOUT_MS = 20_000;
|
|
52
51
|
|
|
53
52
|
/** A PreToolUse payload in Claude's shape — the shape every probed host's gate reads. */
|
|
54
|
-
|
|
53
|
+
function probePayload(command, cwd) {
|
|
55
54
|
return JSON.stringify({
|
|
56
55
|
session_id: 'geneseed-probe', hook_event_name: 'PreToolUse', tool_name: 'Bash',
|
|
57
56
|
tool_input: { command }, cwd,
|
|
@@ -64,7 +63,7 @@ export function probePayload(command, cwd) {
|
|
|
64
63
|
* `shell: "powershell"` wins; Bob runs `cmd /c` on Windows; Claude/OpenClaude run Git Bash
|
|
65
64
|
* there (`claudeGitBashPath`, the rule `claudeHookShell` uses); `sh -c` everywhere else.
|
|
66
65
|
*/
|
|
67
|
-
|
|
66
|
+
function probeShell(host, handler, platform = process.platform, env = process.env) {
|
|
68
67
|
const cmd = handler.command;
|
|
69
68
|
if (handler.shell === 'powershell') {
|
|
70
69
|
const ps = which('powershell') || which('pwsh');
|
|
@@ -173,10 +172,10 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
|
|
|
173
172
|
|
|
174
173
|
// ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
|
|
175
174
|
|
|
176
|
-
|
|
175
|
+
const GUARD_PLUGIN = 'geneseed-guard.js';
|
|
177
176
|
/** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
|
|
178
177
|
* prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
|
|
179
|
-
|
|
178
|
+
const GUARD_BAD_COMMAND = 'rm -rf /';
|
|
180
179
|
|
|
181
180
|
/** The guard plugin of an OpenCode install: `<global>/plugins/` or `<repo>/.opencode/plugins/`
|
|
182
181
|
* (`emitOpencodeInto` copies the plugins to `<dir>/plugins`). */
|
|
@@ -203,7 +202,7 @@ async function quietProbe(fn) {
|
|
|
203
202
|
* (`setupV2`'s docblock). An unexpected throw is not a refusal — it is rethrown, and the caller
|
|
204
203
|
* reports the guard as broken.
|
|
205
204
|
*/
|
|
206
|
-
|
|
205
|
+
async function runGuard(mod, cwd) {
|
|
207
206
|
const v1 = async (command) => {
|
|
208
207
|
const hooks = await mod.server({ worktree: cwd, directory: cwd });
|
|
209
208
|
try {
|
|
@@ -233,7 +232,7 @@ export async function runGuard(mod, cwd) {
|
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
/** The verdict for one API's pair — null when sound. */
|
|
236
|
-
|
|
235
|
+
function guardVerdict([badRefused, goodRefused]) {
|
|
237
236
|
if (!badRefused) return `registered but not refusing — \`${GUARD_BAD_COMMAND}\` went through`;
|
|
238
237
|
if (goodRefused) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
|
|
239
238
|
return null;
|
|
@@ -269,13 +268,17 @@ export async function guardProbe(targets = installTargets(), env = process.env)
|
|
|
269
268
|
}
|
|
270
269
|
let verdicts;
|
|
271
270
|
try {
|
|
271
|
+
// ESM under OpenCode's typeless package.json: parses only with syntax detection (Node >=
|
|
272
|
+
// 22.7, the floor). Its MODULE_TYPELESS_PACKAGE_JSON warning fires a tick later, inside
|
|
273
|
+
// quietProbe's stderr swap, so the doctor never prints it.
|
|
272
274
|
const mod = (await import(pathToFileURL(file).href)).default;
|
|
273
275
|
if (mod?.id !== 'geneseed-guard' || typeof mod.server !== 'function' || typeof mod.setup !== 'function') {
|
|
274
276
|
problems.push(`${label}: not probed — it does not export the Geneseed guard `
|
|
275
277
|
+ '({ id, server, setup }) — run: geneseed rebuild-all');
|
|
276
278
|
continue;
|
|
277
279
|
}
|
|
278
|
-
verdicts = await withTempDirAsync(
|
|
280
|
+
verdicts = await withTempDirAsync(
|
|
281
|
+
(tmp) => quietProbe(() => runGuard(mod, tmp)), 'geneseed-probe-');
|
|
279
282
|
} catch (e) {
|
|
280
283
|
problems.push(`${label}: does not run (${e?.message || e})`);
|
|
281
284
|
continue;
|
|
@@ -287,9 +290,3 @@ export async function guardProbe(targets = installTargets(), env = process.env)
|
|
|
287
290
|
}
|
|
288
291
|
return { problems, probed };
|
|
289
292
|
}
|
|
290
|
-
|
|
291
|
-
/** `withTempDir` for an async body: the dir outlives the promise, then goes. */
|
|
292
|
-
async function withTempDirAsync(fn) {
|
|
293
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-probe-'));
|
|
294
|
-
try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
295
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The checks over the REPO's own records — the registry, committed secrets, the hook shim
|
|
3
|
-
*
|
|
2
|
+
* The checks over the REPO's own records — the registry, committed secrets, the hook shim,
|
|
3
|
+
* the vendored skill folders (their pins and their SKILL.md frontmatter), skill aliases and
|
|
4
|
+
* side files, the `js/README.md` module map, the host tables, and the scorecard ratchet.
|
|
4
5
|
*
|
|
5
6
|
* Same contract as `checks-build.mjs`: an array of problem strings, empty when clean, one
|
|
6
7
|
* planted fault per check in the gate. This is the group that reads what the repo SAYS about
|
|
@@ -10,8 +11,8 @@ import path from 'node:path';
|
|
|
10
11
|
import {
|
|
11
12
|
ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
|
|
12
13
|
} from '../build/source.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { HOSTS } from '../hosts/hosts.mjs';
|
|
15
|
+
import { installTargets, installsReferencingShim } from '../hosts/installs.mjs';
|
|
15
16
|
import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
|
|
16
17
|
import { COMMAND_SET } from '../hosts/opencode.mjs';
|
|
17
18
|
import { hookShimPath, shimDeadPaths } from '../hosts/shim.mjs';
|
|
@@ -41,12 +42,8 @@ function registryKeys() {
|
|
|
41
42
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
42
43
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
44
|
|
|
44
|
-
// `ENTITY_STATUSES
|
|
45
|
-
//
|
|
46
|
-
// doctor's authoring gates are what validate them, and they belong where Python keeps
|
|
47
|
-
// them (`_harness_tui`) now that the catalog reads them too. The gates below are
|
|
48
|
-
// unchanged, which is what licensed the move: a copy would have stopped being the
|
|
49
|
-
// value under test the first time one of them was edited.
|
|
45
|
+
// `ENTITY_STATUSES` is imported from `js/inspect/inventory.mjs`, not copied: the catalog reads
|
|
46
|
+
// it too, and a copy would stop being the value under test the first time one side changed.
|
|
50
47
|
|
|
51
48
|
/**
|
|
52
49
|
* `_harness_build._registry_problems` — `registry.json` describes exactly what `src/`
|
|
@@ -167,44 +164,6 @@ export function secretProblems() {
|
|
|
167
164
|
return problems;
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
/**
|
|
171
|
-
* Every active Claude-STYLE install (`CLAUDE_STYLE`: claude, bob, openclaude — the hosts that
|
|
172
|
-
* share this one machine-wide shim) whose LIVE, recorded hooks still name `shimPath` by exact
|
|
173
|
-
* string, keyed by its own config dir.
|
|
174
|
-
*
|
|
175
|
-
* Split out of `shimProblems` for the case `shimDeadPaths` cannot see: that function reads
|
|
176
|
-
* paths back OUT of the shim body, so it has nothing to read once the shim file is gone
|
|
177
|
-
* outright (deleted, not moved-and-regenerated) — a plain `isFile` check upstream then calls
|
|
178
|
-
* that clean, the same as a source checkout that never emitted. `liveRecordedHooks` is already
|
|
179
|
-
* "the manifest claims it AND the settings file still runs it" (`js/hosts/hosts.mjs`), so a
|
|
180
|
-
* disabled or never-wired install cannot appear here — only installs for which the missing
|
|
181
|
-
* shim is a live, standing failure.
|
|
182
|
-
*
|
|
183
|
-
* MATCHES THE EXACT PATH, not merely a command that mentions the shim's filename
|
|
184
|
-
* (`geneseed-hook`): the shim path is baked per `GENESEED_HOME`, so a machine with more than
|
|
185
|
-
* one Geneseed home (a relocated one, or a sandboxed test run beside a real install) must not
|
|
186
|
-
* let an install naming a DIFFERENT, perfectly healthy shim answer for this one.
|
|
187
|
-
*
|
|
188
|
-
* EXPORTED: `status`'s `gateSummary` (`js/inspect/status.mjs`) needs the same answer to fold
|
|
189
|
-
* into its `dead` field, the one `statusLines` already renders as a `DEAD` gates row — two
|
|
190
|
-
* readers of one fact, not two copies of the install walk.
|
|
191
|
-
*
|
|
192
|
-
* `targets` is a parameter, the same convention `hookShellProblems` uses, so a test can hand
|
|
193
|
-
* it a sandboxed `[host, scope, root]` triple instead of walking this machine's real config
|
|
194
|
-
* dirs and registry — the live default every production caller takes.
|
|
195
|
-
*/
|
|
196
|
-
export function installsReferencingShim(shimPath = hookShimPath(), targets = installTargets()) {
|
|
197
|
-
const out = [];
|
|
198
|
-
for (const [host, scope, root] of targets) {
|
|
199
|
-
if (!CLAUDE_STYLE.includes(host)) continue;
|
|
200
|
-
const cfg = claudeCfg(root, scope, host);
|
|
201
|
-
const live = liveRecordedHooks(cfg)
|
|
202
|
-
.some(([, h]) => typeof h.command === 'string' && h.command.includes(shimPath));
|
|
203
|
-
if (live) out.push(cfg);
|
|
204
|
-
}
|
|
205
|
-
return out;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
167
|
/**
|
|
209
168
|
* `_harness_build._shim_problems` — the hook shim exists and points at something real.
|
|
210
169
|
*
|
|
@@ -220,7 +179,7 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
|
|
|
220
179
|
* reason to own one. But one deleted by hand (or by a cleanup script) out from under an
|
|
221
180
|
* install that still names it is the same dead-hooks failure as a moved checkout, newly worth
|
|
222
181
|
* catching now that the two Claude gates fail CLOSED on a dead hook instead of open
|
|
223
|
-
* (`onFailure: "block"
|
|
182
|
+
* (`onFailure: "block"`) — a Bash/Write/Edit call blocked with no shim to even try
|
|
224
183
|
* running is otherwise invisible until the user notices nothing works.
|
|
225
184
|
* `installsReferencingShim` answers that half; this still returns `[]` when nothing live
|
|
226
185
|
* names the missing shim.
|
|
@@ -243,7 +202,7 @@ export function shimProblems(targets = installTargets()) {
|
|
|
243
202
|
if (!isFile(p)) {
|
|
244
203
|
return installsReferencingShim(p, targets).map((cfg) => `[shim] ${p} does not exist, but ${cfg} `
|
|
245
204
|
+ 'still runs its hooks through it — every hook in that install is dead, and (since '
|
|
246
|
-
+ "
|
|
205
|
+
+ "Geneseed 4.0.0) Claude's git-gate/rule-gate now BLOCK instead of silently passing. Run: "
|
|
247
206
|
+ 'geneseed rebuild-all');
|
|
248
207
|
}
|
|
249
208
|
let body;
|
|
@@ -597,7 +556,7 @@ export function hostMatrixProblems(root = ROOT) {
|
|
|
597
556
|
* invisible here. A check this repo starts to pass is added by hand; one it stops passing is a
|
|
598
557
|
* doctor problem until fixed or deliberately removed from this list, in the same commit.
|
|
599
558
|
*/
|
|
600
|
-
|
|
559
|
+
const SCORECARD_FLOOR = [
|
|
601
560
|
'architecture_doc',
|
|
602
561
|
'agent_instructions',
|
|
603
562
|
'adr_presence',
|
|
@@ -630,6 +589,9 @@ export const SCORECARD_FLOOR = [
|
|
|
630
589
|
'security_critical_marking',
|
|
631
590
|
];
|
|
632
591
|
|
|
592
|
+
/** Where the README's scorecard card lives, and what doctor compares against a fresh render. */
|
|
593
|
+
const SCORECARD_CARD = 'docs/assets/scorecard.svg';
|
|
594
|
+
|
|
633
595
|
/**
|
|
634
596
|
* The scorecard ratchet — one problem per `SCORECARD_FLOOR` check that no longer passes.
|
|
635
597
|
*
|
|
@@ -659,6 +621,3 @@ export function scorecardProblems(root = ROOT) {
|
|
|
659
621
|
+ (k ? `: ${k.evidence}. Fix: ${k.remediation}` : ', and the scorecard has no such check');
|
|
660
622
|
}));
|
|
661
623
|
}
|
|
662
|
-
|
|
663
|
-
/** Where the README's scorecard card lives, and what doctor compares against a fresh render. */
|
|
664
|
-
export const SCORECARD_CARD = 'docs/assets/scorecard.svg';
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Four parts, each the thing the host loads before the user types:
|
|
8
8
|
* - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
|
|
9
|
-
* `AGENTS.md`), CRLF
|
|
10
|
-
*
|
|
9
|
+
* `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
|
|
10
|
+
* `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
|
|
11
|
+
* which the smoke test does not, so the two agree on any carrier without one.
|
|
11
12
|
* - `skills` the name + description every native skill lists (aliases included: they are
|
|
12
13
|
* SKILL.md files the host lists like any other).
|
|
13
14
|
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
@@ -30,8 +31,6 @@ import { isFile, readText } from '../lib/fs.mjs';
|
|
|
30
31
|
*/
|
|
31
32
|
export const CARRIER_CEILING = { full: 62_219, lean: 39_088 };
|
|
32
33
|
|
|
33
|
-
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
|
-
|
|
35
34
|
/**
|
|
36
35
|
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
|
37
36
|
* (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
|
|
@@ -48,7 +47,7 @@ function fmValue(raw) {
|
|
|
48
47
|
/** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
|
|
49
48
|
function listedChars(file, fallbackName) {
|
|
50
49
|
let fm;
|
|
51
|
-
try { [fm] = frontmatter(
|
|
50
|
+
try { [fm] = frontmatter(readText(file)); } catch { return 0; }
|
|
52
51
|
const name = fmValue(fm.get('name')) || fallbackName;
|
|
53
52
|
return name.length + fmValue(fm.get('description')).length;
|
|
54
53
|
}
|
|
@@ -61,7 +60,7 @@ function listedChars(file, fallbackName) {
|
|
|
61
60
|
export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
62
61
|
const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
|
|
63
62
|
if (!carrierPath) return null;
|
|
64
|
-
const carrier =
|
|
63
|
+
const carrier = readText(carrierPath).length;
|
|
65
64
|
|
|
66
65
|
let skills = 0;
|
|
67
66
|
const skillsDir = path.join(dir, 'skills');
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -241,8 +241,8 @@ export function doctorCollect({
|
|
|
241
241
|
problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
|
|
242
242
|
problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
|
|
243
243
|
// Windows: a Claude install's hooks emitted for Git Bash on a machine that no longer has it
|
|
244
|
-
// misbehave under PowerShell
|
|
245
|
-
//
|
|
244
|
+
// misbehave under PowerShell — context/learn fail open, and the two gates fail CLOSED
|
|
245
|
+
// instead (`onFailure: "block"`). The reverse direction is a note.
|
|
246
246
|
problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
|
|
247
247
|
if (probeRun) {
|
|
248
248
|
problems = problems.concat(ran('probe', 'Gate probe', [...probeRun.problems, ...extraProbeProblems]));
|
package/js/inspect/scan.mjs
CHANGED
|
@@ -14,15 +14,13 @@ import { SRC } from '../build/source.mjs';
|
|
|
14
14
|
import { comparePaths, within } from '../lib/paths.mjs';
|
|
15
15
|
import { isDir, isFile } from '../lib/fs.mjs';
|
|
16
16
|
import { has } from '../lib/json.mjs';
|
|
17
|
-
import { FENCE_RE, INLINE_CODE_RE
|
|
17
|
+
import { FENCE_RE, INLINE_CODE_RE } from '../lib/text.mjs';
|
|
18
18
|
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
19
19
|
|
|
20
20
|
// `isDir`/`isFile`/`has` are owned by `js/lib` now (single owner across `hosts/`, `build/`,
|
|
21
21
|
// `inspect/` and `web/`) — re-exported here so this module's own existing callers keep
|
|
22
|
-
// importing them from `./scan.mjs` unchanged.
|
|
23
|
-
|
|
24
|
-
// answer and cannot import this module (it would cycle back through `../build/source.mjs` above).
|
|
25
|
-
export { isDir, isFile, has, blankCodeSpans };
|
|
22
|
+
// importing them from `./scan.mjs` unchanged.
|
|
23
|
+
export { isDir, isFile, has };
|
|
26
24
|
|
|
27
25
|
// --------------------------------------------------------------------------------------
|
|
28
26
|
// _harness_core's scanning primitives
|
|
@@ -106,3 +104,9 @@ export function withTempDir(fn, prefix = 'geneseed-doctor-') {
|
|
|
106
104
|
try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
107
105
|
}
|
|
108
106
|
|
|
107
|
+
/** `withTempDir` for an async body: the dir outlives the promise, then goes. */
|
|
108
|
+
export async function withTempDirAsync(fn, prefix = 'geneseed-doctor-') {
|
|
109
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
110
|
+
try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
111
|
+
}
|
|
112
|
+
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* on every machine, which is what lets doctor compare them. Colours follow the viewer's theme
|
|
13
13
|
* through a `prefers-color-scheme` block, which GitHub honours inside an `<img>` SVG.
|
|
14
14
|
*/
|
|
15
|
-
import { round1 } from './scorecard.mjs';
|
|
15
|
+
import { computeGrade, round1 } from './scorecard.mjs';
|
|
16
16
|
|
|
17
|
-
/** The grade band a percentage falls in — the
|
|
18
|
-
const band = (pct) => (pct
|
|
17
|
+
/** The grade band a percentage falls in — the overall grade's own thresholds, never a copy. */
|
|
18
|
+
const band = (pct) => computeGrade(pct).toLowerCase();
|
|
19
19
|
|
|
20
20
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
21
21
|
|