geneseed 2.0.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 +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- package/web/src/pages/Laws.jsx +230 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Geneseed — OpenCode ponytail plugin (sustained minimal-code mode).
|
|
2
|
+
//
|
|
3
|
+
// The `ponytail` skill (src/skills/ponytail.md) is the invokable, themed source of
|
|
4
|
+
// truth for the "laziest solution that works" discipline. This plugin is its
|
|
5
|
+
// SUSTAINED counterpart: once you opt in with `/ponytail lite|full|ultra`, it appends
|
|
6
|
+
// a compact ruleset to the system prompt EVERY turn so the agent does not drift back
|
|
7
|
+
// to over-building mid-session, and persists the level across turns. Mirrors the
|
|
8
|
+
// upstream ponytail OpenCode plugin (DietrichGebert/ponytail) but is self-contained —
|
|
9
|
+
// no shared `hooks/` module to require, no external state convention.
|
|
10
|
+
//
|
|
11
|
+
// OPT-IN by default (Geneseed treats ponytail as a skill, not an always-on Law): the
|
|
12
|
+
// mode starts at `off` and nothing is injected until you switch it on. Set
|
|
13
|
+
// GENESEED_PONYTAIL=lite|full|ultra to make a level the default for new installs, or
|
|
14
|
+
// GENESEED_PONYTAIL=off (the default) to keep it dormant until asked.
|
|
15
|
+
//
|
|
16
|
+
// Two hooks, mirroring the upstream design:
|
|
17
|
+
// - experimental.chat.system.transform — append the ruleset at the active level.
|
|
18
|
+
// - command.execute.before — intercept `/ponytail <level>` and persist
|
|
19
|
+
// it; the switch applies from the NEXT turn (the transform reads what the command
|
|
20
|
+
// wrote). Levels: lite | full | ultra | off.
|
|
21
|
+
//
|
|
22
|
+
// State lives beside OpenCode's config as `.geneseed-ponytail` (one word: the level),
|
|
23
|
+
// so it survives restarts. Quiet by default; GENESEED_DEBUG=1 logs switches. Every
|
|
24
|
+
// failure is swallowed — it never blocks a session. Experimental hooks; on a build
|
|
25
|
+
// that lacks `chat.system.transform` the plugin simply never injects (the skill still
|
|
26
|
+
// covers the invokable path).
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs"
|
|
29
|
+
import os from "node:os"
|
|
30
|
+
import path from "node:path"
|
|
31
|
+
|
|
32
|
+
const MODES = new Set(["lite", "full", "ultra", "off"])
|
|
33
|
+
const DEBUG = !!process.env.GENESEED_DEBUG
|
|
34
|
+
function log(msg) { if (DEBUG) console.error(`[geneseed-ponytail] ${msg}`) }
|
|
35
|
+
|
|
36
|
+
// --- pure helpers (exported for tests) ---------------------------------------
|
|
37
|
+
|
|
38
|
+
// Normalise a raw mode string to a known level, or null if unrecognised. Tolerant of
|
|
39
|
+
// surrounding whitespace and case; "normal"/"stop"/"none" are spelled as `off`.
|
|
40
|
+
function normalizeMode(raw) {
|
|
41
|
+
const s = String(raw ?? "").trim().toLowerCase()
|
|
42
|
+
if (!s) return null
|
|
43
|
+
if (s === "normal" || s === "stop" || s === "none" || s === "disable") return "off"
|
|
44
|
+
return MODES.has(s) ? s : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The default level when no switch has been made yet — $GENESEED_PONYTAIL, else `off`.
|
|
48
|
+
// Opt-in: Geneseed keeps ponytail dormant until the user asks for it.
|
|
49
|
+
function defaultMode() {
|
|
50
|
+
return normalizeMode(process.env.GENESEED_PONYTAIL) || "off"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The compact ruleset appended to the system prompt for a non-off level. Kept lean on
|
|
54
|
+
// purpose — it rides every turn, so ponytail eats its own dog food. null when off.
|
|
55
|
+
function ponytailInstructions(mode) {
|
|
56
|
+
if (mode === "off" || !MODES.has(mode)) return null
|
|
57
|
+
const ladder =
|
|
58
|
+
"Before writing code, climb the ladder and stop at the first rung that holds: " +
|
|
59
|
+
"(1) does this need to exist at all? speculative need → skip it, say so in one line; " +
|
|
60
|
+
"(2) stdlib does it? use it; (3) native platform feature covers it? prefer it; " +
|
|
61
|
+
"(4) an already-installed dependency solves it? use it, never add a new one for what a few lines do; " +
|
|
62
|
+
"(5) can it be one line? one line; (6) only then the minimum code that works."
|
|
63
|
+
const rules =
|
|
64
|
+
"No abstraction with one implementation, no scaffolding \"for later\", deletion over addition, " +
|
|
65
|
+
"fewest files, shortest working diff. Mark deliberate shortcuts with a `ponytail:` comment naming " +
|
|
66
|
+
"the ceiling and upgrade path. Non-trivial logic leaves ONE runnable check. Never simplify away " +
|
|
67
|
+
"input validation at trust boundaries, error handling that prevents data loss, security, " +
|
|
68
|
+
"accessibility basics, hardware calibration, or anything explicitly requested. Output code first, " +
|
|
69
|
+
"then at most three short lines: skipped X, add when Y."
|
|
70
|
+
const level = {
|
|
71
|
+
lite: "LITE: build what's asked, but name the lazier alternative in one line and let the user pick.",
|
|
72
|
+
full: "FULL: enforce the ladder. Stdlib and native first, shortest diff, shortest explanation.",
|
|
73
|
+
ultra: "ULTRA: YAGNI extremist. Ship the one-liner and challenge the rest of the requirement in the same breath.",
|
|
74
|
+
}[mode]
|
|
75
|
+
return `PONYTAIL MODE — write the laziest solution that works (lazy = efficient, not careless).\n${level}\n${ladder}\n${rules}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- state (flag file beside the OpenCode config) ----------------------------
|
|
79
|
+
|
|
80
|
+
function statePath() {
|
|
81
|
+
// Same precedence as build._opencode_config_dir() and activity's configBase()
|
|
82
|
+
// ($OPENCODE_CONFIG_DIR > $XDG_CONFIG_HOME/opencode > ~/.config/opencode) — an
|
|
83
|
+
// XDG-only check diverges whenever $OPENCODE_CONFIG_DIR is set, so the /ponytail
|
|
84
|
+
// toggle would land in a different dir than the console reads.
|
|
85
|
+
const cfg =
|
|
86
|
+
process.env.OPENCODE_CONFIG_DIR ||
|
|
87
|
+
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode")
|
|
88
|
+
return path.join(cfg, ".geneseed-ponytail")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readMode() {
|
|
92
|
+
try {
|
|
93
|
+
return normalizeMode(fs.readFileSync(statePath(), "utf8")) || defaultMode()
|
|
94
|
+
} catch {
|
|
95
|
+
return defaultMode()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeMode(mode) {
|
|
100
|
+
try {
|
|
101
|
+
const p = statePath()
|
|
102
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
103
|
+
fs.writeFileSync(p, mode)
|
|
104
|
+
return true
|
|
105
|
+
} catch (err) {
|
|
106
|
+
log(`could not persist mode: ${err?.message ?? err}`)
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- plugin ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export const GeneseedPonytail = async () => ({
|
|
114
|
+
// Append the ruleset to the system prompt every turn at the active level.
|
|
115
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
116
|
+
try {
|
|
117
|
+
const mode = readMode()
|
|
118
|
+
const text = ponytailInstructions(mode)
|
|
119
|
+
if (!text) return
|
|
120
|
+
if (Array.isArray(output?.system)) output.system.push(text)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(`transform error: ${err?.message ?? err}`)
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Persist `/ponytail <level>`; the next turn's injection follows it. A bare
|
|
127
|
+
// `/ponytail` with no argument means `full` (the conventional on switch).
|
|
128
|
+
"command.execute.before": async (input) => {
|
|
129
|
+
try {
|
|
130
|
+
if (!input || input.command !== "ponytail") return
|
|
131
|
+
const arg = (input.arguments || "").trim()
|
|
132
|
+
const mode = normalizeMode(arg) || (arg ? null : "full")
|
|
133
|
+
if (!mode) { log(`ignored unknown level: ${arg}`); return }
|
|
134
|
+
writeMode(mode)
|
|
135
|
+
log(`ponytail ${mode}`)
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log(`command error: ${err?.message ?? err}`)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ponytail: OpenCode treats every export in a plugin file as a plugin and rejects
|
|
143
|
+
// any that isn't a function — a bare helper export crashes startup ("null is not an
|
|
144
|
+
// object") or logs "not a function". Hang the test helpers off the factory instead;
|
|
145
|
+
// they ride along for `import`, invisible to the loader. Upgrade path: none needed.
|
|
146
|
+
Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions })
|
|
147
|
+
|
|
148
|
+
export default GeneseedPonytail
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Geneseed — OpenCode workflow plugin.
|
|
2
|
+
//
|
|
3
|
+
// Registers ONE custom tool, `workflow`, that runs saved, code-driven orchestration
|
|
4
|
+
// scripts from the sibling `workflows/` dir (`.opencode/workflows/` in a per-repo
|
|
5
|
+
// emit, `<config>/workflows/` global). The model calls `workflow({ name, args })`;
|
|
6
|
+
// the named script — not the model — drives the control flow, spawning subagents via
|
|
7
|
+
// the runtime (`_runtime.js`). This is the deterministic counterpart to the
|
|
8
|
+
// model-driven `council` / `parallel-agents` skills. See
|
|
9
|
+
// adapters/opencode/workflows/_runtime.js and
|
|
10
|
+
// docs/specs/2026-06-09-opencode-workflow-primitive.md.
|
|
11
|
+
//
|
|
12
|
+
// Saved workflows ONLY — no model-authored/eval'd scripts (v1). Synchronous: the tool
|
|
13
|
+
// blocks until the workflow finishes; a phase-by-phase trace and the full result are
|
|
14
|
+
// written to `.geneseed/workflow-runs/<runId>.log`. Every failure is contained and
|
|
15
|
+
// reported, never thrown into the session.
|
|
16
|
+
//
|
|
17
|
+
// Quiet by default; GENESEED_DEBUG=1 logs to stderr. GENESEED_WORKFLOWS_DIR overrides
|
|
18
|
+
// where saved workflows are loaded from.
|
|
19
|
+
|
|
20
|
+
import { promises as fs } from "node:fs"
|
|
21
|
+
import * as path from "node:path"
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
23
|
+
|
|
24
|
+
const DEBUG = !!process.env.GENESEED_DEBUG
|
|
25
|
+
function dlog(m) { if (DEBUG) console.error(`[geneseed-workflow] ${m}`) }
|
|
26
|
+
|
|
27
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const WORKFLOWS_DIR = process.env.GENESEED_WORKFLOWS_DIR
|
|
29
|
+
? path.resolve(process.env.GENESEED_WORKFLOWS_DIR)
|
|
30
|
+
: path.resolve(__dir, "..", "workflows")
|
|
31
|
+
|
|
32
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i
|
|
33
|
+
|
|
34
|
+
async function listWorkflows() {
|
|
35
|
+
try {
|
|
36
|
+
const entries = await fs.readdir(WORKFLOWS_DIR)
|
|
37
|
+
return entries
|
|
38
|
+
.filter((f) => f.endsWith(".js") && !f.startsWith("_"))
|
|
39
|
+
.map((f) => f.slice(0, -3))
|
|
40
|
+
.sort()
|
|
41
|
+
} catch { return [] }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadWorkflow(name) {
|
|
45
|
+
if (!NAME_RE.test(name)) throw new Error(`invalid workflow name: ${name}`)
|
|
46
|
+
const file = path.join(WORKFLOWS_DIR, `${name}.js`)
|
|
47
|
+
const rel = path.relative(WORKFLOWS_DIR, file)
|
|
48
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("workflow path escapes workflows dir")
|
|
49
|
+
await fs.access(file)
|
|
50
|
+
// Cache-bust: Node's ESM cache holds the first import for the process lifetime,
|
|
51
|
+
// so an edited workflow script would otherwise run stale until OpenCode restarts.
|
|
52
|
+
const mod = await import(pathToFileURL(file).href + "?t=" + Date.now())
|
|
53
|
+
const run = mod.default || mod.run
|
|
54
|
+
if (typeof run !== "function") throw new Error(`workflow "${name}" has no default/run export`)
|
|
55
|
+
return { meta: mod.meta || { name }, run }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nowStamp() {
|
|
59
|
+
// Plugin-side only (NOT inside a workflow script) — a run id needs wall-clock; the
|
|
60
|
+
// runtime itself stays clock-free for reproducibility.
|
|
61
|
+
const d = new Date()
|
|
62
|
+
const p = (n) => String(n).padStart(2, "0")
|
|
63
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeJson(v) {
|
|
67
|
+
try { return JSON.stringify(v, null, 2) } catch { return String(v) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summarize(name, result, stats, tracePath) {
|
|
71
|
+
const head = `workflow "${name}" complete — ${stats.agentCount} agent run(s)` +
|
|
72
|
+
(stats.spent ? `, ~${stats.spent} output tokens` : "") +
|
|
73
|
+
(stats.phases.length ? `, phases: ${stats.phases.join(" → ")}` : "")
|
|
74
|
+
let body = safeJson(result)
|
|
75
|
+
if (body.length > 2000) body = body.slice(0, 2000) + `\n… (truncated — full result in ${tracePath})`
|
|
76
|
+
return `${head}\n\nResult:\n${body}\n\nTrace: ${tracePath}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeTrace(rootDir, runId, lines) {
|
|
80
|
+
if (!rootDir) return "(no trace file — unknown working dir)"
|
|
81
|
+
try {
|
|
82
|
+
const dir = path.join(rootDir, ".geneseed", "workflow-runs")
|
|
83
|
+
await fs.mkdir(dir, { recursive: true })
|
|
84
|
+
const file = path.join(dir, `${runId}.log`)
|
|
85
|
+
await fs.writeFile(file, lines.join("\n") + "\n", "utf8")
|
|
86
|
+
return file
|
|
87
|
+
} catch (e) { dlog(`trace write failed: ${e?.message || e}`); return "(trace write failed)" }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const GeneseedWorkflow = async (ctx) => {
|
|
91
|
+
const { client, directory, worktree } = ctx
|
|
92
|
+
const rootDir = worktree || directory
|
|
93
|
+
let toolHelper
|
|
94
|
+
try { ({ tool: toolHelper } = await import("@opencode-ai/plugin")) }
|
|
95
|
+
catch { dlog("@opencode-ai/plugin tool helper unavailable — registering a raw tool def") }
|
|
96
|
+
|
|
97
|
+
// One-line load banner (always on) so a restart visibly confirms the plugin loaded
|
|
98
|
+
// and the `workflow` tool is registered — and WARNS if this OpenCode build lacks the
|
|
99
|
+
// custom-tool helper (the silent "tool never appears" failure).
|
|
100
|
+
try {
|
|
101
|
+
const names = await listWorkflows()
|
|
102
|
+
const warn = toolHelper ? ""
|
|
103
|
+
: " — WARN: @opencode-ai/plugin 'tool' helper missing; the workflow tool may NOT register on this OpenCode version"
|
|
104
|
+
console.error(`[geneseed-workflow] loaded — tool 'workflow' registered, ${names.length} workflow(s) [${names.join(", ") || "none"}] from ${WORKFLOWS_DIR}${warn}`)
|
|
105
|
+
} catch (e) { console.error(`[geneseed-workflow] loaded but could not list workflows: ${e?.message || e}`) }
|
|
106
|
+
|
|
107
|
+
// Normalize the `args` parameter. OpenCode's tool layer can deliver a structured
|
|
108
|
+
// object param as a JSON STRING (or drop it) when the schema is untyped — so coerce
|
|
109
|
+
// a string back to an object and reject non-object shapes to `{}`. Without this, the
|
|
110
|
+
// workflow script's `args` arrives empty and every `args.foo` read is undefined.
|
|
111
|
+
const normalizeArgs = (raw) => {
|
|
112
|
+
let a = raw ?? {}
|
|
113
|
+
if (typeof a === "string") {
|
|
114
|
+
try { a = JSON.parse(a) } catch { a = {} }
|
|
115
|
+
}
|
|
116
|
+
if (a == null || typeof a !== "object" || Array.isArray(a)) a = {}
|
|
117
|
+
return a
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const execute = async (argv) => {
|
|
121
|
+
const name = (argv?.name || "").trim()
|
|
122
|
+
const available = await listWorkflows()
|
|
123
|
+
if (!name) {
|
|
124
|
+
return `Available workflows: ${available.join(", ") || "(none found in " + WORKFLOWS_DIR + ")"}.\n` +
|
|
125
|
+
`Run one with workflow({ name, args }).`
|
|
126
|
+
}
|
|
127
|
+
if (!available.includes(name)) {
|
|
128
|
+
return `Unknown workflow "${name}". Available: ${available.join(", ") || "(none)"}.`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let wf
|
|
132
|
+
try { wf = await loadWorkflow(name) }
|
|
133
|
+
catch (e) { return `Failed to load workflow "${name}": ${e?.message || e}` }
|
|
134
|
+
|
|
135
|
+
const runId = `${name}-${nowStamp()}`
|
|
136
|
+
const lines = []
|
|
137
|
+
const sink = (line) => lines.push(line)
|
|
138
|
+
sink(`# workflow ${name} — ${wf.meta?.description || ""}`)
|
|
139
|
+
|
|
140
|
+
// Lazily import the runtime so a broken runtime never blocks tool registration.
|
|
141
|
+
let createRuntime
|
|
142
|
+
try { ({ createRuntime } = await import(pathToFileURL(path.join(WORKFLOWS_DIR, "_runtime.js")).href + "?t=" + Date.now())) }
|
|
143
|
+
catch (e) { return `Workflow runtime unavailable: ${e?.message || e}` }
|
|
144
|
+
|
|
145
|
+
const rt = createRuntime({ client, directory, worktree, args: normalizeArgs(argv?.args), log: sink })
|
|
146
|
+
let result, failed = null
|
|
147
|
+
try { result = await wf.run(rt) }
|
|
148
|
+
catch (e) { failed = e; sink(`✗ workflow error: ${e?.stack || e?.message || e}`) }
|
|
149
|
+
|
|
150
|
+
const stats = rt._stats()
|
|
151
|
+
sink(`\n# done — ${stats.agentCount} agent run(s), ~${stats.spent} output tokens`)
|
|
152
|
+
if (!failed) sink(`# result:\n${safeJson(result)}`)
|
|
153
|
+
const tracePath = await writeTrace(rootDir, runId, lines)
|
|
154
|
+
|
|
155
|
+
if (failed) return `Workflow "${name}" failed: ${failed?.message || failed}\nTrace: ${tracePath}`
|
|
156
|
+
return summarize(name, result, stats, tracePath)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const def = {
|
|
160
|
+
description:
|
|
161
|
+
"Run a saved, code-driven Geneseed workflow that orchestrates subagents " +
|
|
162
|
+
"deterministically (parallel fan-out, find→verify pipeline, phased handoff). " +
|
|
163
|
+
"Call with no name to list available workflows.",
|
|
164
|
+
args: toolHelper
|
|
165
|
+
? {
|
|
166
|
+
name: toolHelper.schema.string().optional().describe("saved workflow name; omit to list available"),
|
|
167
|
+
// Keep this `any()` — it's the only param shape known to register cleanly on
|
|
168
|
+
// this OpenCode build (record()/object schemas wedged tool registration → every
|
|
169
|
+
// session message stuck "queued"). The provider may still deliver the object as
|
|
170
|
+
// a JSON string or empty; normalizeArgs() in execute() recovers both cases.
|
|
171
|
+
args: toolHelper.schema.any().optional().describe("inputs passed to the workflow script as `args` (object, or a JSON object string)"),
|
|
172
|
+
}
|
|
173
|
+
: {
|
|
174
|
+
name: { type: "string", description: "saved workflow name; omit to list available" },
|
|
175
|
+
args: { type: "object", description: "inputs passed to the workflow script as `args`" },
|
|
176
|
+
},
|
|
177
|
+
async execute(argv) { return execute(argv) },
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { tool: { workflow: toolHelper ? toolHelper(def) : def } }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default GeneseedWorkflow
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Geneseed — deterministic workflow runtime (OpenCode).
|
|
2
|
+
//
|
|
3
|
+
// The API handed to every saved workflow script: agent(), parallel(), pipeline(),
|
|
4
|
+
// phase(), log(), budget, args. It is code-driven orchestration — the script, not
|
|
5
|
+
// the model, decides the control flow. Child work runs as real OpenCode sessions
|
|
6
|
+
// spawned through the plugin's SDK `client`; orchestration logic here is pure and
|
|
7
|
+
// unit-testable against a mock client (see tests/workflow_runtime.test.mjs).
|
|
8
|
+
//
|
|
9
|
+
// SDK binding note: OpenCode's session API shape shifts between versions, so the
|
|
10
|
+
// child-session helpers below are defensive (envelope/`data` unwrap, multiple parts
|
|
11
|
+
// shapes, graceful degradation). The orchestration primitives do not depend on it.
|
|
12
|
+
//
|
|
13
|
+
// Determinism: this runtime uses no Date.now()/Math.random(), so a run is a pure
|
|
14
|
+
// function of (workflow, args, child replies). Saved scripts SHOULD likewise avoid
|
|
15
|
+
// wall-clock/random for reproducibility (pass timestamps via args). This is an
|
|
16
|
+
// authoring convention — imported modules cannot have their globals shadowed — not a
|
|
17
|
+
// hard sandbox guard. See docs/specs/2026-06-09-opencode-workflow-primitive.md.
|
|
18
|
+
|
|
19
|
+
import * as os from "node:os"
|
|
20
|
+
|
|
21
|
+
const DEBUG = !!process.env.GENESEED_DEBUG
|
|
22
|
+
function dlog(m) { if (DEBUG) console.error(`[geneseed-workflow] ${m}`) }
|
|
23
|
+
|
|
24
|
+
export const MAX_AGENTS = 1000
|
|
25
|
+
export const MAX_ITEMS = 4096
|
|
26
|
+
|
|
27
|
+
export function concurrencyCap() {
|
|
28
|
+
let cpu = 4
|
|
29
|
+
try { cpu = (os.cpus() || []).length || 4 } catch {}
|
|
30
|
+
return Math.max(1, Math.min(16, cpu - 2))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A tiny promise-pool limiter: at most `max` in flight; the rest queue.
|
|
34
|
+
export function createLimiter(max) {
|
|
35
|
+
let active = 0
|
|
36
|
+
const queue = []
|
|
37
|
+
const pump = () => {
|
|
38
|
+
if (active >= max || queue.length === 0) return
|
|
39
|
+
active++
|
|
40
|
+
const { fn, resolve, reject } = queue.shift()
|
|
41
|
+
Promise.resolve().then(fn).then(
|
|
42
|
+
(v) => { active--; resolve(v); pump() },
|
|
43
|
+
(e) => { active--; reject(e); pump() },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); pump() })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- structured-output validation -------------------------------------------
|
|
50
|
+
// Accepts a Zod schema (.safeParse), a predicate fn (value) => true | "error", or a
|
|
51
|
+
// tiny JSON-schema-like descriptor ({ type, required, properties }).
|
|
52
|
+
export function validateSchema(schema, value) {
|
|
53
|
+
if (!schema) return { ok: true, value }
|
|
54
|
+
if (typeof schema === "function") {
|
|
55
|
+
const r = schema(value)
|
|
56
|
+
if (r === true) return { ok: true, value }
|
|
57
|
+
return { ok: false, error: typeof r === "string" ? r : "predicate rejected value" }
|
|
58
|
+
}
|
|
59
|
+
if (typeof schema.safeParse === "function") {
|
|
60
|
+
const r = schema.safeParse(value)
|
|
61
|
+
return r.success
|
|
62
|
+
? { ok: true, value: r.data }
|
|
63
|
+
: { ok: false, error: r.error?.message || "schema mismatch" }
|
|
64
|
+
}
|
|
65
|
+
const t = schema.type
|
|
66
|
+
if (t === "array") return Array.isArray(value) ? { ok: true, value } : { ok: false, error: "expected array" }
|
|
67
|
+
if (t === "object" || schema.properties || schema.required) {
|
|
68
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "expected object" }
|
|
69
|
+
for (const k of (schema.required || [])) if (!(k in value)) return { ok: false, error: `missing key: ${k}` }
|
|
70
|
+
return { ok: true, value }
|
|
71
|
+
}
|
|
72
|
+
if (t && typeof value !== t) return { ok: false, error: `expected ${t}` }
|
|
73
|
+
return { ok: true, value }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// First balanced {...} or [...] span in a string (string-literal aware).
|
|
77
|
+
function sliceBalanced(text) {
|
|
78
|
+
const start = (() => {
|
|
79
|
+
const a = text.indexOf("{"), b = text.indexOf("[")
|
|
80
|
+
if (a < 0) return b
|
|
81
|
+
if (b < 0) return a
|
|
82
|
+
return Math.min(a, b)
|
|
83
|
+
})()
|
|
84
|
+
if (start < 0) return null
|
|
85
|
+
const open = text[start], close = open === "{" ? "}" : "]"
|
|
86
|
+
let depth = 0, inStr = false, esc = false
|
|
87
|
+
for (let i = start; i < text.length; i++) {
|
|
88
|
+
const c = text[i]
|
|
89
|
+
if (inStr) {
|
|
90
|
+
if (esc) esc = false
|
|
91
|
+
else if (c === "\\") esc = true
|
|
92
|
+
else if (c === '"') inStr = false
|
|
93
|
+
} else if (c === '"') inStr = true
|
|
94
|
+
else if (c === open) depth++
|
|
95
|
+
else if (c === close) { depth--; if (depth === 0) return text.slice(start, i + 1) }
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function extractJson(text) {
|
|
101
|
+
if (typeof text !== "string") return undefined
|
|
102
|
+
const candidates = []
|
|
103
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
104
|
+
if (fence) candidates.push(fence[1].trim())
|
|
105
|
+
candidates.push(text.trim())
|
|
106
|
+
const braced = sliceBalanced(text)
|
|
107
|
+
if (braced) candidates.push(braced)
|
|
108
|
+
for (const c of candidates) {
|
|
109
|
+
try { return JSON.parse(c) } catch { /* try next */ }
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- defensive SDK helpers --------------------------------------------------
|
|
115
|
+
function unwrap(res) {
|
|
116
|
+
if (res && typeof res === "object" && "data" in res && res.data &&
|
|
117
|
+
res.id === undefined && res.parts === undefined) return res.data
|
|
118
|
+
return res
|
|
119
|
+
}
|
|
120
|
+
function sessionId(created) {
|
|
121
|
+
const c = unwrap(created)
|
|
122
|
+
return c?.id ?? c?.sessionID ?? c?.session?.id
|
|
123
|
+
}
|
|
124
|
+
function partsText(parts) {
|
|
125
|
+
if (!Array.isArray(parts)) return ""
|
|
126
|
+
return parts
|
|
127
|
+
.filter((p) => p && (p.type === "text" || typeof p.text === "string"))
|
|
128
|
+
.map((p) => p.text || "")
|
|
129
|
+
.join("\n")
|
|
130
|
+
.trim()
|
|
131
|
+
}
|
|
132
|
+
async function lastAssistantText(client, sid) {
|
|
133
|
+
try {
|
|
134
|
+
const res = await client.session.messages({ path: { id: sid } })
|
|
135
|
+
const u = unwrap(res)
|
|
136
|
+
const list = Array.isArray(res) ? res : (Array.isArray(u) ? u : (u?.data ?? []))
|
|
137
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
138
|
+
const m = list[i]
|
|
139
|
+
const role = m?.info?.role ?? m?.role
|
|
140
|
+
if (role && role !== "assistant") continue
|
|
141
|
+
const t = partsText(m?.parts ?? m?.info?.parts ?? [])
|
|
142
|
+
if (t) return t
|
|
143
|
+
}
|
|
144
|
+
} catch (e) { dlog(`messages read failed: ${e?.message || e}`) }
|
|
145
|
+
return ""
|
|
146
|
+
}
|
|
147
|
+
function usageFrom(res) {
|
|
148
|
+
const r = unwrap(res)
|
|
149
|
+
const tok = r?.tokens ?? r?.info?.tokens ?? r?.assistant?.tokens
|
|
150
|
+
return { input: Number(tok?.input ?? 0) || 0, output: Number(tok?.output ?? 0) || 0 }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runChildSession(client, { prompt, agent, model, title }) {
|
|
154
|
+
if (!client?.session?.create || !client?.session?.prompt) {
|
|
155
|
+
throw new Error("OpenCode SDK session API unavailable")
|
|
156
|
+
}
|
|
157
|
+
let created
|
|
158
|
+
try { created = await client.session.create({ body: { title: title || "geneseed-workflow" } }) }
|
|
159
|
+
catch { created = await client.session.create() }
|
|
160
|
+
const sid = sessionId(created)
|
|
161
|
+
if (!sid) throw new Error("could not create child session")
|
|
162
|
+
try {
|
|
163
|
+
const body = { parts: [{ type: "text", text: prompt }] }
|
|
164
|
+
if (agent) body.agent = agent
|
|
165
|
+
if (model) body.model = model
|
|
166
|
+
let res = null
|
|
167
|
+
try { res = await client.session.prompt({ path: { id: sid }, body }) }
|
|
168
|
+
catch (e) { dlog(`prompt failed: ${e?.message || e}`) }
|
|
169
|
+
const r = unwrap(res)
|
|
170
|
+
let text = partsText(r?.parts ?? r?.info?.parts ?? [])
|
|
171
|
+
if (!text) text = await lastAssistantText(client, sid)
|
|
172
|
+
return { text, usage: usageFrom(res) }
|
|
173
|
+
} finally {
|
|
174
|
+
try { await client.session.delete?.({ path: { id: sid } }) } catch { /* best effort */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- the runtime ------------------------------------------------------------
|
|
179
|
+
export function createRuntime(ctx) {
|
|
180
|
+
const { client, directory, worktree, log: sink } = ctx
|
|
181
|
+
const args = ctx.args || {}
|
|
182
|
+
const cap = ctx.concurrency || concurrencyCap()
|
|
183
|
+
const limit = createLimiter(cap)
|
|
184
|
+
let agentCount = 0
|
|
185
|
+
let spent = 0
|
|
186
|
+
const budgetTotal =
|
|
187
|
+
(args && typeof args.budget === "number") ? args.budget
|
|
188
|
+
: (typeof ctx.budget === "number" ? ctx.budget : null)
|
|
189
|
+
const phases = []
|
|
190
|
+
const trace = []
|
|
191
|
+
const emit = (line) => { trace.push(line); if (typeof sink === "function") sink(line) }
|
|
192
|
+
|
|
193
|
+
// Budget meters OUTPUT tokens only (deliberate, per the v1 spec): output is what
|
|
194
|
+
// the workflow author controls via prompt design; input varies with repo size and
|
|
195
|
+
// would make the same script exhaust wildly different budgets across machines.
|
|
196
|
+
const budget = {
|
|
197
|
+
get total() { return budgetTotal },
|
|
198
|
+
spent: () => spent,
|
|
199
|
+
remaining: () => (budgetTotal == null ? Infinity : Math.max(0, budgetTotal - spent)),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function spawn(prompt, opts) {
|
|
203
|
+
return limit(() => runChildSession(client, {
|
|
204
|
+
prompt, agent: opts.agent, model: opts.model, title: `geneseed-wf:${opts.label}`,
|
|
205
|
+
}))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function agent(prompt, opts = {}) {
|
|
209
|
+
if (agentCount >= MAX_AGENTS) throw new Error(`workflow agent cap (${MAX_AGENTS}) exceeded`)
|
|
210
|
+
if (budgetTotal != null && spent >= budgetTotal) {
|
|
211
|
+
throw new Error(`workflow token budget (${budgetTotal}) exhausted`)
|
|
212
|
+
}
|
|
213
|
+
agentCount++
|
|
214
|
+
const label = opts.label || String(prompt).slice(0, 40)
|
|
215
|
+
emit(`· agent: ${label}`)
|
|
216
|
+
let out
|
|
217
|
+
try { out = await spawn(prompt, { ...opts, label }) }
|
|
218
|
+
catch (e) { emit(`✗ agent failed: ${label} — ${e?.message || e}`); return null }
|
|
219
|
+
spent += out.usage?.output || 0
|
|
220
|
+
if (!opts.schema) return out.text
|
|
221
|
+
|
|
222
|
+
let lastErr = "no JSON found in reply"
|
|
223
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
224
|
+
const parsed = extractJson(out.text)
|
|
225
|
+
if (parsed !== undefined) {
|
|
226
|
+
const v = validateSchema(opts.schema, parsed)
|
|
227
|
+
if (v.ok) return v.value
|
|
228
|
+
lastErr = v.error
|
|
229
|
+
}
|
|
230
|
+
if (attempt === 2) break
|
|
231
|
+
emit(`↻ retry ${label} (schema): ${lastErr}`)
|
|
232
|
+
try {
|
|
233
|
+
out = await spawn(
|
|
234
|
+
`${prompt}\n\nYour previous reply did not satisfy the required schema (${lastErr}). ` +
|
|
235
|
+
`Reply with ONLY a single valid JSON value — no prose, no code fence.`,
|
|
236
|
+
{ ...opts, label: `${label}:retry${attempt + 1}` },
|
|
237
|
+
)
|
|
238
|
+
spent += out.usage?.output || 0
|
|
239
|
+
} catch (e) { lastErr = String(e?.message || e); break }
|
|
240
|
+
}
|
|
241
|
+
emit(`✗ schema unmet: ${label} — ${lastErr}`)
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function phase(title) { phases.push(title); emit(`\n=== phase: ${title} ===`) }
|
|
246
|
+
function log(msg) { emit(` ${msg}`) }
|
|
247
|
+
|
|
248
|
+
// BARRIER fan-out: awaits all thunks. A throwing thunk resolves to null.
|
|
249
|
+
async function parallel(thunks) {
|
|
250
|
+
if (!Array.isArray(thunks)) throw new Error("parallel() needs an array of thunks")
|
|
251
|
+
if (thunks.length > MAX_ITEMS) throw new Error(`parallel() item cap (${MAX_ITEMS}) exceeded`)
|
|
252
|
+
return Promise.all(thunks.map((fn) =>
|
|
253
|
+
Promise.resolve().then(fn).catch((e) => { emit(`✗ parallel unit: ${e?.message || e}`); return null })))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// NO-BARRIER staged flow: each item runs all stages independently. A throwing
|
|
257
|
+
// stage drops that item to null and skips its remaining stages.
|
|
258
|
+
async function pipeline(items, ...stages) {
|
|
259
|
+
if (!Array.isArray(items)) throw new Error("pipeline() needs an array of items")
|
|
260
|
+
if (items.length > MAX_ITEMS) throw new Error(`pipeline() item cap (${MAX_ITEMS}) exceeded`)
|
|
261
|
+
return Promise.all(items.map(async (item, i) => {
|
|
262
|
+
let acc = item
|
|
263
|
+
for (let s = 0; s < stages.length; s++) {
|
|
264
|
+
try { acc = await stages[s](acc, item, i) }
|
|
265
|
+
catch (e) { emit(`✗ pipeline item ${i} stage ${s}: ${e?.message || e}`); return null }
|
|
266
|
+
}
|
|
267
|
+
return acc
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
agent, parallel, pipeline, phase, log, budget, args, directory, worktree,
|
|
273
|
+
_stats: () => ({ agentCount, spent, cap, phases: [...phases], trace: [...trace] }),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default createRuntime
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// council — the `council` skill as deterministic code.
|
|
2
|
+
//
|
|
3
|
+
// Seats the standing stance agents, gathers their steelmanned positions in parallel
|
|
4
|
+
// (each in its own isolated context, no groupthink), then a neutral chair synthesises
|
|
5
|
+
// a verdict and preserves the strongest dissent. The model-driven `council` skill asks
|
|
6
|
+
// the agent to convene seats in prose; this runs the same shape on rails.
|
|
7
|
+
//
|
|
8
|
+
// args: { motion?: string, question?: string, seats?: string[] }
|
|
9
|
+
// motion — the decision/change/claim under debate (required).
|
|
10
|
+
// seats — optional subset of seat roles to convene (default: the core six).
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
name: "council",
|
|
14
|
+
description: "Council debate as code: stance agents argue in parallel, a chair returns a verdict + preserved dissent.",
|
|
15
|
+
phases: [{ title: "Positions" }, { title: "Synthesis" }],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// [role, charter] — role doubles as the OpenCode subagent name (Geneseed emits these).
|
|
19
|
+
const SEATS = [
|
|
20
|
+
["advocate", "the strongest case FOR the motion — the prize, the cost of inaction"],
|
|
21
|
+
["skeptic", "devil's advocate — failure modes, hidden assumptions, why NOT"],
|
|
22
|
+
["pragmatist", "cost, effort, and complexity weighed against the payoff (YAGNI)"],
|
|
23
|
+
["steward", "long-term architecture, technical debt, and maintainability"],
|
|
24
|
+
["operator", "running it in production — reliability, on-call, operational cost"],
|
|
25
|
+
["empiricist", "every claim held to evidence; what would prove or disprove it"],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export default async function run(rt) {
|
|
29
|
+
const { agent, parallel, phase, log, args } = rt
|
|
30
|
+
const motion = args?.motion || args?.question
|
|
31
|
+
if (!motion) return { error: "pass args.motion (the decision, change, or claim to debate)" }
|
|
32
|
+
|
|
33
|
+
const wanted = Array.isArray(args?.seats) && args.seats.length ? args.seats : null
|
|
34
|
+
const seats = wanted ? SEATS.filter(([role]) => wanted.includes(role)) : SEATS
|
|
35
|
+
if (!seats.length) {
|
|
36
|
+
return { error: `no seats matched ${JSON.stringify(wanted)} — valid: ${SEATS.map(([r]) => r).join(", ")}` }
|
|
37
|
+
}
|
|
38
|
+
if (wanted && seats.length < wanted.length) {
|
|
39
|
+
log(`ignored unknown seat name(s): ${wanted.filter((w) => !SEATS.some(([r]) => r === w)).join(", ")}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
phase("Positions")
|
|
43
|
+
const briefs = (await parallel(seats.map(([role, charter]) => () =>
|
|
44
|
+
agent(
|
|
45
|
+
`You are the ${role} on a decision council. Motion: "${motion}".\n` +
|
|
46
|
+
`Argue ONLY this stance: ${charter}. Give a tight, steelmanned brief — ` +
|
|
47
|
+
`no hedging, no strawmen.`,
|
|
48
|
+
{ label: `seat:${role}`, phase: "Positions", agent: role },
|
|
49
|
+
).then((text) => ({ role, text }))))).filter((b) => b && b.text)
|
|
50
|
+
|
|
51
|
+
phase("Synthesis")
|
|
52
|
+
const dossier = briefs.map((b) => `### ${b.role}\n${b.text}`).join("\n\n")
|
|
53
|
+
const verdict = await agent(
|
|
54
|
+
`You are the neutral chair of a decision council. Motion: "${motion}".\n\n` +
|
|
55
|
+
`The seat briefs:\n\n${dossier}\n\n` +
|
|
56
|
+
`Synthesise in a neutral voice: state the verdict and its reasoning, record the ` +
|
|
57
|
+
`single strongest surviving dissent verbatim, list what would change the verdict, ` +
|
|
58
|
+
`and name one next action. The council advises — do not write code or push anything.`,
|
|
59
|
+
{ label: "chair", phase: "Synthesis" },
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
log(`debated "${motion}" across ${briefs.length} seat(s)`)
|
|
63
|
+
return { motion, seats: seats.map(([r]) => r), verdict, briefs }
|
|
64
|
+
}
|