geneseed 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +2 -2
  4. package/adapters/opencode/plugins/geneseed-context.js +96 -0
  5. package/adapters/opencode/plugins/geneseed-guard.js +9 -2
  6. package/docs/concepts/enforcement-table.md +9 -3
  7. package/docs/concepts/hooks.md +3 -0
  8. package/docs/concepts/skills.md +1 -0
  9. package/docs/extending.md +1 -1
  10. package/docs/reference/plugin-context.md +2 -0
  11. package/docs/reference/troubleshoot-doctor.md +31 -0
  12. package/docs/understand/enforced-vs-asked.md +1 -0
  13. package/harness.config.json +1 -1
  14. package/js/README.md +3 -1
  15. package/js/cli-table.json +15 -0
  16. package/js/hosts/hooks-done.mjs +169 -0
  17. package/js/hosts/hooks-learn.mjs +5 -0
  18. package/js/hosts/hooks.mjs +11 -2
  19. package/js/hosts/installs.mjs +2 -1
  20. package/js/hosts/shim.mjs +20 -7
  21. package/js/inspect/checks-constitution.mjs +2 -2
  22. package/js/inspect/checks-probe.mjs +295 -0
  23. package/js/inspect/cost.mjs +1 -1
  24. package/js/inspect/doctor.mjs +27 -1
  25. package/js/inspect/inventory.mjs +1 -1
  26. package/package.json +1 -1
  27. package/registry.json +7 -0
  28. package/src/AGENT.md.tmpl +1 -0
  29. package/src/skills/deck.md +56 -0
  30. package/src/skills/frontend-design.md +1 -0
  31. package/src/skills/rule.md +11 -2
  32. package/themes/_TEMPLATE.json +1 -0
  33. package/themes/biker.json +1 -0
  34. package/themes/commentator.json +1 -0
  35. package/themes/cyberpunk.json +1 -0
  36. package/themes/gamer.json +1 -0
  37. package/themes/imperial.json +1 -0
  38. package/themes/joker.json +1 -0
  39. package/themes/marvin.json +1 -0
  40. package/themes/mean.json +1 -0
  41. package/themes/military.json +1 -0
  42. package/themes/neutral.json +1 -0
  43. package/themes/pirate.json +1 -0
  44. package/themes/sports.json +1 -0
  45. package/themes/verstappen.json +1 -0
  46. package/themes/wizard.json +1 -0
@@ -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
- if (!root) return;
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({
@@ -533,11 +537,15 @@ const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
533
537
  * not seen here; CI running the checks is the boundary that holds against that.
534
538
  */
535
539
  export const SENSOR_LIST = '.geneseed/protected-checks.txt';
540
+ // The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
541
+ // protected-checks list: emptying it would disarm the gate it feeds.
542
+ export const DONE_LIST = '.geneseed/done-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.
@@ -579,7 +587,8 @@ function ruleDecide(args, payload) {
579
587
  const sensor = protectedCheck(p);
580
588
  if (sensor) {
581
589
  return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
582
- + `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
590
+ + `check (${sensor === DONE_LIST ? 'the done gate list itself'
591
+ : `${sensor}, listed in ${SENSOR_LIST}`}). A check the agent can edit is not a `
583
592
  + 'check: fix the code, not the check, unless the user asks for this edit.');
584
593
  }
585
594
  const target = ruleGateTarget(p, args.root);
@@ -763,7 +763,8 @@ export function hookShellProblems(targets = installTargets(), platform = process
763
763
  out.push(was === 'bash'
764
764
  ? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
765
765
  + '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'
766
+ + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all, '
767
+ + 'then confirm with: geneseed doctor --probe'
767
768
  : `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
768
769
  + 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
769
770
  }
package/js/hosts/shim.mjs CHANGED
@@ -118,14 +118,18 @@ export function shimDead() {
118
118
  */
119
119
  export function hookShimBody(runner, entry, platform = process.platform) {
120
120
  if (platform === 'win32') {
121
- // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
122
- // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
121
+ // `exit /b %ERRORLEVEL%`, on its OWN line. A bare `exit /b` was here, on the claim that it
122
+ // propagates the live errorlevel, and that is false under `cmd /d /s /c` — Bob's hook
123
+ // shell, and Node's `shell: true`: the batch returned 0 after the gate exited 2, so every
124
+ // Bob block on Windows went through (#290; measured, see hook_form.test.mjs). `%ERRORLEVEL%`
125
+ // expands when ITS line is parsed, which is after the runner line has finished, so the value
126
+ // is the runner's, not a stale one. Never plain `exit` — that kills the parent cmd.exe, so
123
127
  // the emitted `|| exit 0` would never get to evaluate.
124
128
  return '@echo off\r\n'
125
129
  + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
126
130
  + 'setlocal\r\n'
127
131
  + `"${runner}" "${entry}" %*\r\n`
128
- + 'exit /b\r\n';
132
+ + 'exit /b %ERRORLEVEL%\r\n';
129
133
  }
130
134
  return '#!/bin/sh\n'
131
135
  + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
@@ -281,18 +285,27 @@ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh']);
281
285
  */
282
286
  export function claudeHookShell(env = process.env, platform = process.platform) {
283
287
  if (platform !== 'win32') return 'bash';
288
+ return claudeGitBashPath(env) ? 'bash' : 'powershell';
289
+ }
290
+
291
+ /**
292
+ * The Git Bash `claudeHookShell` finds on Windows, as a path — or null. One rule, two readers:
293
+ * the emit only needs to know WHETHER, `doctor --probe` (`js/inspect/checks-probe.mjs`) needs
294
+ * the file itself, to run a gate through the same shell Claude would.
295
+ */
296
+ export function claudeGitBashPath(env = process.env) {
284
297
  const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
285
298
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
286
299
  const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
287
300
  if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
288
- return 'bash';
301
+ return forced;
289
302
  }
290
303
  for (const raw of (read('PATH') || '').split(';')) {
291
304
  const dir = raw.replace(/"/g, '');
292
- if (dir && isFile(path.join(dir, 'git.exe'))
293
- && isFile(path.join(dir, '..', 'bin', 'bash.exe'))) return 'bash';
305
+ const bash = dir ? path.join(dir, '..', 'bin', 'bash.exe') : '';
306
+ if (dir && isFile(path.join(dir, 'git.exe')) && isFile(bash)) return path.resolve(bash);
294
307
  }
295
- return 'powershell';
308
+ return null;
296
309
  }
297
310
 
298
311
  /**
@@ -170,12 +170,12 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
170
170
  * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
171
171
  * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
172
172
  * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5`, `ops-2` in `js/hosts/hooks.mjs` and the OpenCode guard
173
- * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
173
+ * plugin; `law-3` in the done gate, `js/hosts/hooks-done.mjs`), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
174
174
  * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
175
175
  * doctor failure instead of a gate silently guarding its neighbour.
176
176
  */
177
177
  export const HOOK_PINNED = {
178
- 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
178
+ 'sealed-secrets': 'I', 'verify-before-asserting': 'III', 'deletion-is-deliberate': 'IV',
179
179
  'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
180
  'commands-must-return': 'ops 2',
181
181
  };
@@ -0,0 +1,295 @@
1
+ /**
2
+ * `geneseed doctor --probe` — prove each installed gate still REFUSES, not just that it exists.
3
+ *
4
+ * A gate that is registered, launches and then allows everything looks exactly like a working
5
+ * one: `onFailure: "block"` (ADR 0007) covers a gate that never starts, `shimProblems` covers the
6
+ * shim's paths and `hookShellProblems` infers shell drift — none of them observes a verdict. So
7
+ * this runs each live install's own gate command, AS ITS SETTINGS SPELL IT, through the shell
8
+ * the host would use, on two canned payloads: one Law IV call that every build must refuse
9
+ * (`git reset --hard HEAD`, also in Bob's `BLOCK_RULES`) and one that must pass (`git status`).
10
+ * Both directions are judged, because "allows everything" and "refuses everything" (the
11
+ * PowerShell-fallback case `hookShellProblems` can only infer) are both broken gates.
12
+ *
13
+ * WHY THIS SPAWNS, and why it is opt-in. ADR 0002 allows a CLI spawn only as a declared row of
14
+ * `ALLOWED_SPAWNS` (`tests/unit/hook_cli.test.mjs`); this is the row that runs INSTALLED code
15
+ * rather than the checkout's, which is why it waits for `--probe` instead of riding every
16
+ * `doctor`. It runs only a command that `liveRecordedHooks` returns — recorded in our own
17
+ * manifest AND live in the settings — and that still carries `geneseed-hook`, a gate verb and
18
+ * `--root`; anything else is reported and never run. Nothing the payloads name is executed:
19
+ * the gate only reads them, from an empty temp dir.
20
+ *
21
+ * WHAT A PASS DOES NOT CLAIM. The probe runs under doctor's environment — this terminal's PATH
22
+ * and HOME — not the host app's. A host launched with a different environment (an empty HOME, a
23
+ * PATH without the shell's tools) can still break a gate that passes here.
24
+ *
25
+ * `GENESEED_GATE_PROBE=1` keeps the probe's asks out of the gate ledger (`ledger()` in
26
+ * `js/hosts/hooks.mjs`), so `status` never counts a doctor run as a refusal.
27
+ */
28
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ import { liveRecordedHooks } from '../hosts/hosts.mjs';
33
+ import { claudeCfg, installState, installTargets } from '../hosts/installs.mjs';
34
+ import { SHIM_MARK, claudeGitBashPath } from '../hosts/shim.mjs';
35
+ import { which } from '../lib/paths.mjs';
36
+ import { NO_WINDOW } from '../lib/proc.mjs';
37
+ import { NOTE, withTempDir } from './scan.mjs';
38
+ import { spawnSync } from 'node:child_process';
39
+
40
+ /**
41
+ * The gate that refuses Law IV on each host (`claudeHookGroups`, `js/hosts/settings.mjs`). On
42
+ * Claude and OpenClaude it is `git-gate`; `tool-gate` there only counts Skill/Agent calls. Bob
43
+ * runs everything through `tool-gate --host bob`. OpenCode's guard is an in-process plugin with
44
+ * no command: `guardProbe` below imports it instead.
45
+ */
46
+ export const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
47
+
48
+ export const BAD_COMMAND = 'git reset --hard HEAD';
49
+ export const GOOD_COMMAND = 'git status';
50
+
51
+ const PROBE_TIMEOUT_MS = 20_000;
52
+
53
+ /** A PreToolUse payload in Claude's shape — the shape every probed host's gate reads. */
54
+ export function probePayload(command, cwd) {
55
+ return JSON.stringify({
56
+ session_id: 'geneseed-probe', hook_event_name: 'PreToolUse', tool_name: 'Bash',
57
+ tool_input: { command }, cwd,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * The shell a host runs this handler under, as `{ exe, argv, label, verbatim }` — or null when
63
+ * that shell is not on this machine. Mirrors the emit's own rule: an explicit
64
+ * `shell: "powershell"` wins; Bob runs `cmd /c` on Windows; Claude/OpenClaude run Git Bash
65
+ * there (`claudeGitBashPath`, the rule `claudeHookShell` uses); `sh -c` everywhere else.
66
+ */
67
+ export function probeShell(host, handler, platform = process.platform, env = process.env) {
68
+ const cmd = handler.command;
69
+ if (handler.shell === 'powershell') {
70
+ const ps = which('powershell') || which('pwsh');
71
+ return ps ? { exe: ps, argv: ['-NoProfile', '-NonInteractive', '-Command', cmd], label: 'PowerShell' }
72
+ : null;
73
+ }
74
+ if (platform !== 'win32') return { exe: which('sh') || '/bin/sh', argv: ['-c', cmd], label: 'sh' };
75
+ if (host === 'bob') {
76
+ // `/s /c "<cmd>"` with verbatim arguments: cmd strips the outer quotes and keeps the
77
+ // command's own quoting. Node's default quoting would turn its `"` into `\"`, which cmd
78
+ // does not unescape.
79
+ const cmdExe = env.ComSpec || env.COMSPEC || which('cmd');
80
+ return cmdExe ? { exe: cmdExe, argv: ['/d', '/s', '/c', `"${cmd}"`], label: 'cmd', verbatim: true }
81
+ : null;
82
+ }
83
+ const bash = claudeGitBashPath(env);
84
+ return bash ? { exe: bash, argv: ['-c', cmd], label: 'Git Bash' } : null;
85
+ }
86
+
87
+ const ASK_RE = /"permissionDecision":\s*"(?:ask|deny)"/;
88
+
89
+ /** Did the gate refuse? Exit 2 on Bob; on Claude-shaped hosts exit 2 or an ask/deny verdict. */
90
+ export function refused(host, r) {
91
+ if (host === 'bob') return r.status === 2;
92
+ return r.status === 2 || (r.status === 0 && ASK_RE.test(r.stdout || ''));
93
+ }
94
+
95
+ /**
96
+ * The verdict for one gate, from its two runs — null when the gate is sound, or the problem.
97
+ * Pure, so the whole verdict table is pinned without spawning.
98
+ */
99
+ export function probeVerdict(host, bad, good) {
100
+ for (const r of [bad, good]) {
101
+ if (r.status !== 0 && r.status !== 2) {
102
+ const why = r.error ? r.error : `exit ${r.status}`;
103
+ const tail = (r.stderr || '').trim().split(/\r?\n/).pop();
104
+ return `does not run (${why}${tail ? `: ${tail}` : ''})${host === 'claude'
105
+ ? ' — Claude blocks every matching call while this lasts (onFailure: block)'
106
+ : ' — the host lets every call through while this lasts'}`;
107
+ }
108
+ }
109
+ if (!refused(host, bad)) return `registered but not refusing — \`${BAD_COMMAND}\` went through`;
110
+ if (refused(host, good)) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
111
+ return null;
112
+ }
113
+
114
+ /** Is this recorded command one of ours for `verb`? A gate verb, `--root`, and our shim. */
115
+ export function isOurGate(command, verb) {
116
+ return command.includes(` ${verb} --root`) && command.includes(SHIM_MARK);
117
+ }
118
+
119
+ /** One spawn of one handler on one payload. THE ONE SPAWN of this module. */
120
+ function runOnce(shell, payload, cwd, env) {
121
+ const r = spawnSync(shell.exe, shell.argv, {
122
+ input: payload, cwd, env, encoding: 'utf-8', timeout: PROBE_TIMEOUT_MS,
123
+ windowsVerbatimArguments: Boolean(shell.verbatim), ...NO_WINDOW,
124
+ });
125
+ const e = /** @type {NodeJS.ErrnoException | undefined} */ (r.error);
126
+ const error = e ? (e.code === 'ETIMEDOUT' ? 'timed out' : e.message) : null;
127
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, error };
128
+ }
129
+
130
+ /**
131
+ * Probe every live Claude-shaped install. Returns `{ problems, probed }`: `problems` in
132
+ * doctor's line format, `probed` the labels of the gates that ran and behaved.
133
+ * `targets`, `platform` and `env` are parameters so a test hands it a sandboxed install.
134
+ */
135
+ export function gateProbe(targets = installTargets(), { platform = process.platform, env = process.env } = {}) {
136
+ const problems = [];
137
+ const probed = [];
138
+ for (const [host, scope, root] of targets) {
139
+ const verb = PROBED_VERB[host];
140
+ if (!verb || installState(root, host, scope) !== 'active') continue;
141
+ const cfg = claudeCfg(root, scope, host);
142
+ const gates = liveRecordedHooks(cfg)
143
+ .filter(([event, h]) => event === 'PreToolUse' && h.command.includes(` ${verb} --root`));
144
+ if (!gates.length) {
145
+ problems.push(`[probe] ${cfg}: no live ${verb} hook in its settings — nothing refuses `
146
+ + 'a destructive call at the boundary — run: geneseed rebuild-all');
147
+ continue;
148
+ }
149
+ for (const [, handler] of gates) {
150
+ const label = `[probe] ${cfg}: ${verb}`;
151
+ if (!isOurGate(handler.command, verb)) {
152
+ problems.push(`${label} not probed — its command does not run ${SHIM_MARK}, and doctor `
153
+ + 'runs nothing else from a settings file');
154
+ continue;
155
+ }
156
+ const shell = probeShell(host, handler, platform, env);
157
+ if (!shell) {
158
+ problems.push(`${label} does not run — the shell ${host} would use is not on this machine`);
159
+ continue;
160
+ }
161
+ const verdict = withTempDir((tmp) => {
162
+ const runEnv = { ...env, GENESEED_GATE_PROBE: '1', CLAUDE_PROJECT_DIR: tmp };
163
+ const bad = runOnce(shell, probePayload(BAD_COMMAND, tmp), tmp, runEnv);
164
+ const good = runOnce(shell, probePayload(GOOD_COMMAND, tmp), tmp, runEnv);
165
+ return probeVerdict(host, bad, good);
166
+ }, 'geneseed-probe-');
167
+ if (verdict) problems.push(`${label} under ${shell.label} ${verdict}`);
168
+ else probed.push(`${path.normalize(cfg)} (${verb}, ${shell.label})`);
169
+ }
170
+ }
171
+ return { problems, probed };
172
+ }
173
+
174
+ // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
175
+
176
+ export const GUARD_PLUGIN = 'geneseed-guard.js';
177
+ /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
178
+ * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
179
+ export const GUARD_BAD_COMMAND = 'rm -rf /';
180
+
181
+ /** The guard plugin of an OpenCode install: `<global>/plugins/` or `<repo>/.opencode/plugins/`
182
+ * (`emitOpencodeInto` copies the plugins to `<dir>/plugins`). */
183
+ export function guardPluginPath(root, scope) {
184
+ return path.join(scope === 'global' ? root : path.join(root, '.opencode'), 'plugins', GUARD_PLUGIN);
185
+ }
186
+
187
+ /** Run `fn` with stderr swallowed and `GENESEED_GATE_PROBE` set, both restored after. */
188
+ async function quietProbe(fn) {
189
+ const write = process.stderr.write;
190
+ const saved = process.env.GENESEED_GATE_PROBE;
191
+ process.stderr.write = () => true;
192
+ process.env.GENESEED_GATE_PROBE = '1';
193
+ try { return await fn(); } finally {
194
+ process.stderr.write = write;
195
+ if (saved === undefined) delete process.env.GENESEED_GATE_PROBE;
196
+ else process.env.GENESEED_GATE_PROBE = saved;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Both plugin APIs, each on both payloads: `{ v1: [badRefused, goodRefused], v2: [...] }`.
202
+ * V1 (`server`) refuses by THROWING `[geneseed-guard] …`; V2 (`setup`) by RENAMING the call
203
+ * (`setupV2`'s docblock). An unexpected throw is not a refusal — it is rethrown, and the caller
204
+ * reports the guard as broken.
205
+ */
206
+ export async function runGuard(mod, cwd) {
207
+ const v1 = async (command) => {
208
+ const hooks = await mod.server({ worktree: cwd, directory: cwd });
209
+ try {
210
+ await hooks['tool.execute.before']({ tool: 'bash' }, { args: { command } });
211
+ return false;
212
+ } catch (e) {
213
+ if (String(e?.message || '').startsWith('[geneseed-guard]')) return true;
214
+ throw e;
215
+ }
216
+ };
217
+ const v2 = async (command) => {
218
+ /** @type {((event: { tool: string }) => Promise<void>) | null} */
219
+ let hook = null;
220
+ await mod.setup({
221
+ location: { project: { directory: cwd }, directory: cwd },
222
+ tool: { hook: (name, fn) => { if (name === 'execute.before') hook = fn; } },
223
+ });
224
+ if (!hook) throw new Error('setup registered no execute.before hook');
225
+ const event = { tool: 'bash', input: { command } };
226
+ await hook(event);
227
+ return event.tool !== 'bash';
228
+ };
229
+ return {
230
+ v1: [await v1(GUARD_BAD_COMMAND), await v1(GOOD_COMMAND)],
231
+ v2: [await v2(GUARD_BAD_COMMAND), await v2(GOOD_COMMAND)],
232
+ };
233
+ }
234
+
235
+ /** The verdict for one API's pair — null when sound. */
236
+ export function guardVerdict([badRefused, goodRefused]) {
237
+ if (!badRefused) return `registered but not refusing — \`${GUARD_BAD_COMMAND}\` went through`;
238
+ if (goodRefused) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Probe every live OpenCode install's guard plugin by importing it into this process and
244
+ * calling its hooks — no spawn. Proves the installed FILE and its logic under Node, not under
245
+ * OpenCode's runtime (the weekly host canary proves the plugin loads there). Returns
246
+ * `{ problems, probed }` like `gateProbe`; a guard this environment switched off
247
+ * (`GENESEED_GUARD=off|warn`) is a NOTE, not a failure — that switch is the user's.
248
+ *
249
+ * Only a file named `geneseed-guard.js` in an install `installState` recognises is imported,
250
+ * and only one whose default export is `{ id: 'geneseed-guard', server, setup }`.
251
+ */
252
+ export async function guardProbe(targets = installTargets(), env = process.env) {
253
+ const problems = [];
254
+ const probed = [];
255
+ for (const [host, scope, root] of targets) {
256
+ if (host !== 'opencode' || installState(root, host, scope) !== 'active') continue;
257
+ const file = guardPluginPath(root, scope);
258
+ const label = `[probe] ${file}`;
259
+ if (!existsSync(file)) {
260
+ problems.push(`${label}: no guard plugin — nothing refuses a catastrophic command at the `
261
+ + 'boundary — run: geneseed rebuild-all');
262
+ continue;
263
+ }
264
+ const mode = String(env.GENESEED_GUARD || 'on').toLowerCase();
265
+ if (['off', '0', 'false', 'no', 'warn'].includes(mode)) {
266
+ problems.push(`${NOTE}${label}: not probed — GENESEED_GUARD=${mode} in this environment `
267
+ + 'switches the guard off');
268
+ continue;
269
+ }
270
+ let verdicts;
271
+ try {
272
+ const mod = (await import(pathToFileURL(file).href)).default;
273
+ if (mod?.id !== 'geneseed-guard' || typeof mod.server !== 'function' || typeof mod.setup !== 'function') {
274
+ problems.push(`${label}: not probed — it does not export the Geneseed guard `
275
+ + '({ id, server, setup }) — run: geneseed rebuild-all');
276
+ continue;
277
+ }
278
+ verdicts = await withTempDirAsync((tmp) => quietProbe(() => runGuard(mod, tmp)));
279
+ } catch (e) {
280
+ problems.push(`${label}: does not run (${e?.message || e})`);
281
+ continue;
282
+ }
283
+ const bad = [['V1', guardVerdict(verdicts.v1)], ['V2', guardVerdict(verdicts.v2)]]
284
+ .filter(([, v]) => v);
285
+ for (const [api, v] of bad) problems.push(`${label}: guard (OpenCode ${api} hook) ${v}`);
286
+ if (!bad.length) probed.push(`${path.normalize(file)} (guard, in-process)`);
287
+ }
288
+ return { problems, probed };
289
+ }
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
+ }
@@ -28,7 +28,7 @@ import { isFile, readText } from '../lib/fs.mjs';
28
28
  * console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
29
29
  * exactly the measured overrun and say why in that test's docblock, which keeps its history.
30
30
  */
31
- export const CARRIER_CEILING = { full: 62_107, lean: 38_976 };
31
+ export const CARRIER_CEILING = { full: 62_219, lean: 39_088 };
32
32
 
33
33
  const decode = (p) => readText(p).split('\r\n').join('\n');
34
34
 
@@ -39,6 +39,7 @@ import {
39
39
  import { validateIsVendored } from '../hosts/native.mjs';
40
40
  import { printOut } from '../lib/fs.mjs';
41
41
  import { authoringProblems, loopProblems } from './checks-authoring.mjs';
42
+ import { gateProbe, guardProbe } from './checks-probe.mjs';
42
43
  import {
43
44
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
44
45
  } from './checks-build.mjs';
@@ -188,6 +189,7 @@ export function claudeBobEmitProblems(themeName) {
188
189
  */
189
190
  export function doctorCollect({
190
191
  theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
192
+ probe = false, probed = null, extraProbeProblems = [],
191
193
  } = {}) {
192
194
  const available = themeFiles().map(stemOf);
193
195
  if (!available.length) return [[], ['[doctor] no themes found']];
@@ -208,6 +210,11 @@ export function doctorCollect({
208
210
  // placed after them could only ever observe the freshly repaired file and would be dead
209
211
  // code that always reports clean. Reported in its usual slot further down.
210
212
  const shimProbs = shimProblems();
213
+ // `--probe` (js/inspect/checks-probe.mjs) runs the installed gates THROUGH the shim, so it is
214
+ // sampled here for the same reason: after the emits it could only meet a repaired shim.
215
+ // `probed` collects the gates that behaved, for `cmdDoctor`'s summary line.
216
+ const probeRun = probe ? gateProbe() : null;
217
+ if (probeRun && probed) probed.push(...probeRun.probed);
211
218
  let problems = [];
212
219
  withTempDir((tmp) => {
213
220
  for (const themeName of themes) {
@@ -237,6 +244,9 @@ export function doctorCollect({
237
244
  // misbehave under PowerShell (Task 15) — context/learn fail open, and since Task 1 the two
238
245
  // gates fail CLOSED instead (`onFailure: "block"`). The reverse direction is a note.
239
246
  problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
247
+ if (probeRun) {
248
+ problems = problems.concat(ran('probe', 'Gate probe', [...probeRun.problems, ...extraProbeProblems]));
249
+ }
240
250
  // A machine check like the shim's: OPENCODE_CONFIG_DIR adds a dir, it does not replace one.
241
251
  const shadow = opencodeShadowedInstall();
242
252
  problems = problems.concat(ran('opencode_dirs', 'OpenCode config dirs', shadow
@@ -281,9 +291,19 @@ function captureBoth(fn) {
281
291
  * maintainer sweep. The cross-theme parity check runs in every mode.
282
292
  */
283
293
  export function cmdDoctor(args) {
294
+ // `--probe` alone is asynchronous: the OpenCode guard is probed by importing the installed
295
+ // plugin (`guardProbe`), and `import()` has no synchronous form. Every other path stays
296
+ // synchronous, because `validate` and `setup` call this and read its number directly.
297
+ if (args.probe) return guardProbe().then((guard) => doctorReport(args, guard));
298
+ return doctorReport(args, null);
299
+ }
300
+
301
+ function doctorReport(args, guard) {
302
+ const probed = guard ? [...guard.probed] : [];
284
303
  const [themes, collected] = doctorCollect({
285
304
  theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
286
- noBundle: Boolean(args.noBundle),
305
+ noBundle: Boolean(args.noBundle), probe: Boolean(args.probe), probed,
306
+ extraProbeProblems: guard ? guard.problems : [],
287
307
  });
288
308
  if (!themes.length) {
289
309
  printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
@@ -303,6 +323,12 @@ export function cmdDoctor(args) {
303
323
  const note = scoped
304
324
  ? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
305
325
  for (const n of notes) printOut(`${n}\n`);
326
+ // The probe's line on success, and the limit it cannot see past, said every time.
327
+ if (args.probe) {
328
+ printOut(`[probe] ${probed.length} gate(s) refused a destructive call and let a safe one `
329
+ + `through${probed.length ? `: ${probed.join(', ')}` : ''} — under this terminal's `
330
+ + 'environment, not the host app\'s\n');
331
+ }
306
332
  if (problems.length) {
307
333
  printOut(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
308
334
  for (const p of problems) printOut(` - ${p}\n`);
@@ -94,7 +94,7 @@ export const SKILL_CLASS = {
94
94
  loop: 'design', 'brick-forge': 'design',
95
95
  'codebase-design': 'design', wayfinder: 'design',
96
96
  develop: 'build', refactor: 'build', debug: 'build', dependencies: 'build',
97
- 'frontend-design': 'build', prototype: 'build',
97
+ 'frontend-design': 'build', prototype: 'build', deck: 'build',
98
98
  'forge-mcp': 'build', bruno: 'build', worktree: 'build',
99
99
  'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
100
100
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.4.0",
4
+ "version": "4.5.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/registry.json CHANGED
@@ -197,6 +197,13 @@
197
197
  "added": "2026-06-03",
198
198
  "last_verified": "2026-09-06"
199
199
  },
200
+ "skills/deck": {
201
+ "status": "experimental",
202
+ "version": "1.0.0",
203
+ "owner": "Guillaume Lemer",
204
+ "added": "2026-10-16",
205
+ "last_verified": ""
206
+ },
200
207
  "skills/dependencies": {
201
208
  "status": "approved",
202
209
  "version": "1.0.0",
package/src/AGENT.md.tmpl CHANGED
@@ -256,6 +256,7 @@ the ten council seats are convened by the [council {{SKILL}}]({{DIR_SKILLS}}/cou
256
256
  | [bruno]({{DIR_SKILLS}}/bruno.md) | creating, converting, or testing a Bruno API collection — requests, environments, assertions, chaining — from routes, OpenAPI, curl, or an endpoint list |
257
257
  | [prose]({{DIR_SKILLS}}/prose.md) | writing prose for a human — a commit body, PR, doc, comment, memory note, or reply — that should not read like a model wrote it |
258
258
  | [frontend-design]({{DIR_SKILLS}}/frontend-design.md) | building or reshaping a UI that should carry a deliberate point of view, not a templated default |
259
+ | [deck]({{DIR_SKILLS}}/deck.md) | a presentation, slides or a deck with no file format named — one offline HTML file |
259
260
  | [handoff]({{DIR_SKILLS}}/handoff.md) | context filling, or passing work to another agent or session |
260
261
  | [roast-me]({{DIR_SKILLS}}/roast-me.md) | you want your own ask, plan or idea torn apart, brutally and actionably (code goes to code review) |
261
262
  | [council]({{DIR_SKILLS}}/council.md) | a decision, change, or discussion needs debating from several points of view before committing |
@@ -0,0 +1,56 @@
1
+ # {{SKILL}}: deck
2
+
3
+ > {{DESC_DECK}}
4
+ <!-- aliases: slides, presentation -->
5
+
6
+ **Trigger:** the user asks for a presentation, slides or a deck without naming a file format.
7
+ An explicit `.pptx` request belongs to the host's pptx skill. To change an existing deck, edit
8
+ its HTML file in place rather than regenerating it.
9
+
10
+ ## Procedure
11
+ 1. **Interview, one question at a time**, each with a recommended default the user can accept
12
+ with "ok": subject and source; audience (technical peers); goal — inform, convince, train,
13
+ report (inform); length as duration or slide count (~10); style — sober, editorial, dark
14
+ technical, playful, or an imposed brand's colours and logo (sober, follows system light/dark);
15
+ usage — projected live, read alone, or both (both); speaker notes (no); PDF export (yes);
16
+ density (from usage: projected 1-3 bullets a slide, read alone 4-8); a browser render check
17
+ (only if asked). Given a source or outline, skip what it answers, batch the rest in one
18
+ message, and go to step 2.
19
+ 2. **Propose the outline** — parts and one line per slide — and generate only once the user
20
+ validates or corrects it. Write the slides in the user's language unless asked otherwise.
21
+ 3. **Write the whole deck by hand as ONE self-contained file** — no bundled shell, no CDN, no
22
+ external script; all CSS and JS inline:
23
+ - A fixed 1920x1080 stage scaled uniformly to the window, letterboxed, never reflowed; each
24
+ slide a `<section>` with that fixed size, never `height: auto`. Inactive slides hidden with
25
+ `visibility`/`opacity`, not layout tricks.
26
+ - Keys ←/→, Space, PageUp/PageDown, Home/End, and touch swipe; the URL hash `#N` tracks the
27
+ slide, with back/forward working.
28
+ - `@media print` plus `@page` size so each slide prints as one page (`@page` size is Baseline
29
+ 2024; animations never reach the PDF).
30
+ - Speaker notes in `<aside class="notes">`, hidden, toggled with N.
31
+ - A system font stack; a web font only with a system fallback, so the deck degrades offline.
32
+ - Diagrams as inline SVG; raster images base64 only when small (base64 adds ~33%).
33
+ - Colours as `:root` variables with a `prefers-color-scheme: dark` set and checked contrast;
34
+ one real heading per slide in a sane order; `prefers-reduced-motion` honoured; animations
35
+ off unless asked for.
36
+ - Palette, type and avoiding templated defaults: follow the
37
+ [frontend-design {{SKILL}}](../{{DIR_SKILLS}}/frontend-design.md) — not repeated here.
38
+ 4. **Write** `<slug>.html` in the working directory unless the user gives a path; ask before
39
+ overwriting an existing file. Open it in the default browser with the platform's opener
40
+ (`open`, `xdg-open`, or `start ""` on Windows).
41
+ 5. **Verify.** By default, hold every slide to the density limit and say plainly that the render
42
+ was not checked. Only on request, open the deck in a browser the host offers (Chrome MCP,
43
+ Orca browser, Playwright) and run this — it works because inactive slides keep their layout:
44
+ ```js
45
+ [...document.querySelectorAll('section')].map((s, i) => [i + 1, s])
46
+ .filter(([, s]) => s.scrollHeight > s.clientHeight || s.scrollWidth > s.clientWidth)
47
+ .map(([i]) => i)
48
+ ```
49
+ Fix each flagged slide and re-run, two repair rounds at most ({{DOCTRINE:bound-the-loop}}).
50
+ 6. Reply in one line: the file, slide count, ←/→ to move, N for notes, Ctrl+P for PDF.
51
+
52
+ ## Done when
53
+ - One offline HTML file opens in the browser, navigates by key, swipe and hash, prints one slide
54
+ per page, and the reply states whether the render was checked.
55
+
56
+ <!-- INCLUDE: skills/_self-improvement.md -->
@@ -4,6 +4,7 @@
4
4
 
5
5
  **Trigger:** building a new UI or reshaping an existing one, and it should carry a
6
6
  deliberate point of view rather than read as a templated default.
7
+ A presentation or slide deck goes to the [deck {{SKILL}}](../{{DIR_SKILLS}}/deck.md).
7
8
 
8
9
  **Requires:** a way to render and screenshot the result (a browser or the host's
9
10
  preview tool) — the second-pass critique in step 6 cannot run without one.
@@ -73,7 +73,11 @@ re-derived. Then take exactly one branch.
73
73
  rebuild would have seeded it.
74
74
  6. **Draft in the file's own format**: the next free `## R<n> — Title` heading, an
75
75
  optional `(scope: user|project | source: …)` metadata line, then the rule in one
76
- short paragraph, plain and testable. For a promotion from {{MEMORY}}, set
76
+ short paragraph, plain and testable. For a rule the user stated, `source:` keeps
77
+ their own words and the day they said them — `source: user 2026-10-16 "never push
78
+ red"` — one sentence at most: it is what later tells a stale rule from a changed
79
+ mind. The metadata line splits on `|`, so write any `|` inside the quote as `/` and
80
+ say so when you show the draft. For a promotion from {{MEMORY}}, set
77
81
  `source:` to the memory's name and add `trial until:` about a month out — a
78
82
  promoted rule starts on probation.
79
83
  7. Show the exact text to the user and wait for explicit consent ({{LAW:deletion-is-deliberate}}) before
@@ -90,7 +94,12 @@ the user picks "rule" out of impatience and the set inflates.
90
94
  **Review flow** (on request, or when a `trial until:` date has passed): for each rule
91
95
  due, ask whether it actually fired since adoption — graduate it (drop the trial
92
96
  marker), demote it back to a {{MEMORY}} fact, or delete it. Same consent gate as
93
- adoption.
97
+ adoption. Two more cases are due at any time:
98
+ - **Contradicted.** The user's later words contradict a rule: the rule is the suspect.
99
+ Quote both — its `source:` and the new words — and ask which stands.
100
+ - **Broken while only read.** A rule nothing checks but reading it, broken twice
101
+ anyway, is step 1's mechanical case: propose the hook or check rather than
102
+ rewording it.
94
103
 
95
104
  **Consolidation flow** → read [rule/consolidate.md](rule/consolidate.md) and follow it.
96
105
 
@@ -142,6 +142,7 @@
142
142
  "DESC_BRUNO": "<one-line description: build, reorganise or test a Bruno API collection — requests, environments, assertions, chaining — every secret a placeholder>",
143
143
  "DESC_PROSE": "<one-line description: write prose for a human that doesn't read like a model — cut filler, active voice, vary rhythm, trust the reader>",
144
144
  "DESC_FRONTEND_DESIGN": "<one-line description: design a UI with intent — palette/type/layout chosen for the brief, not templated defaults; plan tokens, critique against the brief, keep one bold move>",
145
+ "DESC_DECK": "<one-line description: build slides or a presentation as one self-contained offline HTML file — short interview, outline approved first, keys, URL hash, one slide per printed page>",
145
146
  "DESC_WORKFLOW": "<one-line description: run a saved, code-driven workflow that orchestrates subagents deterministically>",
146
147
  "AGENT_COLORS": {
147
148
  "architect": "primary",