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
package/js/migrate.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `geneseed migrate` — move a machine from the git-checkout install shape to the npm one.
|
|
3
|
+
*
|
|
4
|
+
* P10d, AND IT IS NOT A PORT. Every other module here reproduces a Python original byte for
|
|
5
|
+
* byte; this verb is new behaviour on both sides, written twice in the same change so that
|
|
6
|
+
* `tests/harness_golden.py` — a CROSS-IMPLEMENTATION gate — has a reference side at all. A
|
|
7
|
+
* Node-only verb would have nothing to compare against, and `test_every_entry_verb_is_a_real_
|
|
8
|
+
* harness_subcommand` plus `test_the_matrix_covers_every_verb_it_claims` would both fail it.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT IS NOT: `rebuild-all`. That was the first answer and it was checked before being
|
|
11
|
+
* discarded, because the re-emit really is the same engine and is reused rather than rewritten
|
|
12
|
+
* here. Four behaviours differ, and each is a cell:
|
|
13
|
+
*
|
|
14
|
+
* | an unknown emit marker | rebuild-all COERCES it to a default | migrate REFUSES |
|
|
15
|
+
* | a failing root | continues, exit 1 | ROLLS BACK all, exit 1 |
|
|
16
|
+
* | an already-done machine| re-emits everything | no-op, writes NOTHING |
|
|
17
|
+
* | stale autostart | not its subject | surveyed and REPORTED |
|
|
18
|
+
*
|
|
19
|
+
* Coercing an unrecognised marker is right for a rebuild and is *guessing what the user
|
|
20
|
+
* installed* for a migration. Continuing past a failure is right for a rebuild — one broken
|
|
21
|
+
* install must not block the rest — and for a migration it is the half-migrated machine the
|
|
22
|
+
* whole verb exists to prevent.
|
|
23
|
+
*
|
|
24
|
+
* THE TWO TRIGGERS, AND WHY IT TAKES BOTH. Work is needed when the machine's wiring is
|
|
25
|
+
* `legacy` (see `migrateShape` — and note that shapes 2 and 3 differ only inside the shim
|
|
26
|
+
* body, never in the settings file) OR when the stamp does not name this ROOT. Neither alone
|
|
27
|
+
* is enough: an OpenCode-only machine wires no hooks at all, so its shape is permanently
|
|
28
|
+
* `none` and a shape-only trigger would re-emit it on every run forever; and a user who
|
|
29
|
+
* reinstalls the old Python build flips the shape back to `legacy` while the stamp still
|
|
30
|
+
* matches, which must migrate again. Both inputs, and the stamp is written only on success.
|
|
31
|
+
*/
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { main as driverMain } from '../bin/geneseed.mjs';
|
|
36
|
+
import { ROOT } from './checkout.mjs';
|
|
37
|
+
import {
|
|
38
|
+
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, footprintOfDir, installState,
|
|
39
|
+
installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
|
|
40
|
+
} from './installs.mjs';
|
|
41
|
+
import { DEFAULT_EMIT, setupBuildArgs } from './generate.mjs';
|
|
42
|
+
import {
|
|
43
|
+
autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
|
|
44
|
+
} from './settings.mjs';
|
|
45
|
+
import { writeText, pyPrint, pyPrintErr } from './lib/fs.mjs';
|
|
46
|
+
|
|
47
|
+
/** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
|
|
48
|
+
const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The settings file a host wires hooks into, or null for a host that has none.
|
|
52
|
+
*
|
|
53
|
+
* Only Claude and Bob carry hook commands. OpenCode's hooks are a PLUGIN (`js/opencode.mjs`
|
|
54
|
+
* emits JS that runs in the host's own process and names neither the shim nor `harness.py`),
|
|
55
|
+
* and Copilot has no hook mechanism at all. So those two classify as `none` and their whole
|
|
56
|
+
* migration is the re-emit — which is the same re-emit, in the same change, on all three
|
|
57
|
+
* hosts, which is what the project's host-parity rule asks for.
|
|
58
|
+
*/
|
|
59
|
+
function hookSettingsFile(root, host, scope) {
|
|
60
|
+
if (host !== 'claude' && host !== 'bob') return null;
|
|
61
|
+
const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
|
|
62
|
+
return path.join(root, name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
|
|
66
|
+
export function hookCommandsOf(file) {
|
|
67
|
+
// `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
|
|
68
|
+
// comma is the one shape no machine in this project writes, and strict JSON rejects it —
|
|
69
|
+
// which would classify a wired install as `none` and make `migrate` skip the machine
|
|
70
|
+
// silently. `mergeClaudeSettings` already reads through comments for exactly this reason.
|
|
71
|
+
// Found by the JSONC corpus fixture; a generated fixture would have hidden it.
|
|
72
|
+
let loaded;
|
|
73
|
+
try { [loaded] = readJsonc(readFileSync(file, 'utf8')); } catch { return []; }
|
|
74
|
+
if (!loaded || typeof loaded !== 'object') return [];
|
|
75
|
+
const hooks = loaded.hooks;
|
|
76
|
+
if (!hooks || typeof hooks !== 'object') return [];
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const groups of Object.values(hooks)) {
|
|
79
|
+
if (!Array.isArray(groups)) continue;
|
|
80
|
+
for (const g of groups) {
|
|
81
|
+
for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
|
|
82
|
+
if (h && typeof h.command === 'string') out.push(h.command);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `<GENESEED_HOME>/.migrated` — the ROOT the last successful migration installed from. */
|
|
90
|
+
export function stampPath() { return path.join(shimHome(), '.migrated'); }
|
|
91
|
+
|
|
92
|
+
/** Where the pre-migration copies live while the re-emit runs. */
|
|
93
|
+
function stashDir() { return path.join(shimHome(), '.migrate-backup'); }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Step 1 — classify, WITHOUT touching anything.
|
|
97
|
+
*
|
|
98
|
+
* A refusal that happens here is free: nothing has been written, so "refuse and report" needs
|
|
99
|
+
* no rollback. That ordering is the cheapest atomicity there is and it is why the survey is a
|
|
100
|
+
* separate pass rather than a check inside the loop.
|
|
101
|
+
*/
|
|
102
|
+
export function migrateSurvey() {
|
|
103
|
+
const rows = [];
|
|
104
|
+
const commands = [];
|
|
105
|
+
const unrecognised = [];
|
|
106
|
+
for (const [host, scope, root] of installTargets()) {
|
|
107
|
+
if (installState(root, host, scope) !== 'active') continue;
|
|
108
|
+
const raw = readMaybe(path.join(root, '.geneseed-emit'));
|
|
109
|
+
const marker = raw === null ? '' : raw.trim();
|
|
110
|
+
// An unrecognised marker is a REFUSAL, not a default. `rebuild-all` coerces here.
|
|
111
|
+
if (marker && !KNOWN_EMITS.has(marker)) {
|
|
112
|
+
unrecognised.push({ host, scope, root, marker });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const file = hookSettingsFile(root, host, scope);
|
|
116
|
+
if (file) commands.push(...hookCommandsOf(file));
|
|
117
|
+
rows.push({ host, scope, root, marker, file });
|
|
118
|
+
}
|
|
119
|
+
let shimBody = '';
|
|
120
|
+
try { shimBody = readFileSync(hookShimPath(), 'utf8'); } catch { /* no shim yet */ }
|
|
121
|
+
const shape = migrateShape(commands, shimBody);
|
|
122
|
+
const stamped = (readMaybe(stampPath()) || '').trim();
|
|
123
|
+
// THE TRIGGER IS THE STAMP, AND THE SHAPE IS REPORTED — the first draft had it the other
|
|
124
|
+
// way round and the ref-vs-ref self-check killed it in one run. `shape === 'legacy'` cannot
|
|
125
|
+
// be a trigger, because what "current" MEANS is "the shim body names the .mjs entry", and
|
|
126
|
+
// the Python implementation bakes python + harness.py by design (`_hook_runner_entry`). So
|
|
127
|
+
// a migration run from the reference can never reach `current`, its second run would
|
|
128
|
+
// re-emit forever, and the two implementations would diverge on idempotence by
|
|
129
|
+
// construction. The stamp is a value both sides write and read identically.
|
|
130
|
+
return { rows, unrecognised, shape, stamped, needed: stamped !== ROOT };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Hand-written login entries that still name some other checkout. Reported, never rewritten. */
|
|
134
|
+
function autostartFindings() {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const p of autostartPaths()) {
|
|
137
|
+
const text = readMaybe(p);
|
|
138
|
+
if (text !== null && autostartStale(text, ROOT)) out.push(p);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A stash key that cannot collide across drives, and is a legal file name on both platforms. */
|
|
144
|
+
const stashKey = (p) => p.replace(/[\\/:]/g, '_');
|
|
145
|
+
|
|
146
|
+
export function cmdMigrate(args = {}) {
|
|
147
|
+
const dryRun = Boolean(args.dryRun);
|
|
148
|
+
const { rows, unrecognised, shape, needed } = migrateSurvey();
|
|
149
|
+
|
|
150
|
+
if (unrecognised.length) {
|
|
151
|
+
// Refuse the WHOLE run. A migration that skipped the row it did not understand and
|
|
152
|
+
// migrated the rest is the half-migrated machine this verb exists to prevent.
|
|
153
|
+
pyPrintErr('[migrate] REFUSED — unrecognised install marker(s):\n');
|
|
154
|
+
for (const u of unrecognised) {
|
|
155
|
+
pyPrintErr(`[migrate] ${u.host}:${u.scope} (${u.root}) has .geneseed-emit `
|
|
156
|
+
+ `'${u.marker}', which is not an emit this generator answers to.\n`);
|
|
157
|
+
}
|
|
158
|
+
pyPrintErr('[migrate] Nothing was changed. Fix or delete the marker, then re-run.\n');
|
|
159
|
+
return 3;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!rows.length) {
|
|
163
|
+
pyPrint('[migrate] no active installs detected.\n');
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!needed) {
|
|
168
|
+
pyPrint(`[migrate] already on the npm shape (${rows.length} install(s)); nothing to do.\n`);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pyPrint(`[migrate] ${rows.length} install(s), wiring shape: ${shape}\n`);
|
|
173
|
+
if (dryRun) {
|
|
174
|
+
for (const r of rows) {
|
|
175
|
+
pyPrint(`[migrate] would re-emit ${r.host}:${r.scope} (${r.root})\n`);
|
|
176
|
+
}
|
|
177
|
+
for (const p of autostartFindings()) {
|
|
178
|
+
pyPrint(`[migrate] would report the autostart entry at ${p}\n`);
|
|
179
|
+
}
|
|
180
|
+
pyPrint('[migrate] --dry-run: nothing was written.\n');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 2 — stash every file the re-emit can touch, so step 4 can put it all back.
|
|
185
|
+
const stash = stashDir();
|
|
186
|
+
const saved = [];
|
|
187
|
+
try {
|
|
188
|
+
rmSync(stash, { recursive: true, force: true });
|
|
189
|
+
mkdirSync(stash, { recursive: true });
|
|
190
|
+
const shim = hookShimPath();
|
|
191
|
+
for (const p of [...rows.map((r) => r.file).filter(Boolean), shim]) {
|
|
192
|
+
if (!existsSync(p)) continue;
|
|
193
|
+
const dest = path.join(stash, stashKey(p));
|
|
194
|
+
copyFileSync(p, dest);
|
|
195
|
+
saved.push([p, dest]);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
pyPrintErr(`[migrate] could not stage a backup (${e.message}); nothing was changed.\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const restore = () => {
|
|
203
|
+
for (const [orig, dest] of saved) {
|
|
204
|
+
try { copyFileSync(dest, orig); } catch { /* best effort — report below regardless */ }
|
|
205
|
+
}
|
|
206
|
+
try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Step 3 — re-emit, in each install's OWN five values. Same reads as `cmdRebuildAll`.
|
|
210
|
+
for (const r of rows) {
|
|
211
|
+
const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
|
|
212
|
+
? r.marker : '';
|
|
213
|
+
const emit = marker || DEFAULT_EMIT.get(`${r.host} ${r.scope}`) || 'opencode-global';
|
|
214
|
+
const theme = themeOfDir(r.root) || defaultTheme();
|
|
215
|
+
const out = r.scope === 'global' ? null : r.root;
|
|
216
|
+
const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
|
|
217
|
+
postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode());
|
|
218
|
+
const label = `${r.host}:${r.scope} (${r.root})`;
|
|
219
|
+
pyPrint(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
|
|
220
|
+
let rc = 1;
|
|
221
|
+
try { rc = driverMain(argv); } catch { rc = 1; }
|
|
222
|
+
if (rc !== 0) {
|
|
223
|
+
// Step 4 — ALL OR NOTHING. `rebuild-all` continues here on purpose; a migration
|
|
224
|
+
// must not, or the machine is left half on each shape.
|
|
225
|
+
pyPrintErr(`[migrate] FAILED ${label} (exit ${rc}) — rolling back.\n`);
|
|
226
|
+
restore();
|
|
227
|
+
pyPrintErr(`[migrate] rolled back ${saved.length} file(s); the install is unchanged.\n`);
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
|
|
233
|
+
try {
|
|
234
|
+
mkdirSync(path.dirname(stampPath()), { recursive: true });
|
|
235
|
+
writeText(stampPath(), `${ROOT}\n`);
|
|
236
|
+
} catch { /* an unwritable stamp costs a redundant re-emit next run, never correctness */ }
|
|
237
|
+
|
|
238
|
+
pyPrint(`[migrate] migrated ${rows.length} install(s).\n`);
|
|
239
|
+
|
|
240
|
+
// Step 5 — what could NOT be fixed automatically. Reported, never rewritten: nothing in
|
|
241
|
+
// this repository has ever written an autostart entry, so migrate does not own the file.
|
|
242
|
+
// The `start` ACTION is load-bearing, not decoration: bare `web` runs `serve()` in the
|
|
243
|
+
// FOREGROUND with `daemon=false`, which never writes the daemon record, so `web stop`,
|
|
244
|
+
// `web restart` and `web status` stay blind to the server this note just told someone to
|
|
245
|
+
// launch at every login. Derived, not trusted: `TheMigrateNoteAdvisesARealWebAction`.
|
|
246
|
+
for (const p of autostartFindings()) {
|
|
247
|
+
pyPrint(`[migrate] NOTE: the autostart entry at ${p} still names another checkout — `
|
|
248
|
+
+ 'update it by hand to run: geneseed web start --no-browser\n');
|
|
249
|
+
}
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
package/js/native.mjs
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-native layer, in Node — where RENDER stops being pure.
|
|
3
|
+
*
|
|
4
|
+
* A faithful translation of `_build_emit._write_native_layer` and the frontmatter
|
|
5
|
+
* builders it dispatches to. Capability specs become host-native subagents and skills:
|
|
6
|
+
* one dialect per host (OpenCode / Claude Code / Copilot), vendored skill folders copied
|
|
7
|
+
* through verbatim, authoring templates shipped flat.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS THE HARD HALF. `js/render.mjs` is a pure function of `src/` and `themes/`;
|
|
10
|
+
* this file is not, in three separate ways, and each one is a place a port can be subtly
|
|
11
|
+
* wrong while looking right:
|
|
12
|
+
*
|
|
13
|
+
* 1. The frontmatter depends on the USER's `agent-overrides.json`, read out of the emit
|
|
14
|
+
* target. So the output is a function of the machine it runs on.
|
|
15
|
+
* 2. Claim-on-create decides which files get written AT ALL: a target that already
|
|
16
|
+
* exists and was not in the previous manifest belongs to the user, and is skipped
|
|
17
|
+
* with a warning. So the output is a function of what is already on disk.
|
|
18
|
+
* 3. Three host dialects diverge inside one loop — including a FILENAME change
|
|
19
|
+
* (`<name>.agent.md`) that drags a link rewrite along with it.
|
|
20
|
+
*
|
|
21
|
+
* None of the three is reachable from the golden harness's happy path: golden emits into
|
|
22
|
+
* a fresh tree with an empty overrides stub. `tests/test_native_layer_parity.py` drives
|
|
23
|
+
* both implementations over the cells golden cannot construct, and byte-compares the
|
|
24
|
+
* written trees, the returned values AND the warning stream.
|
|
25
|
+
*
|
|
26
|
+
* NOTHING HERE MAY WRITE TO STDOUT. Python's warnings all go to stderr, and the emitted
|
|
27
|
+
* hook gates signal by printing JSON on stdout — a stray byte from a library on that path
|
|
28
|
+
* turns a blocking gate into a silently permissive one (see the P0 notes in the spec).
|
|
29
|
+
* Warnings go through `warn()` below, which is stderr, always.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { writeText, copyFile, jsonDumps, parseJson, pyStr, pyRepr, pyTruthy, readText }
|
|
34
|
+
from './lib/fs.mjs';
|
|
35
|
+
|
|
36
|
+
/** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
|
|
37
|
+
export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `PurePath(rel).parts` — split on EITHER separator, empties dropped.
|
|
41
|
+
*
|
|
42
|
+
* `'skills/x/'.split('/')` carries a trailing `''` that `Path` does not have, and the two
|
|
43
|
+
* vendored predicates below are `len(parts) >= 2` tests where that would matter.
|
|
44
|
+
*/
|
|
45
|
+
const pathParts = (rel) => rel.split(/[\\/]+/).filter((p) => p !== '');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `_build_render.is_vendored_path` — a BUNDLE-relative `skills/<vendored>/…`.
|
|
49
|
+
*
|
|
50
|
+
* `DIR_SKILLS` is always the neutral `skills`, so the second segment is the skill name.
|
|
51
|
+
*/
|
|
52
|
+
export function isVendoredPath(rel) {
|
|
53
|
+
const parts = pathParts(rel);
|
|
54
|
+
return parts.length >= 2 && parts[0] === 'skills' && VENDORED_SKILL_DIRS.has(parts[1]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `build._validate_is_vendored` — the same question, tolerant of `skills` at ANY depth.
|
|
59
|
+
*
|
|
60
|
+
* The per-repo native layers nest one level deeper than a `files`/opencode-global bundle
|
|
61
|
+
* (`.claude/skills/<name>/…`, `.bob/skills/<name>/…`, `.github/skills/<name>/…`), so
|
|
62
|
+
* `doctor`'s scan of those trees needs the loose form or every vendored folder's own
|
|
63
|
+
* upstream cross-links read as dead links. Never the last segment — `parts[:-1]`, because a
|
|
64
|
+
* FILE named `skills` is not a directory of them.
|
|
65
|
+
*/
|
|
66
|
+
export function validateIsVendored(rel) {
|
|
67
|
+
const parts = pathParts(rel);
|
|
68
|
+
return parts.slice(0, -1).some((p, i) => p === 'skills' && VENDORED_SKILL_DIRS.has(parts[i + 1]));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Mirrors `_build_emit.AGENT_COLORS` — the fallback when a theme carries no map. */
|
|
72
|
+
const AGENT_COLORS = {
|
|
73
|
+
architect: 'primary', reviewer: 'warning', tester: 'success',
|
|
74
|
+
docs: 'info', security: 'error', explorer: 'accent',
|
|
75
|
+
_default: 'secondary',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Mirrors `_build_emit._VALID_AGENT_COLOR_SLOTS` — OpenCode's named theme slots. */
|
|
79
|
+
const VALID_AGENT_COLOR_SLOTS = new Set(
|
|
80
|
+
['primary', 'secondary', 'accent', 'success', 'warning', 'error', 'info']);
|
|
81
|
+
|
|
82
|
+
/** `_build_emit._SIBLING_AGENT_LINK_RE` — a bare same-dir agent filename in a link. */
|
|
83
|
+
const SIBLING_AGENT_LINK_RE = /\]\(([A-Za-z0-9_-]+)\.md\)/g;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `_build_emit._strip_skill_body_links`'s pattern: every RELATIVE markdown link to a
|
|
87
|
+
* `.md` spec, keeping the link text. Python's `\s` and JS's differ on U+FEFF, which is
|
|
88
|
+
* not whitespace to Python and is to JS — unreachable inside a link target.
|
|
89
|
+
*/
|
|
90
|
+
const SKILL_BODY_LINK_RE = /\[([^\]]+)\]\((?!https?:\/\/|\/|#)[^)\s]*\.md(?:#[^)\s]*)?\)/g;
|
|
91
|
+
|
|
92
|
+
/** Every warning in this module. stderr, never stdout — see the module header. */
|
|
93
|
+
function warn(line) {
|
|
94
|
+
process.stderr.write(`${line}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `_build_render._first_blockquote` — the one-line purpose of a spec. */
|
|
98
|
+
export function firstBlockquote(text) {
|
|
99
|
+
// `splitlines()` also breaks on VT, FF, FS-GS-RS, NEL and the two Unicode separators;
|
|
100
|
+
// `split('\n')` does not. Measured: no `src/**.md` contains any of them, and readText
|
|
101
|
+
// has already folded CRLF and CR. Likewise `str.strip()` covers a few code points JS's
|
|
102
|
+
// `trim()` does not (and vice versa) — none of them occur either.
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const s = line.trim();
|
|
105
|
+
if (s.startsWith('>')) return s.replace(/^>+/, '').trim(); // `lstrip(">")`
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `_build_emit.desc_of`. */
|
|
111
|
+
export function descOf(text) {
|
|
112
|
+
return firstBlockquote(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `_build_render._HTML_COMMENT_RE`. */
|
|
116
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `_build_render._desc_block_problem` — "" when the spec's shape holds, else the reason.
|
|
120
|
+
*
|
|
121
|
+
* The guard `firstBlockquote` needs and cannot be: it returns the first `>` line ANYWHERE,
|
|
122
|
+
* so a spec that opens with prose and quotes something later gets that unrelated line as its
|
|
123
|
+
* OpenCode/Claude/Bob `description:`, with nothing anywhere reporting it. This asserts the
|
|
124
|
+
* shape every real spec has — title, then the purpose blockquote — after the authoring
|
|
125
|
+
* comment is stripped.
|
|
126
|
+
*
|
|
127
|
+
* `.replace` with a `g` regex, matching `re.sub`'s replace-all; the Python's pattern is
|
|
128
|
+
* non-global-looking but `re.sub` always is. `split('\n')` rather than `pySplitLines`: the
|
|
129
|
+
* text has been through `readText`, and the reason `firstBlockquote` gives for the same
|
|
130
|
+
* choice applies verbatim to the same files.
|
|
131
|
+
*/
|
|
132
|
+
export function descBlockProblem(text) {
|
|
133
|
+
const nonblank = text.replace(HTML_COMMENT_RE, '').split('\n').filter((ln) => ln.trim());
|
|
134
|
+
if (!nonblank.length) return 'file is empty (after stripping authoring comments)';
|
|
135
|
+
if (!nonblank[0].replace(/^\s+/, '').startsWith('#')) {
|
|
136
|
+
return `first content line is not a title ('# ...'): ${pyRepr(nonblank[0].trim())}`;
|
|
137
|
+
}
|
|
138
|
+
if (nonblank.length < 2) return 'has a title but no purpose blockquote after it';
|
|
139
|
+
const second = nonblank[1].trim();
|
|
140
|
+
if (!second.startsWith('>')) {
|
|
141
|
+
return `first block after the title is not a '>' blockquote: ${pyRepr(second)}`;
|
|
142
|
+
}
|
|
143
|
+
if (!second.replace(/^>+/, '').trim()) return 'purpose blockquote is empty';
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `_build_render._is_readonly`. */
|
|
148
|
+
function isReadonly(text) {
|
|
149
|
+
return text.includes('Read-only');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `_build_emit._strip_skill_body_links`. */
|
|
153
|
+
export function stripSkillBodyLinks(body) {
|
|
154
|
+
return body.replace(SKILL_BODY_LINK_RE, '$1');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `_build_emit._agent_color_map` — validated, always a valid OpenCode slot. */
|
|
158
|
+
function agentColorMap(theme) {
|
|
159
|
+
let raw = null;
|
|
160
|
+
if (isPlainObject(theme)) raw = theme.AGENT_COLORS;
|
|
161
|
+
if (!isPlainObject(raw)) raw = AGENT_COLORS;
|
|
162
|
+
const cleaned = {};
|
|
163
|
+
// Insertion order matters: it is the order the warnings below are printed in, and the
|
|
164
|
+
// parity gate compares the warning stream. Python dicts and JS objects agree on it for
|
|
165
|
+
// string keys that are not array indices — agent names never are.
|
|
166
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
167
|
+
if (VALID_AGENT_COLOR_SLOTS.has(v)) {
|
|
168
|
+
cleaned[k] = v;
|
|
169
|
+
} else {
|
|
170
|
+
warn(`[geneseed] WARN: AGENT_COLORS[${pyRepr(k)}] = ${pyRepr(v)} is not a valid `
|
|
171
|
+
+ `OpenCode theme slot (${[...VALID_AGENT_COLOR_SLOTS].sort().join(', ')}) — `
|
|
172
|
+
+ `falling back to 'secondary'`);
|
|
173
|
+
cleaned[k] = 'secondary';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!Object.prototype.hasOwnProperty.call(cleaned, '_default')) cleaned._default = 'secondary';
|
|
177
|
+
return cleaned;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `_build_emit._agent_color`. */
|
|
181
|
+
function agentColor(stem, theme) {
|
|
182
|
+
const colors = agentColorMap(theme);
|
|
183
|
+
return Object.prototype.hasOwnProperty.call(colors, stem) ? colors[stem] : colors._default;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isPlainObject(v) {
|
|
187
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `is not None` — the test that lets a configured `temperature: 0` survive. */
|
|
191
|
+
function given(v) {
|
|
192
|
+
return v !== undefined && v !== null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `overrides.get(stem) or {}`.
|
|
197
|
+
*
|
|
198
|
+
* The `hasOwnProperty` guard is not ceremony: a bare `overrides[stem]` reaches
|
|
199
|
+
* `Object.prototype` for an agent named `constructor`, `toString` or `valueOf`, and would
|
|
200
|
+
* hand back a function where Python hands back `None`.
|
|
201
|
+
*
|
|
202
|
+
* Known divergence, recorded rather than papered over: where the value is present but is
|
|
203
|
+
* NOT an object (a user writing `"reviewer": "haiku"` instead of
|
|
204
|
+
* `"reviewer": {"model": "haiku"}`), Python raises AttributeError and aborts the emit
|
|
205
|
+
* while this returns undefined for every key and emits no override lines. Not a byte
|
|
206
|
+
* difference on any path the gate covers — a difference in which malformed input crashes.
|
|
207
|
+
*/
|
|
208
|
+
function agentOverride(overrides, stem) {
|
|
209
|
+
return (Object.prototype.hasOwnProperty.call(overrides, stem) && overrides[stem]) || {};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The four optional per-agent override lines.
|
|
214
|
+
*
|
|
215
|
+
* Emitted ONLY when configured, so an empty `agent-overrides.json` is zero change. The
|
|
216
|
+
* `model`/`variant` tests are truthiness and the `temperature`/`steps` tests are
|
|
217
|
+
* `is not None` — a configured `temperature: 0` must survive, an empty model must not.
|
|
218
|
+
*
|
|
219
|
+
* Python spells these four ifs out twice, in `_opencode_agent_frontmatter` and in
|
|
220
|
+
* `_write_primary_agent`. One owner here rather than a second copy: the two Python
|
|
221
|
+
* bodies are identical today, and the parity gate drives BOTH call sites, so if they
|
|
222
|
+
* ever diverge the gate says so instead of the copies silently rotting apart.
|
|
223
|
+
*/
|
|
224
|
+
export function pushOverrideLines(fm, ov) {
|
|
225
|
+
if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
|
|
226
|
+
if (given(ov.temperature)) fm.push(`temperature: ${pyStr(ov.temperature)}`);
|
|
227
|
+
if (pyTruthy(ov.variant)) fm.push(`variant: ${pyStr(ov.variant)}`);
|
|
228
|
+
if (given(ov.steps)) fm.push(`steps: ${pyStr(ov.steps)}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `_build_emit._claude_agent_frontmatter`. */
|
|
232
|
+
function claudeAgentFrontmatter(stem, text, overrides) {
|
|
233
|
+
const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
|
|
234
|
+
const ov = agentOverride(overrides, stem);
|
|
235
|
+
if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
|
|
236
|
+
if (isReadonly(text)) {
|
|
237
|
+
const denied = ['Write', 'Edit', 'NotebookEdit', 'WebFetch'];
|
|
238
|
+
if (!text.includes('<!-- bash: allow -->')) denied.push('Bash');
|
|
239
|
+
fm.push(`disallowedTools: ${denied.join(', ')}`);
|
|
240
|
+
}
|
|
241
|
+
return fm;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `_build_emit._copilot_agent_frontmatter` — `tools:` is an ALLOWLIST, not a denylist. */
|
|
245
|
+
function copilotAgentFrontmatter(stem, text, overrides) {
|
|
246
|
+
const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
|
|
247
|
+
const ov = agentOverride(overrides, stem);
|
|
248
|
+
if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
|
|
249
|
+
if (isReadonly(text)) {
|
|
250
|
+
const allowed = ['read', 'search', 'todo', 'agent'];
|
|
251
|
+
if (text.includes('<!-- bash: allow -->')) allowed.push('execute');
|
|
252
|
+
fm.push(`tools: [${allowed.join(', ')}]`);
|
|
253
|
+
}
|
|
254
|
+
return fm;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `_build_emit._opencode_agent_frontmatter`. */
|
|
258
|
+
function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
259
|
+
const fm = [`description: ${jsonDumps(descOf(text))}`, 'mode: subagent'];
|
|
260
|
+
fm.push(`color: ${agentColor(stem, theme)}`);
|
|
261
|
+
pushOverrideLines(fm, agentOverride(overrides, stem));
|
|
262
|
+
if (isReadonly(text)) {
|
|
263
|
+
fm.push('permission:', ' edit: deny', ' webfetch: deny');
|
|
264
|
+
if (text.includes('<!-- bash: allow -->')) fm.push(' bash:', ' "*": ask');
|
|
265
|
+
else fm.push(' bash: deny');
|
|
266
|
+
}
|
|
267
|
+
return fm;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `_build_emit._load_agent_overrides`. */
|
|
271
|
+
export function loadAgentOverrides(base) {
|
|
272
|
+
let data;
|
|
273
|
+
try {
|
|
274
|
+
// parseJson, not JSON.parse: the override values are rendered with `pyStr`, which
|
|
275
|
+
// needs the int/float distinction the raw literal carries and `JSON.parse` discards.
|
|
276
|
+
data = parseJson(readText(path.join(base, 'agent-overrides.json')));
|
|
277
|
+
} catch {
|
|
278
|
+
// Missing or malformed => no overrides, so agents inherit the host model.
|
|
279
|
+
// Wider than Python's `except (OSError, json.JSONDecodeError)` by one case: a file
|
|
280
|
+
// that is not valid UTF-8 raises UnicodeDecodeError there and aborts the emit, where
|
|
281
|
+
// Node's 'utf8' decode substitutes U+FFFD and this degrades to `{}`. Recorded rather
|
|
282
|
+
// than reproduced — matching it means detecting a decode error Node does not report.
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
const agents = isPlainObject(data) ? data.agents : null;
|
|
286
|
+
return isPlainObject(agents) ? agents : {};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `Path.relative_to(base).as_posix()`. */
|
|
290
|
+
function relPosix(base, target) {
|
|
291
|
+
return path.relative(base, target).split(path.sep).join('/');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `_build_emit._write_native_layer`.
|
|
296
|
+
*
|
|
297
|
+
* `items` are `render_all`'s, as `{ rel, text, src }` — `text` null for a binary that is
|
|
298
|
+
* copied rather than rendered. `src` is the absolute source path; `opts.src` is the
|
|
299
|
+
* source root it is relativised against (Python reads `_build_core.SRC` directly, which
|
|
300
|
+
* is the module-level single owner; here it travels explicitly, as in `js/render.mjs`).
|
|
301
|
+
*
|
|
302
|
+
* Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
|
|
303
|
+
* paths in write order.
|
|
304
|
+
*
|
|
305
|
+
* That order is NOT observable, and this comment used to claim it was ("it lands in
|
|
306
|
+
* `owned`, which the prune diffs against"). Measured when a mutation reversing it stayed
|
|
307
|
+
* green: all three manifest writers spell `"owned": sorted(owned)` and every prune is
|
|
308
|
+
* `set(old_owned) - set(owned)`, so the sequence is erased on both sides. Write order is
|
|
309
|
+
* kept because it mirrors the Python and costs nothing, not because anything reads it —
|
|
310
|
+
* the same distinction `themed_rel` earned, and the reason the mutation is recorded here
|
|
311
|
+
* rather than papered over with a cell that cannot exist.
|
|
312
|
+
*/
|
|
313
|
+
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
314
|
+
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
315
|
+
theme = null, src,
|
|
316
|
+
} = {}) {
|
|
317
|
+
const ov = overrides || {};
|
|
318
|
+
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
319
|
+
let nAgents = 0;
|
|
320
|
+
let nSkills = 0;
|
|
321
|
+
const written = [];
|
|
322
|
+
let headerPrinted = false;
|
|
323
|
+
|
|
324
|
+
// True -> ok to (over)write; false -> a pre-existing file we never owned, so it is the
|
|
325
|
+
// user's: leave it, warn, and keep it out of the manifest.
|
|
326
|
+
const claim = (dest) => {
|
|
327
|
+
if (oldSet === null || cfg === null || !existsSync(dest)) return true;
|
|
328
|
+
const rel = relPosix(cfg, dest);
|
|
329
|
+
if (oldSet.has(rel)) return true;
|
|
330
|
+
if (!manifestExisted && !headerPrinted) {
|
|
331
|
+
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
332
|
+
+ 'are treated as yours');
|
|
333
|
+
headerPrinted = true;
|
|
334
|
+
}
|
|
335
|
+
warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
|
|
336
|
+
+ 'clobbering it');
|
|
337
|
+
return false;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const write = (dest, text) => {
|
|
341
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
342
|
+
writeText(dest, text);
|
|
343
|
+
written.push(dest);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
for (const { text, src: source } of items) {
|
|
347
|
+
const sparts = relPosix(src, source).split('/');
|
|
348
|
+
|
|
349
|
+
// Vendored third-party skill folders ride along verbatim, preserving their own
|
|
350
|
+
// multi-file layout — copied through, NOT wrapped as a native SKILL.md, and never
|
|
351
|
+
// counted as harness skills.
|
|
352
|
+
// `isVendoredPath`, not a fourth copy of the same three clauses: P5g gave the predicate a
|
|
353
|
+
// second caller (`doctor`'s bundle scan) and the byte gate is what licenses the merge.
|
|
354
|
+
if (isVendoredPath(relPosix(src, source))) {
|
|
355
|
+
const dest = path.join(skillsDir, ...sparts.slice(1));
|
|
356
|
+
if (!claim(dest)) continue;
|
|
357
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
358
|
+
// The same vendored folder mixes both writers: a rendered `.md` goes through
|
|
359
|
+
// writeText (and picks up CRLF on Windows), a binary through copy2 (and keeps LF).
|
|
360
|
+
if (text !== null) writeText(dest, text);
|
|
361
|
+
else copyFile(source, dest);
|
|
362
|
+
written.push(dest);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (text === null) continue;
|
|
366
|
+
if (sparts.length !== 2 || !sparts[1].endsWith('.md')) continue;
|
|
367
|
+
const [folder, fname] = sparts;
|
|
368
|
+
const targetDir = { agents: agentsDir, skills: skillsDir }[folder];
|
|
369
|
+
if (targetDir === undefined) continue;
|
|
370
|
+
|
|
371
|
+
if (fname.startsWith('_')) {
|
|
372
|
+
// Authoring templates are shipped verbatim and FLAT — not wrapped as a native
|
|
373
|
+
// skill — so an author following the `_template.md` note has the scaffold on disk.
|
|
374
|
+
const dest = path.join(targetDir, fname);
|
|
375
|
+
if (!claim(dest)) continue;
|
|
376
|
+
write(dest, lstripNewlines(text));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const stem = fname.slice(0, -3);
|
|
381
|
+
let body = lstripNewlines(text);
|
|
382
|
+
let fm;
|
|
383
|
+
let dest;
|
|
384
|
+
let kind;
|
|
385
|
+
if (folder === 'agents') {
|
|
386
|
+
if (host === 'claude') {
|
|
387
|
+
fm = claudeAgentFrontmatter(stem, text, ov);
|
|
388
|
+
dest = path.join(agentsDir, `${stem}.md`);
|
|
389
|
+
} else if (host === 'copilot') {
|
|
390
|
+
fm = copilotAgentFrontmatter(stem, text, ov);
|
|
391
|
+
dest = path.join(agentsDir, `${stem}.agent.md`);
|
|
392
|
+
// The filename change drags the links with it: a sibling `](skeptic.md)` would
|
|
393
|
+
// point at a file this dialect never writes.
|
|
394
|
+
body = body.replace(SIBLING_AGENT_LINK_RE, ']($1.agent.md)');
|
|
395
|
+
} else {
|
|
396
|
+
fm = opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
397
|
+
dest = path.join(agentsDir, `${stem}.md`);
|
|
398
|
+
}
|
|
399
|
+
kind = 'agent';
|
|
400
|
+
} else {
|
|
401
|
+
// Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
|
|
402
|
+
fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
|
|
403
|
+
body = stripSkillBodyLinks(body);
|
|
404
|
+
dest = path.join(skillsDir, stem, 'SKILL.md');
|
|
405
|
+
kind = 'skill';
|
|
406
|
+
}
|
|
407
|
+
if (!claim(dest)) continue;
|
|
408
|
+
write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
|
|
409
|
+
if (kind === 'agent') nAgents += 1;
|
|
410
|
+
else nSkills += 1;
|
|
411
|
+
}
|
|
412
|
+
return { nAgents, nSkills, written };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `str.lstrip("\n")` — only newlines, not all whitespace.
|
|
417
|
+
*
|
|
418
|
+
* Currently a no-op on every input the generator produces: measured, no file under `src/`
|
|
419
|
+
* and no rendered spec in any of the 14 themes begins with a newline. Mutating it away
|
|
420
|
+
* leaves the parity gate green, and that is correct rather than a hole — there is no
|
|
421
|
+
* reachable behaviour to detect. Kept because it is what the Python does, and a spec
|
|
422
|
+
* author adding a leading blank line is one commit away from making it matter.
|
|
423
|
+
*/
|
|
424
|
+
function lstripNewlines(text) {
|
|
425
|
+
return text.replace(/^\n+/, '');
|
|
426
|
+
}
|