geneseed 3.20.0 → 4.1.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 +71 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +324 -175
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/cli-table.json +7 -7
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +11 -30
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +48 -18
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +43 -87
- package/js/web/api.mjs +13 -4
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +1 -2
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +3 -2
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/bricks/_template.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/doctrines/rigor.md +8 -7
- package/src/laws/universal.md +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +6 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +8 -2
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/ship.md +2 -1
- package/src/skills/worktree.md +2 -0
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
- package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
- package/web/dist/index.html +2 -2
|
@@ -301,6 +301,23 @@ async function writeMemories(output, memDir, existing) {
|
|
|
301
301
|
return written
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// The distil's reply into the store: a child session's one agent lesson, else memory
|
|
305
|
+
// files plus their MEMORY.md lines. Shared by the V1 and V2 runs.
|
|
306
|
+
async function storeDistil(output, { isChild, memDir, agentName, existing }) {
|
|
307
|
+
if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
|
|
308
|
+
if (isChild) {
|
|
309
|
+
const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
|
|
310
|
+
if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
|
|
311
|
+
const f = await appendAgentLesson(memDir, agentName, lesson)
|
|
312
|
+
console.error(`[geneseed-learn] agent lesson -> ${f}`)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const written = await writeMemories(output, memDir, existing)
|
|
316
|
+
if (written.length) {
|
|
317
|
+
console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
304
321
|
export const GeneseedLearn = async (ctx) => {
|
|
305
322
|
const { client } = ctx ?? {}
|
|
306
323
|
const ours = new Set() // throwaway distil sessions — never mine our own output
|
|
@@ -376,18 +393,7 @@ export const GeneseedLearn = async (ctx) => {
|
|
|
376
393
|
if (newId) await client.session.delete({ path: { id: newId } }).catch(() => {})
|
|
377
394
|
}
|
|
378
395
|
|
|
379
|
-
|
|
380
|
-
if (isChild) {
|
|
381
|
-
const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
|
|
382
|
-
if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
|
|
383
|
-
const f = await appendAgentLesson(memDir, agentName, lesson)
|
|
384
|
-
console.error(`[geneseed-learn] agent lesson -> ${f}`)
|
|
385
|
-
return
|
|
386
|
-
}
|
|
387
|
-
const written = await writeMemories(output, memDir, existing)
|
|
388
|
-
if (written.length) {
|
|
389
|
-
console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
|
|
390
|
-
}
|
|
396
|
+
await storeDistil(output, { isChild, memDir, agentName, existing })
|
|
391
397
|
} catch (err) {
|
|
392
398
|
// Never break the session over a memory write.
|
|
393
399
|
console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
|
|
@@ -426,6 +432,113 @@ export const GeneseedLearn = async (ctx) => {
|
|
|
426
432
|
|
|
427
433
|
// OpenCode treats every export as a plugin and rejects non-functions; hang the test
|
|
428
434
|
// helpers off the factory instead — reachable via import, invisible to the loader.
|
|
429
|
-
Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson })
|
|
435
|
+
Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson, flattenV2 })
|
|
436
|
+
|
|
437
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
438
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
439
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
440
|
+
//
|
|
441
|
+
// What differs on V2:
|
|
442
|
+
// - The debounce re-arms on `session.execution.succeeded`: V2 core never publishes
|
|
443
|
+
// `session.idle` (deprecated in its schema), and the execution pair is its turn
|
|
444
|
+
// boundary. Events are `{ type, data }`, not `{ type, properties }`.
|
|
445
|
+
// - The transcript is `session.context()` — the messages after the last compaction —
|
|
446
|
+
// typed `user` (text) / `assistant` (content parts, `model: { providerID, id }`).
|
|
447
|
+
// - The distil is one stateless `generate.text({ prompt, model })` call with the
|
|
448
|
+
// session's own model: no throwaway session to create, guard against and delete.
|
|
449
|
+
function flattenV2(messages) {
|
|
450
|
+
const lines = []
|
|
451
|
+
let model = null
|
|
452
|
+
for (const m of messages || []) {
|
|
453
|
+
if (m?.type === "user") {
|
|
454
|
+
const text = String(m.text ?? "").trim()
|
|
455
|
+
if (text) lines.push(`user: ${text}`)
|
|
456
|
+
} else if (m?.type === "assistant") {
|
|
457
|
+
const text = (m.content || [])
|
|
458
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
459
|
+
.map((p) => p.text).join("\n").trim()
|
|
460
|
+
if (text) lines.push(`assistant: ${text}`)
|
|
461
|
+
if (m.model?.providerID && m.model?.id) model = m.model
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { notes: lines.join("\n\n"), model }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function setupV2(ctx) {
|
|
468
|
+
const root = ctx?.location?.project?.directory || ctx?.location?.directory || process.cwd()
|
|
469
|
+
const timers = new Map()
|
|
470
|
+
const inFlight = new Set()
|
|
471
|
+
let warnedNoDir = false
|
|
472
|
+
|
|
473
|
+
async function runDistill(sid) {
|
|
474
|
+
if (await sovereignBypass(root)) return
|
|
475
|
+
if (inFlight.has(sid)) return
|
|
476
|
+
inFlight.add(sid)
|
|
477
|
+
try {
|
|
478
|
+
const meta = await ctx.session.get({ sessionID: sid }).catch(() => null)
|
|
479
|
+
if (String(meta?.title ?? "").startsWith("geneseed-")) return
|
|
480
|
+
const isChild = !!meta?.parentID
|
|
481
|
+
const agentName = isChild ? resolveAgentName(meta) : null
|
|
482
|
+
if (isChild && !agentName) return
|
|
483
|
+
|
|
484
|
+
const memDir = await resolveMemoryDir()
|
|
485
|
+
if (!memDir) {
|
|
486
|
+
if (!warnedNoDir) {
|
|
487
|
+
warnedNoDir = true
|
|
488
|
+
console.error("[geneseed-learn] no memory dir — set $GENESEED_HARNESS or $GENESEED_MEMORY.")
|
|
489
|
+
}
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let { notes, model } = flattenV2(await ctx.session.context({ sessionID: sid }))
|
|
494
|
+
if (notes.length < MIN_NOTES_CHARS) return
|
|
495
|
+
notes = notes.slice(-MAX_NOTES_CHARS)
|
|
496
|
+
if (!model) {
|
|
497
|
+
const env = envModel()
|
|
498
|
+
model = env ? { providerID: env.providerID, id: env.modelID } : null
|
|
499
|
+
}
|
|
500
|
+
if (!model) {
|
|
501
|
+
console.error("[geneseed-learn] could not determine a model — set $GENESEED_MODEL=provider/model.")
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const existing = await existingSlugs(memDir)
|
|
506
|
+
const prompt = isChild
|
|
507
|
+
? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
|
|
508
|
+
: buildPrompt(notes, existing)
|
|
509
|
+
const reply = await ctx.generate.text({
|
|
510
|
+
prompt, model: { providerID: model.providerID, id: model.id, ...(model.variant ? { variant: model.variant } : {}) },
|
|
511
|
+
})
|
|
512
|
+
await storeDistil(String(reply?.text ?? ""), { isChild, memDir, agentName, existing })
|
|
513
|
+
} catch (err) {
|
|
514
|
+
console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
|
|
515
|
+
} finally {
|
|
516
|
+
inFlight.delete(sid)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const controller = new AbortController()
|
|
521
|
+
void (async () => {
|
|
522
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
523
|
+
const sid = ev?.data?.sessionID
|
|
524
|
+
if (!sid) continue
|
|
525
|
+
if (ev.type === "session.deleted") {
|
|
526
|
+
const t = timers.get(sid)
|
|
527
|
+
if (t) clearTimeout(t)
|
|
528
|
+
timers.delete(sid)
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (ev.type !== "session.execution.succeeded") continue
|
|
532
|
+
const prev = timers.get(sid)
|
|
533
|
+
if (prev) clearTimeout(prev)
|
|
534
|
+
timers.set(sid, setTimeout(() => { timers.delete(sid); runDistill(sid) }, DEBOUNCE_MS))
|
|
535
|
+
}
|
|
536
|
+
})().catch((err) => console.error(`[geneseed-learn] event stream ended: ${err?.message ?? err}`))
|
|
537
|
+
return () => {
|
|
538
|
+
controller.abort()
|
|
539
|
+
for (const t of timers.values()) clearTimeout(t)
|
|
540
|
+
timers.clear()
|
|
541
|
+
}
|
|
542
|
+
}
|
|
430
543
|
|
|
431
|
-
export default GeneseedLearn
|
|
544
|
+
export default { id: "geneseed-learn", server: GeneseedLearn, setup: setupV2 }
|
|
@@ -157,4 +157,49 @@ export const GeneseedNotify = async ({ client }) => {
|
|
|
157
157
|
// off the factory instead — reachable via `import`, invisible to the loader.
|
|
158
158
|
Object.assign(GeneseedNotify, { lastUserMs, shouldNotify, notifyCommand })
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
161
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
162
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
163
|
+
//
|
|
164
|
+
// V2 core never publishes `session.idle` (deprecated in its schema). The turn boundary is
|
|
165
|
+
// the execution pair instead: `session.execution.started` stamps when the agent began,
|
|
166
|
+
// `session.execution.succeeded`/`failed` ends it — so the turn length comes straight off
|
|
167
|
+
// the events, no transcript read. An interrupted execution is the user's own doing (or a
|
|
168
|
+
// shutdown) and is not announced. Events are `{ type, created, data }`, not V1's
|
|
169
|
+
// `{ type, properties }`.
|
|
170
|
+
async function setupV2(ctx) {
|
|
171
|
+
if (OFF) return
|
|
172
|
+
const started = new Map()
|
|
173
|
+
const controller = new AbortController()
|
|
174
|
+
void (async () => {
|
|
175
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
176
|
+
const sid = ev?.data?.sessionID
|
|
177
|
+
if (!sid) continue
|
|
178
|
+
if (ev.type === "session.execution.started") { started.set(sid, ev.created); continue }
|
|
179
|
+
if (ev.type === "session.execution.interrupted" || ev.type === "session.deleted") { started.delete(sid); continue }
|
|
180
|
+
if (ev.type !== "session.execution.succeeded" && ev.type !== "session.execution.failed") continue
|
|
181
|
+
const begin = started.get(sid) ?? null
|
|
182
|
+
started.delete(sid)
|
|
183
|
+
try {
|
|
184
|
+
let title = "", parentID
|
|
185
|
+
try {
|
|
186
|
+
const info = await ctx.session.get({ sessionID: sid })
|
|
187
|
+
title = info?.title ?? ""
|
|
188
|
+
parentID = info?.parentID
|
|
189
|
+
} catch {}
|
|
190
|
+
const now = typeof ev.created === "number" ? ev.created : Date.now()
|
|
191
|
+
if (!shouldNotify({ now, lastUserMs: begin, parentID, title, minMs: MIN_MS })) {
|
|
192
|
+
log(`skipped ${sid} (child/throwaway or turn under ${MIN_MS}ms)`)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
deliver(TITLE, title ? `Done: ${title}` : "Agent finished — your turn.")
|
|
196
|
+
log(`notified for ${sid}`)
|
|
197
|
+
} catch (err) {
|
|
198
|
+
log(`skipped: ${err?.message ?? err}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
202
|
+
return () => controller.abort()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default { id: "geneseed-notify", server: GeneseedNotify, setup: setupV2 }
|
|
@@ -143,6 +143,48 @@ export const GeneseedPonytail = async () => ({
|
|
|
143
143
|
// any that isn't a function — a bare helper export crashes startup ("null is not an
|
|
144
144
|
// object") or logs "not a function". Hang the test helpers off the factory instead;
|
|
145
145
|
// they ride along for `import`, invisible to the loader. Upgrade path: none needed.
|
|
146
|
-
Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions })
|
|
146
|
+
Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions, levelFromPrompt })
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// --- OpenCode 2 --------------------------------------------------------------
|
|
149
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
150
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
151
|
+
//
|
|
152
|
+
// V2 has neither hook. `session.hook("context")` carries the outgoing system parts (the
|
|
153
|
+
// ruleset is pushed there), and there is no `command.execute.before`: a markdown command
|
|
154
|
+
// expands its template into the prompt, so the switch is read off the prompt instead —
|
|
155
|
+
// the first line of PONYTAIL_COMMAND_BODY (js/hosts/opencode.mjs), `Ponytail level
|
|
156
|
+
// requested: <args>`. Change that line and this pattern together.
|
|
157
|
+
const PONYTAIL_PROMPT_RE = /^Ponytail level requested:[ \t]*(.*)$/m
|
|
158
|
+
|
|
159
|
+
// The level a `/ponytail` prompt asks for: undefined when the text is not one, null
|
|
160
|
+
// when its argument is no known level, else the level (empty argument = `full`).
|
|
161
|
+
function levelFromPrompt(text) {
|
|
162
|
+
const m = PONYTAIL_PROMPT_RE.exec(String(text ?? ""))
|
|
163
|
+
if (!m || m.index !== 0) return undefined
|
|
164
|
+
const arg = m[1].trim()
|
|
165
|
+
return normalizeMode(arg) || (arg ? null : "full")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function setupV2(ctx) {
|
|
169
|
+
await ctx.session.hook("prompt", (event) => {
|
|
170
|
+
try {
|
|
171
|
+
const mode = levelFromPrompt(event?.prompt?.text)
|
|
172
|
+
if (mode === undefined) return
|
|
173
|
+
if (!mode) { log("ignored unknown level"); return }
|
|
174
|
+
writeMode(mode)
|
|
175
|
+
log(`ponytail ${mode}`)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log(`prompt hook error: ${err?.message ?? err}`)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
await ctx.session.hook("context", (event) => {
|
|
181
|
+
try {
|
|
182
|
+
const text = ponytailInstructions(readMode())
|
|
183
|
+
if (text && Array.isArray(event?.system)) event.system.push({ type: "text", text })
|
|
184
|
+
} catch (err) {
|
|
185
|
+
log(`context hook error: ${err?.message ?? err}`)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default { id: "geneseed-ponytail", server: GeneseedPonytail, setup: setupV2 }
|
|
@@ -116,23 +116,10 @@ async function writeTrace(rootDir, runId, lines) {
|
|
|
116
116
|
} catch (e) { dlog(`trace write failed: ${e?.message || e}`); return "(trace write failed)" }
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// The `workflow` tool's body, shared by the V1 and V2 registrations: `client` is the V1
|
|
120
|
+
// SDK client, or on V2 the facade `v2Client` builds over the plugin context.
|
|
121
|
+
function makeExecute({ client, directory, worktree }) {
|
|
121
122
|
const rootDir = worktree || directory
|
|
122
|
-
let toolHelper
|
|
123
|
-
try { ({ tool: toolHelper } = await import("@opencode-ai/plugin")) }
|
|
124
|
-
catch { dlog("@opencode-ai/plugin tool helper unavailable — registering a raw tool def") }
|
|
125
|
-
|
|
126
|
-
// One-line load banner (always on) so a restart visibly confirms the plugin loaded
|
|
127
|
-
// and the `workflow` tool is registered — and WARNS if this OpenCode build lacks the
|
|
128
|
-
// custom-tool helper (the silent "tool never appears" failure).
|
|
129
|
-
try {
|
|
130
|
-
const names = await listWorkflows()
|
|
131
|
-
const warn = toolHelper ? ""
|
|
132
|
-
: " — WARN: @opencode-ai/plugin 'tool' helper missing; the workflow tool may NOT register on this OpenCode version"
|
|
133
|
-
console.error(`[geneseed-workflow] loaded — tool 'workflow' registered, ${names.length} workflow(s) [${names.join(", ") || "none"}] from ${WORKFLOWS_DIR}${warn}`)
|
|
134
|
-
} catch (e) { console.error(`[geneseed-workflow] loaded but could not list workflows: ${e?.message || e}`) }
|
|
135
|
-
|
|
136
123
|
// Normalize the `args` parameter. OpenCode's tool layer can deliver a structured
|
|
137
124
|
// object param as a JSON STRING (or drop it) when the schema is untyped — so coerce
|
|
138
125
|
// a string back to an object and reject non-object shapes to `{}`. Without this, the
|
|
@@ -185,22 +172,52 @@ export const GeneseedWorkflow = async (ctx) => {
|
|
|
185
172
|
return summarize(name, result, stats, tracePath)
|
|
186
173
|
}
|
|
187
174
|
|
|
175
|
+
return execute
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The `workflow` tool as the model sees it, on both majors.
|
|
179
|
+
const TOOL_DESCRIPTION =
|
|
180
|
+
"Run a saved, code-driven Geneseed workflow that orchestrates subagents " +
|
|
181
|
+
"deterministically (parallel fan-out, find→verify pipeline, phased handoff). " +
|
|
182
|
+
"Call with no name to list available workflows."
|
|
183
|
+
const NAME_DESCRIPTION = "saved workflow name; omit to list available"
|
|
184
|
+
const ARGS_DESCRIPTION = "inputs passed to the workflow script as `args` (object, or a JSON object string)"
|
|
185
|
+
|
|
186
|
+
// One-line load banner (always on) so a restart visibly confirms the plugin loaded and the
|
|
187
|
+
// `workflow` tool is registered. tests/host_canary.mjs reads it as V1's proof of load.
|
|
188
|
+
async function loadBanner(warn = "") {
|
|
189
|
+
try {
|
|
190
|
+
const names = await listWorkflows()
|
|
191
|
+
console.error(`[geneseed-workflow] loaded — tool 'workflow' registered, ${names.length} workflow(s) [${names.join(", ") || "none"}] from ${WORKFLOWS_DIR}${warn}`)
|
|
192
|
+
} catch (e) { console.error(`[geneseed-workflow] loaded but could not list workflows: ${e?.message || e}`) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const GeneseedWorkflow = async (ctx) => {
|
|
196
|
+
const { client, directory, worktree } = ctx
|
|
197
|
+
let toolHelper
|
|
198
|
+
try { ({ tool: toolHelper } = await import("@opencode-ai/plugin")) }
|
|
199
|
+
catch { dlog("@opencode-ai/plugin tool helper unavailable — registering a raw tool def") }
|
|
200
|
+
|
|
201
|
+
// Warn when this OpenCode build lacks the custom-tool helper (the silent "tool never
|
|
202
|
+
// appears" failure).
|
|
203
|
+
await loadBanner(toolHelper ? ""
|
|
204
|
+
: " — WARN: @opencode-ai/plugin 'tool' helper missing; the workflow tool may NOT register on this OpenCode version")
|
|
205
|
+
|
|
206
|
+
const execute = makeExecute({ client, directory, worktree })
|
|
207
|
+
|
|
188
208
|
const def = {
|
|
189
|
-
description:
|
|
190
|
-
"Run a saved, code-driven Geneseed workflow that orchestrates subagents " +
|
|
191
|
-
"deterministically (parallel fan-out, find→verify pipeline, phased handoff). " +
|
|
192
|
-
"Call with no name to list available workflows.",
|
|
209
|
+
description: TOOL_DESCRIPTION,
|
|
193
210
|
args: toolHelper
|
|
194
211
|
? {
|
|
195
|
-
name: toolHelper.schema.string().optional().describe(
|
|
212
|
+
name: toolHelper.schema.string().optional().describe(NAME_DESCRIPTION),
|
|
196
213
|
// Keep this `any()` — it's the only param shape known to register cleanly on
|
|
197
214
|
// this OpenCode build (record()/object schemas wedged tool registration → every
|
|
198
215
|
// session message stuck "queued"). The provider may still deliver the object as
|
|
199
216
|
// a JSON string or empty; normalizeArgs() in execute() recovers both cases.
|
|
200
|
-
args: toolHelper.schema.any().optional().describe(
|
|
217
|
+
args: toolHelper.schema.any().optional().describe(ARGS_DESCRIPTION),
|
|
201
218
|
}
|
|
202
219
|
: {
|
|
203
|
-
name: { type: "string", description:
|
|
220
|
+
name: { type: "string", description: NAME_DESCRIPTION },
|
|
204
221
|
args: { type: "object", description: "inputs passed to the workflow script as `args`" },
|
|
205
222
|
},
|
|
206
223
|
async execute(argv) { return execute(argv) },
|
|
@@ -209,4 +226,87 @@ export const GeneseedWorkflow = async (ctx) => {
|
|
|
209
226
|
return { tool: { workflow: toolHelper ? toolHelper(def) : def } }
|
|
210
227
|
}
|
|
211
228
|
|
|
212
|
-
|
|
229
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
230
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
231
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
232
|
+
//
|
|
233
|
+
// V2 registers tools through `tool.transform` with a plain JSON-schema `input` (no
|
|
234
|
+
// `@opencode-ai/plugin` helper — that package is `@opencode/plugin` there, and not
|
|
235
|
+
// needed). The runtime (`_runtime.js`) and every saved workflow speak the V1 SDK client,
|
|
236
|
+
// so `v2Client` presents that client's few calls over the V2 session domain instead of
|
|
237
|
+
// forking the runtime:
|
|
238
|
+
// create → session.create (a worktree directory becomes the session's location)
|
|
239
|
+
// prompt → switchAgent/switchModel when asked, then session.prompt — which only
|
|
240
|
+
// admits the input on V2 — then session.wait for the loop to go idle, and the
|
|
241
|
+
// last assistant message read back from session.context
|
|
242
|
+
// messages → session.context, reshaped to V1's `{ info, parts }`
|
|
243
|
+
// delete → session.remove
|
|
244
|
+
function v2Message(m) {
|
|
245
|
+
if (m?.type === "assistant") {
|
|
246
|
+
return {
|
|
247
|
+
info: { role: "assistant", tokens: m.tokens },
|
|
248
|
+
parts: (m.content || []).filter((p) => p?.type === "text").map((p) => ({ type: "text", text: p.text })),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { info: { role: m?.type }, parts: typeof m?.text === "string" ? [{ type: "text", text: m.text }] : [] }
|
|
252
|
+
}
|
|
253
|
+
function v2ModelRef(model) {
|
|
254
|
+
if (typeof model === "string" && model.includes("/")) {
|
|
255
|
+
const [providerID, ...rest] = model.split("/")
|
|
256
|
+
return { providerID, id: rest.join("/") }
|
|
257
|
+
}
|
|
258
|
+
const id = model?.id ?? model?.modelID
|
|
259
|
+
return model?.providerID && id ? { providerID: model.providerID, id } : null
|
|
260
|
+
}
|
|
261
|
+
function v2Client(ctx) {
|
|
262
|
+
const context = async (sid) => (await ctx.session.context({ sessionID: sid })) || []
|
|
263
|
+
return {
|
|
264
|
+
session: {
|
|
265
|
+
create: async ({ body, query } = {}) => ctx.session.create({
|
|
266
|
+
...(body?.title ? { title: body.title } : {}),
|
|
267
|
+
...(query?.directory ? { location: { directory: query.directory } } : {}),
|
|
268
|
+
}),
|
|
269
|
+
prompt: async ({ path: p, body } = {}) => {
|
|
270
|
+
const sessionID = p?.id
|
|
271
|
+
if (body?.agent) await ctx.session.switchAgent({ sessionID, agent: body.agent })
|
|
272
|
+
const model = v2ModelRef(body?.model)
|
|
273
|
+
if (model) await ctx.session.switchModel({ sessionID, model })
|
|
274
|
+
const text = (body?.parts || []).filter((x) => x?.type === "text").map((x) => x.text).join("\n")
|
|
275
|
+
await ctx.session.prompt({ sessionID, text })
|
|
276
|
+
await ctx.session.wait({ sessionID })
|
|
277
|
+
const last = (await context(sessionID)).filter((m) => m?.type === "assistant").pop()
|
|
278
|
+
return last ? v2Message(last) : null
|
|
279
|
+
},
|
|
280
|
+
messages: async ({ path: p } = {}) => (await context(p?.id)).map(v2Message),
|
|
281
|
+
delete: async ({ path: p } = {}) => ctx.session.remove({ sessionID: p?.id }),
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function setupV2(ctx) {
|
|
287
|
+
const directory = ctx?.location?.directory
|
|
288
|
+
const worktree = ctx?.location?.project?.directory
|
|
289
|
+
const execute = makeExecute({ client: v2Client(ctx), directory, worktree })
|
|
290
|
+
await loadBanner()
|
|
291
|
+
await ctx.tool.transform((editor) => {
|
|
292
|
+
editor.add({
|
|
293
|
+
name: "workflow",
|
|
294
|
+
description: TOOL_DESCRIPTION,
|
|
295
|
+
input: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
name: { type: "string", description: NAME_DESCRIPTION },
|
|
299
|
+
args: { description: ARGS_DESCRIPTION },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
// A rejected promise here is a defect that fails the whole agent step, not the call:
|
|
303
|
+
// every failure comes back as text, as the V1 tool's do.
|
|
304
|
+
execute: async (input) => {
|
|
305
|
+
try { return { content: await execute(input) } }
|
|
306
|
+
catch (e) { return { content: `Workflow failed: ${e?.message || e}` } }
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default { id: "geneseed-workflow", server: GeneseedWorkflow, setup: setupV2 }
|
package/bin/geneseed-hook.mjs
CHANGED
|
@@ -38,7 +38,12 @@
|
|
|
38
38
|
* subparsers (25 invocable names — `update` is an alias of `upgrade`); a hook entry that
|
|
39
39
|
* silently accepted `doctor` and did nothing would be the worst available failure, because
|
|
40
40
|
* every Geneseed hook returns 0 and signals through stdout — so "did nothing" and "worked"
|
|
41
|
-
* are the same observation.
|
|
41
|
+
* are the same observation. That refusal exits **1**, never 2: on Claude Code and Bob, exit 2
|
|
42
|
+
* is the one code that blocks a tool call (or, on `Stop`/`SubagentStop`, keeps the turn going
|
|
43
|
+
* without running the hook again), so a verb this build does not know yet — the forward case,
|
|
44
|
+
* when a newer Geneseed names a verb this stale checkout has never heard of, under the
|
|
45
|
+
* machine-wide last-writer-wins shim (see I3/B1 below) — must not read as a block. Exit 1 still
|
|
46
|
+
* shows a `<hook name> hook error` notice; it just does not stop anything (see I3/B1).
|
|
42
47
|
*/
|
|
43
48
|
// FIRST, before anything it could fail to run: an old Node gets one sentence, not a stack trace.
|
|
44
49
|
import '../js/lib/node-floor.mjs';
|
|
@@ -55,6 +60,13 @@ import { printErr } from '../js/lib/fs.mjs';
|
|
|
55
60
|
// specifiers stay literal strings — `tests/unit/hook_cli.test.mjs`'s import walk reads them.
|
|
56
61
|
const HOSTED = { '--root': 'root', '--host': 'host' };
|
|
57
62
|
const gates = () => import('../js/hosts/hooks.mjs');
|
|
63
|
+
// Verbs PreToolUse/PostToolUse actually gate a tool call with. The machine-wide, last-writer-wins
|
|
64
|
+
// shim (this file's header) means a stale checkout can be handed a flag only a newer install
|
|
65
|
+
// knows (e.g. `--no-consent`); on Claude Code, exit 2 from one of THESE verbs blocks the tool
|
|
66
|
+
// outright, with no ask. `main` below answers an argv error on a gate verb with exit 0 instead —
|
|
67
|
+
// a gate's own decision still exits 0 and signals through stdout (`askDecision`), so this only
|
|
68
|
+
// changes what used to be a hard lockout into "the gate is silent this one call" (see I3/B1).
|
|
69
|
+
const GATE_VERBS = new Set(['git-gate', 'rule-gate', 'tool-gate']);
|
|
58
70
|
const VERBS = {
|
|
59
71
|
context: { load: () => import('../js/hosts/hooks-context.mjs'), fn: 'cmdContext', flags: HOSTED },
|
|
60
72
|
'git-gate': {
|
|
@@ -65,7 +77,8 @@ const VERBS = {
|
|
|
65
77
|
learn: {
|
|
66
78
|
load: () => import('../js/hosts/hooks-learn.mjs'),
|
|
67
79
|
fn: 'cmdLearn',
|
|
68
|
-
|
|
80
|
+
// `--host` only keys the global stand-down's marker (js/hosts/hosts.mjs); learn has no dialect.
|
|
81
|
+
flags: { '--memory': 'memory', '--host': 'host' },
|
|
69
82
|
switches: { '--consolidate': 'consolidate' },
|
|
70
83
|
positional: 'file',
|
|
71
84
|
},
|
|
@@ -118,7 +131,9 @@ async function main(argv) {
|
|
|
118
131
|
|
|
119
132
|
const verb = argv[0];
|
|
120
133
|
if (!verb || verb === '-h' || verb === '--help') {
|
|
121
|
-
|
|
134
|
+
// 1, not 2: see this file's header (I3/B1) — 2 is the one code that blocks a tool call on
|
|
135
|
+
// Claude Code and Bob, and a bare invocation is not a gate decision.
|
|
136
|
+
return die(1, `the following arguments are required: cmd (one of ${
|
|
122
137
|
Object.keys(VERBS).join(', ')})`);
|
|
123
138
|
}
|
|
124
139
|
const spec = VERBS[verb];
|
|
@@ -130,7 +145,11 @@ async function main(argv) {
|
|
|
130
145
|
// The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
|
|
131
146
|
// (package.json), it answers every non-hook verb, and it survives the deletion of the
|
|
132
147
|
// interpreter-plus-script invocation this line used to print.
|
|
133
|
-
|
|
148
|
+
//
|
|
149
|
+
// 1, not 2 (I3/B1): the forward case is the one that matters — settings emitted by a NEWER
|
|
150
|
+
// Geneseed name a verb this stale checkout, running from the machine-wide last-writer-wins
|
|
151
|
+
// shim, has never heard of. That must not read as a block.
|
|
152
|
+
return die(1, `invalid choice: '${verb}'. This entry point carries only the HOOK `
|
|
134
153
|
+ `verbs (${Object.keys(VERBS).join(', ')}); every other harness subcommand lives `
|
|
135
154
|
+ 'elsewhere — run `geneseed ' + verb + '`.');
|
|
136
155
|
}
|
|
@@ -146,7 +165,19 @@ async function main(argv) {
|
|
|
146
165
|
if (rc !== null) return rc;
|
|
147
166
|
}
|
|
148
167
|
const parsed = parse(spec, argv.slice(1));
|
|
149
|
-
if (parsed.error)
|
|
168
|
+
if (parsed.error) {
|
|
169
|
+
// A gate verb must never exit 2 on an argv error: that is indistinguishable, to Claude's
|
|
170
|
+
// PreToolUse, from the gate deliberately blocking (see I3/B1 above). Print the same message
|
|
171
|
+
// to stderr for a human debugging the shim, but return 0 with no stdout, exactly like a gate
|
|
172
|
+
// that chose not to act.
|
|
173
|
+
if (GATE_VERBS.has(verb)) { printErr(`geneseed-hook: error: ${parsed.error}\n`); return 0; }
|
|
174
|
+
// `context` and `learn` are not gate verbs, but their own argv errors are not a decision
|
|
175
|
+
// either, so still 1, not 2 (I3/B1): a `<hook name> hook error` notice, and nothing more.
|
|
176
|
+
// `learn` is the one that matters here — it runs on Stop/SubagentStop, where exit 2 ALSO has
|
|
177
|
+
// an effect (it sends Claude back to keep working instead of letting the turn end; docs
|
|
178
|
+
// `hooks.md`, "Stop, SubagentStop, TaskCompleted, and TeammateIdle").
|
|
179
|
+
return die(1, parsed.error);
|
|
180
|
+
}
|
|
150
181
|
return (await spec.load())[spec.fn](parsed.args);
|
|
151
182
|
}
|
|
152
183
|
|
package/docs/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[← Back to README](../README.md)
|
|
4
4
|
|
|
5
|
-
**New to agent harnesses?** Read **Understand** first
|
|
5
|
+
**New to agent harnesses?** Read **Understand** first: the track every other page assumes. Its last section, *Why it works this way*, is reference, to consult when a question comes up. The same pages open the **Docs** tab of the web console (`geneseed web`), which groups each part into sections, adds an interactive map of your own install, and filters host-specific passages to the tool you use.
|
|
6
6
|
|
|
7
7
|
## 1 · Understand
|
|
8
8
|
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
**Day to day** — [A day with the harness](understand/a-day.md): the moments you will actually meet · [Enforced vs. asked](understand/enforced-vs-asked.md): what is guaranteed by code, and what the model is only asked to do.
|
|
14
14
|
|
|
15
|
+
**Why it works this way** — [Why Geneseed works this way](understand/design-choices.md): every decision already taken, one line each, and where it is argued · [The shape of the harness](understand/design-choices-shape.md) · [Gates](understand/design-choices-gates.md) · [Hosts](understand/design-choices-hosts.md) · [Install, uninstall, update](understand/design-choices-install.md) · [Engineering, skills and agents](understand/design-choices-engineering.md) · [Considered and refused](design-history.md#considered-and-refused).
|
|
16
|
+
|
|
15
17
|
## 2 · Guides
|
|
16
18
|
|
|
17
19
|
| Section | Guide | When |
|
package/docs/candidate-hosts.md
CHANGED
|
@@ -66,14 +66,6 @@ skill catalogue for the price of one directory name, with no host-specific code
|
|
|
66
66
|
A Gemini host would be the third verdict spelling in `js/hosts/hooks.mjs` (after Claude's
|
|
67
67
|
ask and Bob's exit code 2), plus a resolver and a carrier.
|
|
68
68
|
|
|
69
|
-
## Evaluated, dropped
|
|
70
|
-
|
|
71
|
-
- **GitHub Copilot** — shipped as a host through 3.9.0, removed in 3.10.0. Dropped for lack
|
|
72
|
-
of support: its hooks were never verified live, its `postToolUse` output does not reach
|
|
73
|
-
the model, and it shared no cross-tool surface with the other hosts (camelCase payloads,
|
|
74
|
-
matcherless hook entries, its own agent dialect and carrier), so every feature needed a
|
|
75
|
-
Copilot-only decision.
|
|
76
|
-
|
|
77
69
|
## Not verified
|
|
78
70
|
|
|
79
71
|
None of the three CLIs is installed on the reviewing machine. Every row above is from the
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
group: concepts
|
|
3
|
+
order: 24
|
|
4
|
+
title: "Rule enforcement, by host"
|
|
5
|
+
kind: "concept"
|
|
6
|
+
section: "Harness"
|
|
7
|
+
description: "Every law and doctrine rule, and what each host actually does about it."
|
|
8
|
+
---
|
|
9
|
+
[Enforced vs. asked](../understand/enforced-vs-asked.md) names the two tiers. This page is the
|
|
10
|
+
full cross-reference: every rule id, its pack, and what each host does at the tool boundary. A
|
|
11
|
+
few rules are enforced; the rest are asked on every host, through the root rules file
|
|
12
|
+
(`CLAUDE.md`, `AGENT.md`, `AGENTS.md`).
|
|
13
|
+
|
|
14
|
+
Derived from the code, not from this project's own planning notes: `js/hosts/hooks.mjs`'s
|
|
15
|
+
`BLOCK_RULES` and gate functions, `HOOK_PINNED` in `js/inspect/checks-constitution.mjs`,
|
|
16
|
+
`LAW_IV_BASH` and `CONSENT_RULE` in `js/hosts/settings.mjs`, and
|
|
17
|
+
`adapters/opencode/plugins/geneseed-guard.js`. `HOOK_PINNED` is what keeps this table from
|
|
18
|
+
silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-stable-rule-ids.md).
|
|
19
|
+
|
|
20
|
+
### The enforced rules
|
|
21
|
+
|
|
22
|
+
| id | Pack | Claude Code | OpenClaude | OpenCode | Bob |
|
|
23
|
+
|---|---|---|---|---|---|
|
|
24
|
+
| `sealed-secrets` | law | ask: rule-gate (credential-shaped content outside `.env*`) | same as Claude Code | block: guard plugin (key/credential file path, or vendor-prefixed secret content); warns only on `.env*` | block (exit 2) |
|
|
25
|
+
| `deletion-is-deliberate` | law | ask: git-gate (the destructive-git set in [Hooks](hooks.md): force-push and every history-discarding git act it lists) | same as Claude Code | ask: permission prompt (the same destructive-git set) **+** block: guard plugin (catastrophic shell: `rm -rf /`, fork bomb, `format`, `dd` over a raw disk) **+** warn only: guard plugin (the destructive-git set, as a backstop to the permission prompt) | block (exit 2) |
|
|
26
|
+
| `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
|
|
27
|
+
| `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
|
|
28
|
+
| `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
|
|
29
|
+
| `commands-must-return` | ops | ask: git-gate (a recursive scan rooted at a filesystem root: `find /`, `du`, `grep -r`, `rg`, `Get-ChildItem -Recurse` at `/`, `/c`, `C:\`) | same as Claude Code | block: guard plugin (same root-scan pattern) | block (exit 2) |
|
|
30
|
+
|
|
31
|
+
Bob has no "ask" tier (see [ADR 0007](../adr/0007-gates-fail-closed-per-host-ceiling.md)), so a
|
|
32
|
+
rule the other hosts only ask about either blocks outright on Bob or degrades to a log line.
|
|
33
|
+
Never a silent pass; Bob runs every gate above through one fused command, `tool-gate`, dispatched
|
|
34
|
+
on the payload shape rather than on separate matchers. OpenCode enforces through two separate
|
|
35
|
+
mechanisms: its own permission prompt (`opencode.json`, static globs) and the guard plugin
|
|
36
|
+
(`tool.execute.before`, pattern matching); a rule can use either or both.
|
|
37
|
+
See [ADR 0008](../adr/0008-host-parity-ceiling-of-the-weakest-host.md) for why a gate that only
|
|
38
|
+
one host can run still ships for every host it can.
|
|
39
|
+
|
|
40
|
+
### Everything else: asked only, on every host
|
|
41
|
+
|
|
42
|
+
Every other rule carries no gate on any host. A human reads them because the model was told to
|
|
43
|
+
follow them, not because a tool call was ever inspected.
|
|
44
|
+
|
|
45
|
+
**Laws** — `one-intent-one-act` · `verify-before-asserting` · `surface-failures` ·
|
|
46
|
+
`data-not-orders` · `least-privilege` · `cure-the-cause` · `echo-the-intent`.
|
|
47
|
+
|
|
48
|
+
**Doctrine: process** — `plan-before-acting` · `context-economy` · `read-the-docs-first` ·
|
|
49
|
+
`bound-the-loop` · `one-writer-per-file`.
|
|
50
|
+
|
|
51
|
+
**Doctrine: craft** — `automate-repetition` · `english-configuration` · `documentation-in-step` ·
|
|
52
|
+
`search-before-creating` · `respect-conventions` · `smallest-viable-diff` · `one-writer-per-value`.
|
|
53
|
+
|
|
54
|
+
**Doctrine: ops** — `tool-discovery` · `edit-the-source-not-the-surface` · `complete-the-teardown` ·
|
|
55
|
+
`record-the-probe-not-the-snapshot` · `restart-is-not-reload` ·
|
|
56
|
+
`serialize-against-a-rationed-resource`.
|
|
57
|
+
|
|
58
|
+
**Doctrine: rigor** — `idempotent-by-default` · `honest-tests` · `cover-and-verify` ·
|
|
59
|
+
`prove-the-gate`.
|
|
60
|
+
|
|
61
|
+
**Doctrine: comms** — `codes-that-persist` · `structure-beside-prose` · `declare-the-judgment-calls`.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
**Where next:** [Enforced vs. asked](../understand/enforced-vs-asked.md) · [Rules](rules.md) ·
|
|
66
|
+
[Hook gates, settings and the shim](hook-gates.md)
|
|
@@ -16,7 +16,7 @@ If a gate cannot evaluate a call — an unreadable payload, a bug — it asks, w
|
|
|
16
16
|
geneseed status
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
In an [excluded folder](exclusions.md), every hook of a global install exits silently. And a global install's `context`
|
|
19
|
+
In an [excluded folder](exclusions.md), every hook of a global install exits silently. And a global install's `context`, `git-gate` and `learn` hooks stand down when the session's project folder (the one the session started in, not a parent of it) has its own live per-repo install of the same host that wires the same hook, so nothing is injected, asked or learned twice. A per-repo install that is deactivated, or whose hooks were never wired, does not count: the global hook keeps running. `GENESEED_STACK_GLOBAL=1` keeps both.
|
|
20
20
|
|
|
21
21
|
## Where they are written
|
|
22
22
|
|
|
@@ -37,7 +37,7 @@ The merge is surgical. Every other key and every hook of your own survive, and t
|
|
|
37
37
|
**Bob** has its own contract. It uses Claude's event names but ignores a hook's output before a tool call — the only way to refuse is exit code 2 — and it has no tool matcher, `SubagentStop` or `PreCompact`. So Bob gets three hooks:
|
|
38
38
|
|
|
39
39
|
- `SessionStart` → `geneseed-hook context`.
|
|
40
|
-
- `PreToolUse` → `geneseed-hook tool-gate`, the git gate and the rule gate fused into one command that decides from the shape of the call. It **blocks** (exit 2) only for the
|
|
40
|
+
- `PreToolUse` → `geneseed-hook tool-gate`, the git gate and the rule gate fused into one command that decides from the shape of the call. It **blocks** (exit 2) only for the checks that admit no judgement call: secrets, destructive git, a recursive scan of a whole filesystem, and a write under a project's own protected-checks list. The commit/push and rule-or-memory checks become a warning line, because a hard block would leave Bob unable to commit at all.
|
|
41
41
|
- `Stop` → `geneseed-hook learn`.
|
|
42
42
|
|
|
43
43
|
Neither host has been verified live on the authoring machine. If a hook does not fire, the same rules still reach the agent through the instructions file.
|