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/emit.mjs
ADDED
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emit half, in Node — and the process boundary Python drives it across.
|
|
3
|
+
*
|
|
4
|
+
* `js/render.mjs` renders, `js/native.mjs` and `js/opencode.mjs` write the host-native
|
|
5
|
+
* layers, `js/settings.mjs` edits the files the user co-owns; this module is the piece that
|
|
6
|
+
* turns them into an install on disk and the CLI that a `build.py` emit spawns. It is a
|
|
7
|
+
* translation of `_build_render.build` plus BOTH halves of `_build_emit.emit_opencode`,
|
|
8
|
+
* `_build_global.emit_opencode_global` and `_build_global._emit_claude_core` — the last of
|
|
9
|
+
* which is the shared engine behind six emits, so the four job kinds below cover all NINE.
|
|
10
|
+
*
|
|
11
|
+
* "ALL NINE" IS A MEASUREMENT HERE, NOT A CLAIM. It was a claim for two phases and it was
|
|
12
|
+
* false: `emit_opencode_global` spawned Node zero times while four documents said it did,
|
|
13
|
+
* because it was the one mode with no boundary cell able to contradict them.
|
|
14
|
+
* `tests/test_seam_coverage.py` now counts `run_node` invocations per mode and pins the
|
|
15
|
+
* table, and it refuses a mode that crosses without a cell in `tests/test_emit_boundary.py`
|
|
16
|
+
* — so the sentence above cannot come back without the observation behind it.
|
|
17
|
+
*
|
|
18
|
+
* THE HANDOFF THIS MODULE WAS SHAPED BY. Python drove, one spawn per emit:
|
|
19
|
+
*
|
|
20
|
+
* the Python driver, one emit
|
|
21
|
+
* +- spawn node js/emit.mjs <job.json>
|
|
22
|
+
* | RENDER Node writes every file Geneseed owns wholesale
|
|
23
|
+
* | WIRE Node merges the CLAUDE.md managed block, settings(.local).json
|
|
24
|
+
* | and opencode.json — the files the USER co-owns
|
|
25
|
+
* | Node returns { owned, stats, managed, stdout, stderr } as JSON on stdout
|
|
26
|
+
* +- Python PRUNE old_owned - owned
|
|
27
|
+
* +- Python MANIFEST owned + managed
|
|
28
|
+
* +- Python VERIFY _settings_integrity_check
|
|
29
|
+
*
|
|
30
|
+
* WIRE joined the child at P3b and VERIFY deliberately did not. VERIFY runs after MANIFEST
|
|
31
|
+
* and MANIFEST is Python, so with one spawn per emit there is no child left to run it in —
|
|
32
|
+
* and that is worth having rather than merely tolerable: `_settings_integrity_check` never
|
|
33
|
+
* writes, so every Claude-shaped emit now ends with PYTHON re-reading the settings file
|
|
34
|
+
* NODE just wrote and checking it against the claims NODE just returned. Two
|
|
35
|
+
* implementations of the wiring layer have to interoperate on a real file, on every build.
|
|
36
|
+
*
|
|
37
|
+
* `hookOpts` is the one job field this module cannot compute and must not default. It
|
|
38
|
+
* carries the interpreter and entry point the hook shim bakes, and the child's own
|
|
39
|
+
* `process.execPath` is *node* while the hooks it is wiring are still Python. Left
|
|
40
|
+
* undefined, `hookShimBody` would write `"undefined" "undefined" %*` into the shim and every
|
|
41
|
+
* hook in the install would be dead — so `hookPrefix` throws instead of defaulting.
|
|
42
|
+
*
|
|
43
|
+
* Python still drives because the runtime *is* Python and calls `build.emit_*` in-process
|
|
44
|
+
* from doctor, web deploy, setup and rebuild-all; Node-as-driver would wrap each of those
|
|
45
|
+
* in a subprocess. The seam is designed to collapse: the driver becomes `geneseed.mjs`, the
|
|
46
|
+
* spawn becomes an import, and this job object becomes the function signature.
|
|
47
|
+
*
|
|
48
|
+
* STDOUT CARRIES THE PROTOCOL AND NOTHING ELSE, STRUCTURALLY.
|
|
49
|
+
*
|
|
50
|
+
* The generator prints progress to stdout, and so does this process's protocol — the same
|
|
51
|
+
* stream. Rather than ask every future caller to remember which is which, `main` REPLACES
|
|
52
|
+
* `process.stdout.write` and `process.stderr.write` with buffers for the whole run and
|
|
53
|
+
* restores them only to emit the single JSON document. So:
|
|
54
|
+
*
|
|
55
|
+
* - a stray `console.log` anywhere in the render tree cannot corrupt the protocol; it
|
|
56
|
+
* lands in `payload.stdout`, Python re-prints it, and the byte comparison against the
|
|
57
|
+
* Python generator FAILS. Loud, not silent.
|
|
58
|
+
* - Python re-emits both buffers through its own `print`, so the emitted bytes carry
|
|
59
|
+
* Python's encoding behaviour (including its cp1252 failure modes) unchanged. The
|
|
60
|
+
* alternative — letting Node inherit the streams — would have written UTF-8 where
|
|
61
|
+
* Python writes the console's locale encoding, a divergence on exactly the machines
|
|
62
|
+
* least able to report it.
|
|
63
|
+
*
|
|
64
|
+
* This matters beyond tidiness because the same modules reach hook paths at P3, where the
|
|
65
|
+
* emitted git-gate and rule-gate signal their verdict as JSON on stdout and return 0 on
|
|
66
|
+
* every path: one stray byte there turns a blocking gate into a silently permissive one.
|
|
67
|
+
*/
|
|
68
|
+
import {
|
|
69
|
+
existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync,
|
|
70
|
+
} from 'node:fs';
|
|
71
|
+
import { createHash } from 'node:crypto';
|
|
72
|
+
import { pathToFileURL } from 'node:url';
|
|
73
|
+
import path from 'node:path';
|
|
74
|
+
import { destRel, renderAll, renderFile, SRC_DIR_TOKENS } from './render.mjs';
|
|
75
|
+
import { writeNativeLayer, loadAgentOverrides } from './native.mjs';
|
|
76
|
+
import {
|
|
77
|
+
ensureAgentOverridesStub, writePrimaryAgent, writeCommandLayer, writePonytailCommand,
|
|
78
|
+
writeTheme, writeColorThemes, copyPlugins, copyWorkflows, sourceReleaseVersion,
|
|
79
|
+
} from './opencode.mjs';
|
|
80
|
+
import {
|
|
81
|
+
mergeOpencodeJson, mergeClaudeSettings, wireClaudeExcludes, unwireClaudeExcludes,
|
|
82
|
+
unwireClaudeSettings, managedBlockWrite, managedBlockRemove,
|
|
83
|
+
} from './settings.mjs';
|
|
84
|
+
import { VERSION_MARKER } from './hosts.mjs';
|
|
85
|
+
import {
|
|
86
|
+
writeText, readText, copyFile, jsonDumpsIndent, parseJson, pyAscii, pyTruthy,
|
|
87
|
+
} from './lib/fs.mjs';
|
|
88
|
+
/** `_build_render.SRC_DIRS_MARKER`. */
|
|
89
|
+
const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
|
|
90
|
+
/** `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run. */
|
|
91
|
+
const OWNED_SRC_DIRS = ['laws', 'agents', 'skills'];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
|
|
95
|
+
*
|
|
96
|
+
* `cfg.capabilityLinkRe` overrides it and the driver always supplies one, for the same
|
|
97
|
+
* reason `cfg.structure` exists: the constant is in `_OWNED` because a doctor test
|
|
98
|
+
* redirects it to the pre-fix form and asserts the emit then renders dead links, and a
|
|
99
|
+
* redirect that stops at the process boundary half-works silently. The literal stays as
|
|
100
|
+
* the fallback for the parity harnesses, which drive this module in-process.
|
|
101
|
+
*/
|
|
102
|
+
const CAPABILITY_LINK_RE =
|
|
103
|
+
/\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
|
|
104
|
+
|
|
105
|
+
/** `_build_render._TMPL_SPEC_RE`. */
|
|
106
|
+
const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
|
|
110
|
+
// something the user owns. Duplicated from `_build_render.py` rather than read from a
|
|
111
|
+
// shared file: the parity gate drives both sides, so drift surfaces as a failing cell
|
|
112
|
+
// instead of as two copies rotting apart, and a shared file would be a third format to
|
|
113
|
+
// keep in agreement. Backticks are escaped; a raw one would end the literal.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** `_build_render.CONTEXT_STUB`. */
|
|
117
|
+
const CONTEXT_STUB = {
|
|
118
|
+
_comment: "Point the agent at this project's own documentation. Each entry: 'path' "
|
|
119
|
+
+ "(absolute, or relative to the repo root), 'load' ('eager' = read every "
|
|
120
|
+
+ "session for small always-relevant rules; 'lazy' = read only when the task "
|
|
121
|
+
+ "needs it), and 'description'. This file is host-specific — git-ignore it. "
|
|
122
|
+
+ 'The build creates it once, empty, and never overwrites it.',
|
|
123
|
+
context: [],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** `_build_render.WIKI_STUB`. */
|
|
127
|
+
const WIKI_STUB = `\
|
|
128
|
+
// Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
|
|
129
|
+
// an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
|
|
130
|
+
// (JSONC). It is host-specific — never commit it. The build created it once, empty,
|
|
131
|
+
// and will never overwrite it.
|
|
132
|
+
//
|
|
133
|
+
// Each wiki carries:
|
|
134
|
+
// name a short label
|
|
135
|
+
// path absolute root of the vault (use forward slashes, also on Windows)
|
|
136
|
+
// description one line shown to the agent
|
|
137
|
+
// entries notes OR folders to load: path relative to the vault root ("." =
|
|
138
|
+
// the whole vault); load "eager" = read every session, "lazy" =
|
|
139
|
+
// read on demand; a folder applies its mode to every note beneath
|
|
140
|
+
// it, a file entry overrides its folder, "exclude" prunes
|
|
141
|
+
// conventions the vault's authoring-rules note — read before the first write
|
|
142
|
+
// inbox drop folder for notes the agent cannot confidently file
|
|
143
|
+
// protected folders the agent must never write to (guard-enforced on OpenCode)
|
|
144
|
+
//
|
|
145
|
+
// Example — copy this object into the "wikis" array below and edit:
|
|
146
|
+
// {
|
|
147
|
+
// "name": "Brain",
|
|
148
|
+
// "path": "C:/Users/me/Documents/Brain",
|
|
149
|
+
// "description": "my machine-wide knowledge base",
|
|
150
|
+
// "entries": [
|
|
151
|
+
// { "path": "ARCHITECTURE.md", "load": "eager", "description": "the root map" },
|
|
152
|
+
// { "path": ".", "load": "lazy" }
|
|
153
|
+
// ],
|
|
154
|
+
// "conventions": "STYLE.md",
|
|
155
|
+
// "inbox": "Inbox/",
|
|
156
|
+
// "protected": ["Journal/"]
|
|
157
|
+
// }
|
|
158
|
+
{
|
|
159
|
+
"wikis": []
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
/** `_build_render.RULES_FILE`. */
|
|
164
|
+
const RULES_FILE = 'user-rules.md';
|
|
165
|
+
|
|
166
|
+
/** `_build_render.RULES_STUB`. */
|
|
167
|
+
const RULES_STUB = `\
|
|
168
|
+
# User rules
|
|
169
|
+
|
|
170
|
+
Your own standing rules. The agent obeys every rule in this file exactly as it
|
|
171
|
+
obeys the laws in AGENT.md §1 — always in force, in every task. A user rule may
|
|
172
|
+
*tighten* a law, never repeal one: where they conflict, the law wins.
|
|
173
|
+
|
|
174
|
+
Geneseed seeded this file once and will never overwrite it. The laws file is
|
|
175
|
+
regenerated on every update — never edit that one; this file is where your own
|
|
176
|
+
governance lives, and it survives updates, reinstalls, and theme switches.
|
|
177
|
+
Unlike \`context.json\`, it is safe to commit: project rules are meant to travel
|
|
178
|
+
with the repo and bind the whole team.
|
|
179
|
+
|
|
180
|
+
Keep the set small — every rule here is loaded every session, and a bloated
|
|
181
|
+
rule set dilutes the rules that matter. A durable fact belongs in memory, a
|
|
182
|
+
pointer to documentation belongs in \`context.json\`; only a standing *behaviour*
|
|
183
|
+
belongs here.
|
|
184
|
+
|
|
185
|
+
Format — one rule per \`## R<n> — Title\` heading, an optional metadata line in
|
|
186
|
+
parentheses, then the rule stated plainly:
|
|
187
|
+
|
|
188
|
+
## R1 — No emoji in commit subjects
|
|
189
|
+
(scope: project | source: written by hand)
|
|
190
|
+
Commit subjects are plain text; no emoji, no decorative unicode.
|
|
191
|
+
|
|
192
|
+
\`trial until: YYYY-MM-DD\` in the metadata line marks a rule on probation —
|
|
193
|
+
usually one promoted from a recurring memory. Review it by that date, then
|
|
194
|
+
graduate it (remove the marker) or demote it back to memory.
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
/** `_build_render.EXCLUDES_FILE`. */
|
|
198
|
+
export const EXCLUDES_FILE = 'excludes.json';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
|
|
202
|
+
*
|
|
203
|
+
* Exported since P5c because `js/excludes.mjs` reads it as the single source of the shape a
|
|
204
|
+
* missing or corrupt `excludes.json` degrades to, exactly as `_harness_exclude._read_excludes`
|
|
205
|
+
* reads `build.EXCLUDES_STUB` rather than spelling `{"excludes": []}` a second time.
|
|
206
|
+
*/
|
|
207
|
+
export const EXCLUDES_STUB = `\
|
|
208
|
+
{
|
|
209
|
+
"_comment": "Folders where this global Geneseed install goes dormant (hooks silent, preamble suppressed). Managed by \`harness exclude add|remove|list\`; safe to edit by hand. Paths are absolute.",
|
|
210
|
+
"excludes": []
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `_build_global._BOB_RULES_STUB` — the workspace shadow stub a PROJECT Bob emit ships.
|
|
216
|
+
*
|
|
217
|
+
* Exported since P5c: `harness exclude add` writes the SAME stub into an excluded repo, and
|
|
218
|
+
* decides ownership on the next `remove` by comparing the file's content to it byte for byte
|
|
219
|
+
* — so a second spelling here would orphan every stub the other writer created.
|
|
220
|
+
*/
|
|
221
|
+
export const BOB_RULES_STUB = `\
|
|
222
|
+
<!-- geneseed: workspace shadow stub -->
|
|
223
|
+
This project's Geneseed instructions are the repo-root \`AGENTS.md\`, which Bob
|
|
224
|
+
auto-loads. This file exists only to shadow the same-named global Geneseed rules
|
|
225
|
+
file (\`~/.bob/rules/geneseed.md\`) so the global preamble does not stack on top of
|
|
226
|
+
the project's own. Follow the root \`AGENTS.md\`.
|
|
227
|
+
`;
|
|
228
|
+
|
|
229
|
+
/** `_build_render.PROFILE_FILE`. */
|
|
230
|
+
const PROFILE_FILE = 'PROFILE.md';
|
|
231
|
+
|
|
232
|
+
/** `_build_render.PROFILE_STUB`. */
|
|
233
|
+
const PROFILE_STUB = `\
|
|
234
|
+
# Your profile
|
|
235
|
+
|
|
236
|
+
Who you are and how you like to work — so the agent can meet you where you are
|
|
237
|
+
instead of guessing. Every section is optional; delete what you don't want to
|
|
238
|
+
share, add what you do. Geneseed seeded this file once and will never overwrite
|
|
239
|
+
it, so it survives updates, reinstalls, and theme switches.
|
|
240
|
+
|
|
241
|
+
This is *identity, not rules*. A standing rule the agent must obey belongs in
|
|
242
|
+
\`user-rules.md\` (AGENT.md §1); this file only colours how the agent works — tone,
|
|
243
|
+
depth, defaults. Where the two ever seem to conflict, the rule wins: precedence
|
|
244
|
+
is laws, then user-rules, then this profile.
|
|
245
|
+
|
|
246
|
+
## Who I am
|
|
247
|
+
|
|
248
|
+
Role, domains you know deeply, domains you're learning. What you're usually here
|
|
249
|
+
to do.
|
|
250
|
+
|
|
251
|
+
## How I work
|
|
252
|
+
|
|
253
|
+
Habits, tools, and environment worth knowing — your stack, your shell, the
|
|
254
|
+
conventions you hold to, the things that reliably annoy you.
|
|
255
|
+
|
|
256
|
+
## Register preferences
|
|
257
|
+
|
|
258
|
+
How you like answers pitched: terse or expansive, teach-me or just-do-it, how
|
|
259
|
+
much pushback you want, which language(s) you think in.
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
/** `_build_render.BUNDLE_GITIGNORE`. */
|
|
263
|
+
const BUNDLE_GITIGNORE = `\
|
|
264
|
+
# Generated by Geneseed. The rendered harness — AGENT.md, the laws, agents, and
|
|
265
|
+
# skills — is safe to commit; track it if you want it versioned with your project.
|
|
266
|
+
# Only the host-specific / personal files below are kept out of git.
|
|
267
|
+
|
|
268
|
+
# Project-context manifest — may hold private paths; never commit.
|
|
269
|
+
context.json
|
|
270
|
+
|
|
271
|
+
# Knowledge-base manifest — holds private machine paths; never commit.
|
|
272
|
+
# (wiki.json is the legacy name from earlier seeds.)
|
|
273
|
+
wiki.jsonc
|
|
274
|
+
wiki.json
|
|
275
|
+
|
|
276
|
+
# Per-agent model/temperature overrides — host-specific; never commit.
|
|
277
|
+
agent-overrides.json
|
|
278
|
+
|
|
279
|
+
# Which theme + emit mode + footprint this host last built (local build state, must not travel).
|
|
280
|
+
.geneseed-theme
|
|
281
|
+
.geneseed-emit
|
|
282
|
+
.geneseed-footprint
|
|
283
|
+
.geneseed-srcdirs.json
|
|
284
|
+
|
|
285
|
+
# memory/ keeps its own .gitignore so learned facts stay on this machine.
|
|
286
|
+
# notebook/ keeps its own .gitignore so the agent's own files stay on this machine.
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Small filesystem predicates. Python spells these `Path.is_file()` / `is_dir()`, both
|
|
291
|
+
// of which answer False for a missing path rather than raising; `statSync` throws.
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
function isFile(p) {
|
|
295
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isDir(p) {
|
|
299
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `dict.get(key)` with Python's semantics — OWN properties only.
|
|
303
|
+
*
|
|
304
|
+
* The same hazard `js/settings.mjs` and `js/native.mjs` both spell out: the keys here come
|
|
305
|
+
* out of a manifest the user can edit, and `old['constructor']` hands back
|
|
306
|
+
* `Object.prototype`'s member where Python's `.get` returns None. */
|
|
307
|
+
function get(obj, key) {
|
|
308
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isDict(v) {
|
|
312
|
+
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do. */
|
|
316
|
+
function rglobFiles(root, out = []) {
|
|
317
|
+
for (const name of readdirSync(root)) {
|
|
318
|
+
if (name === '__pycache__') continue;
|
|
319
|
+
const full = path.join(root, name);
|
|
320
|
+
if (isDir(full)) rglobFiles(full, out);
|
|
321
|
+
else out.push(full);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
|
|
328
|
+
*
|
|
329
|
+
* Not a duplicate of `rglobFiles` by accident: `source_fingerprint`'s Python filters
|
|
330
|
+
* `__pycache__` and the legacy-store migration in `_global_memory` does not, so a single
|
|
331
|
+
* walk would be wrong for one caller either way. A user's legacy bundle really can carry
|
|
332
|
+
* one (the harness ships `.py` skill scripts), and copying it or not is a byte difference
|
|
333
|
+
* the two runtimes would disagree on silently.
|
|
334
|
+
*/
|
|
335
|
+
function rglobAllFiles(root, out = []) {
|
|
336
|
+
for (const name of readdirSync(root)) {
|
|
337
|
+
const full = path.join(root, name);
|
|
338
|
+
if (isDir(full)) rglobAllFiles(full, out);
|
|
339
|
+
else out.push(full);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** `p.relative_to(base).as_posix()`. */
|
|
345
|
+
function relPosix(base, p) {
|
|
346
|
+
return path.relative(base, p).split(path.sep).join('/');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Version identity
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* `_build_render.source_fingerprint`.
|
|
355
|
+
*
|
|
356
|
+
* Sorted by the ROOT-relative POSIX path as a plain string — NOT `comparePaths`. Python
|
|
357
|
+
* sorts by an explicit `key=` here rather than by `Path` objects, so this one sort is
|
|
358
|
+
* case-SENSITIVE on every platform while `sorted(SRC.rglob("*"))` in `render_all` is not.
|
|
359
|
+
* Two sorts of the same files, two different orders, and only one of them is observable
|
|
360
|
+
* in the output; reproducing the wrong one changes the fingerprint of every install.
|
|
361
|
+
*/
|
|
362
|
+
export function sourceFingerprint(cfg) {
|
|
363
|
+
const h = createHash('sha256');
|
|
364
|
+
const files = [];
|
|
365
|
+
for (const r of [cfg.src, cfg.themes, cfg.pluginSrc, cfg.workflowSrc]) {
|
|
366
|
+
if (isDir(r)) files.push(...rglobFiles(r));
|
|
367
|
+
}
|
|
368
|
+
const keyed = files.map((p) => [relPosix(cfg.root, p), p]);
|
|
369
|
+
keyed.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
370
|
+
for (const [rel, p] of keyed) {
|
|
371
|
+
h.update(Buffer.from(`${rel}\0`, 'utf8'));
|
|
372
|
+
h.update(readFileSync(p));
|
|
373
|
+
h.update(Buffer.from([0]));
|
|
374
|
+
}
|
|
375
|
+
return h.digest('hex').slice(0, 12);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* `_build_render.read_version` — the FINGERPRINT token, which is a different name and a
|
|
380
|
+
* different value from `read_release_version` right below it. `harness version` and the
|
|
381
|
+
* status panel both compare this one against `sourceFingerprint`.
|
|
382
|
+
*
|
|
383
|
+
* `txt.split()[0] if txt else None`: a marker that is empty or whitespace-only reads as no
|
|
384
|
+
* version at all, so the caller's candidate walk carries on past it rather than stopping.
|
|
385
|
+
*/
|
|
386
|
+
export function readVersion(dir) {
|
|
387
|
+
let txt;
|
|
388
|
+
try {
|
|
389
|
+
txt = readText(path.join(dir, VERSION_MARKER)).trim();
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
// `str.split()` with no argument splits on runs of whitespace AND drops the leading
|
|
394
|
+
// empties; `String.split(/\s+/)` does not, so an indented marker would yield ''.
|
|
395
|
+
const first = txt.split(/\s+/).filter(Boolean)[0];
|
|
396
|
+
return first === undefined ? null : first;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** `_build_render.read_release_version`. */
|
|
400
|
+
function readReleaseVersion(out) {
|
|
401
|
+
let txt;
|
|
402
|
+
try {
|
|
403
|
+
txt = readText(path.join(out, VERSION_MARKER)).trim();
|
|
404
|
+
} catch {
|
|
405
|
+
// Python catches OSError only. A marker that is not valid UTF-8 raises there and
|
|
406
|
+
// degrades to U+FFFD here (Node's decoder substitutes rather than reporting) — the
|
|
407
|
+
// same asymmetry already recorded for `agent-overrides.json`, and on no covered path.
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const m = /\[release ([^\]]+)\]/.exec(txt);
|
|
411
|
+
return m ? m[1] : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** `_build_core._parse_version_tuple`. */
|
|
415
|
+
function parseVersionTuple(v) {
|
|
416
|
+
const parts = v.trim().split('.');
|
|
417
|
+
if (!parts.length) return null;
|
|
418
|
+
const out = [];
|
|
419
|
+
for (const p of parts) {
|
|
420
|
+
// `str.isdigit()`, not `Number.isInteger(+p)`: '' and '1e2' and ' 1' are all
|
|
421
|
+
// non-digit to Python, and `+p` accepts every one of them.
|
|
422
|
+
if (!/^[0-9]+$/.test(p)) return null;
|
|
423
|
+
out.push(Number(p));
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* `_build_core.version_is_newer` — null when either side fails to parse.
|
|
430
|
+
*
|
|
431
|
+
* EXPORTED FOR ITS GATE, and the argument belongs here rather than in the test. Its only
|
|
432
|
+
* caller is `warnIfDowngrade`, one line down, whose whole observable behaviour is "print a
|
|
433
|
+
* warning or say nothing" — so through the emit face `false` and `null` are the SAME
|
|
434
|
+
* observation, and six of the nine claims this function makes are invisible. That numeric
|
|
435
|
+
* comparison beats lexical (`1.10.0` > `1.9.0`), that a short tuple is zero-padded on the
|
|
436
|
+
* right (`1.2` == `1.2.0`), and that an unparseable side answers `null` rather than `false`
|
|
437
|
+
* are each a decision with a wrong answer that no cell and no emit can distinguish.
|
|
438
|
+
*
|
|
439
|
+
* Nothing else changes: it is the same function, called the same way, from the same one
|
|
440
|
+
* place. See `tests/unit/lifecycle.test.mjs`.
|
|
441
|
+
*/
|
|
442
|
+
export function versionIsNewer(a, b) {
|
|
443
|
+
const ta = parseVersionTuple(a);
|
|
444
|
+
const tb = parseVersionTuple(b);
|
|
445
|
+
if (ta === null || tb === null) return null;
|
|
446
|
+
const width = Math.max(ta.length, tb.length);
|
|
447
|
+
for (let i = 0; i < width; i++) {
|
|
448
|
+
const x = ta[i] ?? 0;
|
|
449
|
+
const y = tb[i] ?? 0;
|
|
450
|
+
if (x !== y) return x > y;
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
|
|
456
|
+
function warnIfDowngrade(cfg, out) {
|
|
457
|
+
const deployed = readReleaseVersion(out);
|
|
458
|
+
if (deployed === null) return;
|
|
459
|
+
const current = sourceReleaseVersion(cfg);
|
|
460
|
+
if (versionIsNewer(deployed, current)) {
|
|
461
|
+
process.stdout.write(`[geneseed] WARN: installing older Geneseed ${current} over newer `
|
|
462
|
+
+ `${deployed} at ${out} — did you forget git pull?\n`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** `_build_render.write_version`. */
|
|
467
|
+
function writeVersion(cfg, out) {
|
|
468
|
+
warnIfDowngrade(cfg, out);
|
|
469
|
+
const fp = sourceFingerprint(cfg);
|
|
470
|
+
const release = sourceReleaseVersion(cfg);
|
|
471
|
+
// `datetime.date.today().isoformat()` — the LOCAL date. `toISOString()` is UTC and
|
|
472
|
+
// would stamp tomorrow's date for anyone east of Greenwich after their evening.
|
|
473
|
+
//
|
|
474
|
+
// No gate can catch this one, and it is recorded rather than left to be discovered:
|
|
475
|
+
// both runtimes read their own clock at almost the same instant, so a UTC-vs-local
|
|
476
|
+
// mutation produces identical bytes except during the hour the two dates disagree.
|
|
477
|
+
// Mutating it away stays green here for a reason no cell can remove — freezing the
|
|
478
|
+
// clock across a process boundary is not something the harness can do — which is
|
|
479
|
+
// exactly why the choice is spelled out instead of relying on the comparison.
|
|
480
|
+
const d = new Date();
|
|
481
|
+
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
|
|
482
|
+
+ `${String(d.getDate()).padStart(2, '0')}`;
|
|
483
|
+
writeText(path.join(out, VERSION_MARKER),
|
|
484
|
+
`${fp} (built ${today}) [release ${release}]\n`);
|
|
485
|
+
return fp;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Source completeness
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
|
|
493
|
+
function missingReferencedSpecs(cfg) {
|
|
494
|
+
let tmpl;
|
|
495
|
+
try {
|
|
496
|
+
tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
|
|
497
|
+
} catch {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
const missing = new Set();
|
|
501
|
+
for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
|
|
502
|
+
const dir = kind.toLowerCase();
|
|
503
|
+
if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
|
|
504
|
+
missing.add(`${dir}/${name}.md`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return [...missing].sort();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* `_build_render.assert_source_complete`.
|
|
512
|
+
*
|
|
513
|
+
* Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
|
|
514
|
+
* captured stderr buffer and the throw carries the exit code, which the driver turns
|
|
515
|
+
* back into a `SystemExit(1)` on the Python side — so the caller sees the same stream
|
|
516
|
+
* bytes and the same exit status whichever runtime rendered.
|
|
517
|
+
*/
|
|
518
|
+
function assertSourceComplete(cfg, context = '') {
|
|
519
|
+
const missing = missingReferencedSpecs(cfg);
|
|
520
|
+
if (!missing.length) return;
|
|
521
|
+
const where = context ? ` (${context})` : '';
|
|
522
|
+
process.stderr.write(
|
|
523
|
+
`[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
|
|
524
|
+
+ `${missing.length} spec(s) with no file under src/:\n`
|
|
525
|
+
+ missing.map((m) => ` - ${m}\n`).join('')
|
|
526
|
+
+ '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
|
|
527
|
+
+ 'and a global re-emit would delete the good copies in an existing install.\n'
|
|
528
|
+
+ '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
|
|
529
|
+
+ 'and try again.\n');
|
|
530
|
+
const e = new Error('source is incomplete');
|
|
531
|
+
e.exitCode = 1;
|
|
532
|
+
throw e;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Seeded stubs — write-once, never overwrite
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
/** `_build_render.ensure_context_stub`. */
|
|
540
|
+
function ensureContextStub(out) {
|
|
541
|
+
const dest = path.join(out, 'context.json');
|
|
542
|
+
if (!existsSync(dest)) {
|
|
543
|
+
writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
|
|
548
|
+
function ensureWikiStub(out) {
|
|
549
|
+
const dest = path.join(out, 'wiki.jsonc');
|
|
550
|
+
if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** `_build_render.ensure_rules_stub`. */
|
|
554
|
+
function ensureRulesStub(out) {
|
|
555
|
+
const dest = path.join(out, RULES_FILE);
|
|
556
|
+
if (!existsSync(dest)) writeText(dest, RULES_STUB);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** `_build_render.ensure_profile_stub`. */
|
|
560
|
+
function ensureProfileStub(out) {
|
|
561
|
+
const dest = path.join(out, PROFILE_FILE);
|
|
562
|
+
if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
|
|
567
|
+
* overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
|
|
568
|
+
* `emitClaudeRender` rather than with the bundle stubs beside it.
|
|
569
|
+
*/
|
|
570
|
+
function ensureExcludesStub(out) {
|
|
571
|
+
const dest = path.join(out, EXCLUDES_FILE);
|
|
572
|
+
if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** `_build_render.ensure_bundle_gitignore`. */
|
|
576
|
+
function ensureBundleGitignore(out) {
|
|
577
|
+
const dest = path.join(out, '.gitignore');
|
|
578
|
+
if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
|
|
582
|
+
function ensureMemoryIndex(memDir) {
|
|
583
|
+
if (!isDir(memDir)) return;
|
|
584
|
+
const idx = path.join(memDir, 'MEMORY.md');
|
|
585
|
+
if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** `_build_render.ensure_notebook_index`. */
|
|
589
|
+
function ensureNotebookIndex(nbDir) {
|
|
590
|
+
if (!isDir(nbDir)) return;
|
|
591
|
+
const idx = path.join(nbDir, 'NOTEBOOK.md');
|
|
592
|
+
if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// The owned-dir marker
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
/** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
|
|
600
|
+
function readPriorSrcDirs(out) {
|
|
601
|
+
let data;
|
|
602
|
+
try {
|
|
603
|
+
data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
|
|
604
|
+
} catch {
|
|
605
|
+
return {};
|
|
606
|
+
}
|
|
607
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `_build_render._write_src_dirs_marker` — temp + replace. */
|
|
611
|
+
function writeSrcDirsMarker(out, resolved) {
|
|
612
|
+
const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
|
|
613
|
+
writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
|
|
614
|
+
renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
|
|
619
|
+
* by file content rather than by code.
|
|
620
|
+
*
|
|
621
|
+
* `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
|
|
622
|
+
* name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
|
|
623
|
+
* the bundle's PARENT, an absolute path replaces `out` entirely under Python's
|
|
624
|
+
* `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
|
|
625
|
+
* single-segment name resolving directly under `out` is ever deleted.
|
|
626
|
+
*/
|
|
627
|
+
function safePriorDirName(out, priorName) {
|
|
628
|
+
return typeof priorName === 'string'
|
|
629
|
+
&& priorName !== '.' && priorName !== '..'
|
|
630
|
+
&& !path.isAbsolute(priorName)
|
|
631
|
+
&& path.basename(priorName) === priorName
|
|
632
|
+
&& path.dirname(path.resolve(out, priorName)) === path.resolve(out);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// build
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* `_build_render.build` — render the bundle into `out`.
|
|
641
|
+
*
|
|
642
|
+
* `out` arrives as the STRING Python already resolved, and is echoed into the progress
|
|
643
|
+
* lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
|
|
644
|
+
* Windows, and re-deriving it here would differ by separator in every message.
|
|
645
|
+
*/
|
|
646
|
+
/**
|
|
647
|
+
* THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
|
|
648
|
+
* `tests/test_emit_phase_order.py`.
|
|
649
|
+
*
|
|
650
|
+
* Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
|
|
651
|
+
* writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
|
|
652
|
+
* CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
|
|
653
|
+
* already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
|
|
654
|
+
* `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
|
|
655
|
+
*
|
|
656
|
+
* WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
|
|
657
|
+
* its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
|
|
658
|
+
* dispatchers and therefore two statements at every call site — a shape its own docstring calls
|
|
659
|
+
* "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
|
|
660
|
+
* THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
|
|
661
|
+
* (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
|
|
662
|
+
* module at all — they are in `bin/geneseed.mjs`'s driver bodies. A walker would have to span two
|
|
663
|
+
* files and would still be reading source rather than watching a run.
|
|
664
|
+
*
|
|
665
|
+
* So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
|
|
666
|
+
* actually executed, in the order it executed, including the branch a given host took.
|
|
667
|
+
*
|
|
668
|
+
* ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
|
|
669
|
+
* `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
|
|
670
|
+
* the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
|
|
671
|
+
* decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
|
|
672
|
+
* gate into a silently permissive one.
|
|
673
|
+
*
|
|
674
|
+
* Read at CALL time, not at import, so one process can drive several emits under it.
|
|
675
|
+
*/
|
|
676
|
+
export function phaseLog(phase) {
|
|
677
|
+
if (!process.env.GENESEED_PHASE_LOG) return;
|
|
678
|
+
process.stderr.write(`[geneseed:phase] ${phase}\n`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
|
|
682
|
+
phaseLog('RENDER');
|
|
683
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
|
|
684
|
+
assertSourceComplete(cfg, `theme '${themeName}'`);
|
|
685
|
+
mkdirSync(out, { recursive: true });
|
|
686
|
+
|
|
687
|
+
// Wipe the owned dirs ONLY inside an established bundle: a first render into an
|
|
688
|
+
// arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
|
|
689
|
+
const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
|
|
690
|
+
const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
|
|
691
|
+
const resolvedSrcDirs = {};
|
|
692
|
+
for (const srcDir of OWNED_SRC_DIRS) {
|
|
693
|
+
// `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
|
|
694
|
+
// where Python would go on to join `None` onto a path and raise. No theme has one.
|
|
695
|
+
const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
|
|
696
|
+
resolvedSrcDirs[srcDir] = dirname;
|
|
697
|
+
const managed = path.join(out, dirname);
|
|
698
|
+
|
|
699
|
+
// `pyTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
|
|
700
|
+
// `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
|
|
701
|
+
// two. Getting that wrong warns about a value Python skips in silence — a stderr
|
|
702
|
+
// divergence reachable only from a hand-edited file, which is the one place nobody
|
|
703
|
+
// would look for it.
|
|
704
|
+
const priorName = priorSrcDirs[srcDir];
|
|
705
|
+
if (isBundle && pyTruthy(priorName) && priorName !== dirname) {
|
|
706
|
+
if (safePriorDirName(out, priorName)) {
|
|
707
|
+
const stale = path.join(out, priorName);
|
|
708
|
+
if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
|
|
709
|
+
} else {
|
|
710
|
+
process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
|
|
711
|
+
+ `${pyAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!isDir(managed)) continue;
|
|
716
|
+
if (isBundle) {
|
|
717
|
+
rmSync(managed, { recursive: true, force: true });
|
|
718
|
+
} else {
|
|
719
|
+
// STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
|
|
720
|
+
// Windows console when it carried a warning-sign emoji.
|
|
721
|
+
process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
|
|
722
|
+
+ 'Geneseed bundle — keeping it; rendered files merge into it.\n');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
|
|
727
|
+
for (const { rel, text, src } of items) {
|
|
728
|
+
const dest = path.join(out, rel);
|
|
729
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
730
|
+
// The notebook is the agent's sovereign space: seeded files are written once and
|
|
731
|
+
// never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
|
|
732
|
+
// re-asserted — the one law it cannot lift.
|
|
733
|
+
const parts = rel.split('/');
|
|
734
|
+
if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (text !== null) writeText(dest, text);
|
|
738
|
+
else copyFile(src, dest);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
|
|
742
|
+
writeVersion(cfg, out);
|
|
743
|
+
writeSrcDirsMarker(out, resolvedSrcDirs);
|
|
744
|
+
ensureContextStub(out);
|
|
745
|
+
ensureWikiStub(out);
|
|
746
|
+
ensureRulesStub(out);
|
|
747
|
+
ensureProfileStub(out);
|
|
748
|
+
ensureBundleGitignore(out);
|
|
749
|
+
ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
|
|
750
|
+
ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
|
|
751
|
+
process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
// emit_opencode — the RENDER stage only
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
|
|
760
|
+
*
|
|
761
|
+
* Exported for `js/doctor.mjs`: `_rendered_problems` applies the same de-linking before
|
|
762
|
+
* comparing an OpenCode bundle's AGENT.md against a fresh render, because the emit puts
|
|
763
|
+
* that difference back by design and a check that did not would report the file stale on
|
|
764
|
+
* every run forever.
|
|
765
|
+
*/
|
|
766
|
+
export function stripCapabilityLinks(cfg, text) {
|
|
767
|
+
const re = cfg.capabilityLinkRe
|
|
768
|
+
? new RegExp(cfg.capabilityLinkRe, 'g')
|
|
769
|
+
: CAPABILITY_LINK_RE;
|
|
770
|
+
return text.replace(re, '$1');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The RENDER half of `_build_emit.emit_opencode`: everything up to (not including) the
|
|
775
|
+
* `_merge_opencode_json` call. WIRE, PRUNE and MANIFEST stay in Python.
|
|
776
|
+
*
|
|
777
|
+
* `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
|
|
778
|
+
* manifest is Python's stage — it writes it, prunes against it, and now reads it too, so
|
|
779
|
+
* there is one owner of the file instead of two processes racing to interpret it.
|
|
780
|
+
*
|
|
781
|
+
* `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']`,
|
|
782
|
+
* and the HOSTS registry stays Python this phase; passing the decision keeps the registry
|
|
783
|
+
* in exactly one place rather than duplicating it for one boolean.
|
|
784
|
+
*/
|
|
785
|
+
export function emitOpencodeRender(cfg, job) {
|
|
786
|
+
phaseLog('RENDER');
|
|
787
|
+
const {
|
|
788
|
+
theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
|
|
789
|
+
} = job;
|
|
790
|
+
const oc = path.join(root, '.opencode');
|
|
791
|
+
|
|
792
|
+
build(cfg, _theme, out, { footprint, nativeCatalog });
|
|
793
|
+
|
|
794
|
+
// OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
|
|
795
|
+
// plain names (the portable build keeps them). A deliberate de-link, not a fix.
|
|
796
|
+
const agentMd = path.join(out, 'AGENT.md');
|
|
797
|
+
if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
|
|
798
|
+
|
|
799
|
+
const owned = [];
|
|
800
|
+
const { theme, items } = renderAll(cfg, _theme);
|
|
801
|
+
|
|
802
|
+
ensureAgentOverridesStub(cfg, out);
|
|
803
|
+
const overrides = loadAgentOverrides(out);
|
|
804
|
+
|
|
805
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
806
|
+
items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
|
|
807
|
+
{ host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
|
|
808
|
+
for (const p of written) owned.push(relPosix(oc, p));
|
|
809
|
+
|
|
810
|
+
const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
|
|
811
|
+
if (primary) owned.push(relPosix(oc, primary));
|
|
812
|
+
|
|
813
|
+
const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
|
|
814
|
+
commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
|
|
815
|
+
for (const p of commands) owned.push(relPosix(oc, p));
|
|
816
|
+
|
|
817
|
+
owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
|
|
818
|
+
for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
|
|
819
|
+
|
|
820
|
+
const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
|
|
821
|
+
const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
|
|
822
|
+
|
|
823
|
+
// WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
|
|
824
|
+
// (`_rel_under` is the Python side by design), and only the BASENAME is consumed
|
|
825
|
+
// downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
|
|
826
|
+
phaseLog('WIRE');
|
|
827
|
+
const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath));
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
owned,
|
|
831
|
+
stats: {
|
|
832
|
+
nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
|
|
833
|
+
},
|
|
834
|
+
cfgName,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// The global-store helpers, shared by the `opencode-global` and `claude` jobs
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
|
|
844
|
+
* store that already holds something.
|
|
845
|
+
*
|
|
846
|
+
* The three outcomes are the returned status string, which the emit prints, so they are
|
|
847
|
+
* compared through stdout as well as through the tree. Python's `theme` parameter is
|
|
848
|
+
* dropped: the store dir is ALWAYS the classic English `memory/`, never themed (the
|
|
849
|
+
* OpenCode config dir uses fixed names), so the argument was never read.
|
|
850
|
+
*
|
|
851
|
+
* The migration branch copies arbitrary USER files out of a legacy bundle, which is the
|
|
852
|
+
* reason this is not a plain translation: everything it touches is the user's, and the
|
|
853
|
+
* only thing keeping it safe is that it runs at all only when the destination is empty.
|
|
854
|
+
*/
|
|
855
|
+
function globalMemory(cfgDir, items, legacy, srcRoot) {
|
|
856
|
+
const memName = 'memory';
|
|
857
|
+
const memDir = path.join(cfgDir, memName);
|
|
858
|
+
if (isDir(memDir) && readdirSync(memDir).length) return `kept ${memName}/`;
|
|
859
|
+
mkdirSync(memDir, { recursive: true });
|
|
860
|
+
if (legacy) {
|
|
861
|
+
// `dict.fromkeys([mem_name, "memory", "anamnesis"])` — de-duplicated, order kept.
|
|
862
|
+
// `mem_name` is the literal 'memory' and the Python docstring beside it says the
|
|
863
|
+
// store name is NEVER themed, so the first two entries always collapse and
|
|
864
|
+
// `anamnesis` — an older themed install's name — is the only live alias. The dedupe
|
|
865
|
+
// is what keeps the duplicate harmless rather than copying the same dir twice.
|
|
866
|
+
for (const nm of [...new Set([memName, 'memory', 'anamnesis'])]) {
|
|
867
|
+
const src = path.join(legacy, nm);
|
|
868
|
+
if (isDir(src) && readdirSync(src).length) {
|
|
869
|
+
for (const f of rglobAllFiles(src)) {
|
|
870
|
+
const dest = path.join(memDir, path.relative(src, f));
|
|
871
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
872
|
+
copyFile(f, dest);
|
|
873
|
+
}
|
|
874
|
+
return `migrated ${nm}/ -> ${memName}/`;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
for (const { text, src } of items) {
|
|
879
|
+
const sp = relPosix(srcRoot, src).split('/');
|
|
880
|
+
if (sp[0] === 'memory' && sp.length > 1) {
|
|
881
|
+
// `destRel`, because this seeds from the SOURCE path and not from the item's `rel`
|
|
882
|
+
// (which is themed, and `memory/` never is). Without it the store would be seeded
|
|
883
|
+
// with the on-disk name `gitignore` — see `js/render.mjs`'s `destRel`.
|
|
884
|
+
const dest = path.join(memDir, destRel(path.join(...sp.slice(1))));
|
|
885
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
886
|
+
if (text !== null) writeText(dest, text);
|
|
887
|
+
else copyFile(src, dest);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return `seeded ${memName}/`;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
|
|
894
|
+
function globalNotebook(cfgDir, items, legacy, srcRoot) {
|
|
895
|
+
const nbName = 'notebook';
|
|
896
|
+
const nbDir = path.join(cfgDir, nbName);
|
|
897
|
+
if (isDir(nbDir) && readdirSync(nbDir).length) return `kept ${nbName}/`;
|
|
898
|
+
mkdirSync(nbDir, { recursive: true });
|
|
899
|
+
if (legacy) {
|
|
900
|
+
const src = path.join(legacy, nbName);
|
|
901
|
+
if (isDir(src) && readdirSync(src).length) {
|
|
902
|
+
for (const f of rglobAllFiles(src)) {
|
|
903
|
+
const dest = path.join(nbDir, path.relative(src, f));
|
|
904
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
905
|
+
copyFile(f, dest);
|
|
906
|
+
}
|
|
907
|
+
return `migrated ${nbName}/`;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
for (const { text, src } of items) {
|
|
911
|
+
const sp = relPosix(srcRoot, src).split('/');
|
|
912
|
+
if (sp[0] === nbName && sp.length > 1) {
|
|
913
|
+
// `destRel` — same reason as `globalMemory` above.
|
|
914
|
+
const dest = path.join(nbDir, destRel(path.join(...sp.slice(1))));
|
|
915
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
916
|
+
if (text !== null) writeText(dest, text);
|
|
917
|
+
else copyFile(src, dest);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return `seeded ${nbName}/`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* `_build_global._ship_lean_laws`.
|
|
925
|
+
*
|
|
926
|
+
* Historically the one RENDER that ran AFTER a WIRE, which is why the phase-order
|
|
927
|
+
* normalisation had to move it before the seam could be cut. Re-derived rather than
|
|
928
|
+
* trusted: its inputs are `items` and `theme` (both from `render_all`) and its only
|
|
929
|
+
* output besides the files is what it APPENDS to `owned` — which travels back in the
|
|
930
|
+
* payload for the manifest and the prune. It reads nothing any wiring stage writes, so
|
|
931
|
+
* its new position is not merely legal, it has no dependency on the old one.
|
|
932
|
+
*/
|
|
933
|
+
function shipLeanLaws(items, theme, cfgDir, owned) {
|
|
934
|
+
const lawsDir = theme.DIR_LAWS ?? 'laws';
|
|
935
|
+
for (const { rel, text } of items) {
|
|
936
|
+
const parts = rel.split('/');
|
|
937
|
+
// `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
|
|
938
|
+
// is empty. `String.split` never returns an empty array, so this half can never be
|
|
939
|
+
// false here — kept for the mirror, and its mutation stays green because there is
|
|
940
|
+
// nothing to detect, not because the gate cannot see it.
|
|
941
|
+
if (text !== null && parts.length && parts[0] === lawsDir) {
|
|
942
|
+
const dest = path.join(cfgDir, ...parts);
|
|
943
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
944
|
+
writeText(dest, text);
|
|
945
|
+
owned.push(rel);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
// emit_opencode_global — the ninth, and the last emit to cross
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
|
|
956
|
+
* per-repo" OpenCode deployment. PRUNE and MANIFEST stay in Python; there is no VERIFY,
|
|
957
|
+
* because this emit writes no settings file.
|
|
958
|
+
*
|
|
959
|
+
* ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
|
|
960
|
+
* in its Python closure, counted with `ast` rather than by name-mapping, which is how the
|
|
961
|
+
* scout that produced this list first reported `_posture_body` and `_mode_body` as missing
|
|
962
|
+
* when both are `registerBody` in `js/render.mjs`. What is new is the assembly and the
|
|
963
|
+
* three values that had to be decided on the Python side.
|
|
964
|
+
*
|
|
965
|
+
* `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
|
|
966
|
+
* `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
|
|
967
|
+
* both redirect it at a sandbox — so resolving it here would render 135 files into the
|
|
968
|
+
* developer's real `~/.config/opencode`. Python's entry point resolves it once, before
|
|
969
|
+
* anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
|
|
970
|
+
* `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
|
|
971
|
+
* resolver.
|
|
972
|
+
*
|
|
973
|
+
* `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
|
|
974
|
+
* writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
|
|
975
|
+
* notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
|
|
976
|
+
* byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
|
|
977
|
+
* leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
|
|
978
|
+
*
|
|
979
|
+
* `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
|
|
980
|
+
* input, and `Path.as_posix()` is the Python side's spelling of it.
|
|
981
|
+
*/
|
|
982
|
+
export function emitOpencodeGlobalRender(cfg, job) {
|
|
983
|
+
phaseLog('RENDER');
|
|
984
|
+
const {
|
|
985
|
+
theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
|
|
986
|
+
} = job;
|
|
987
|
+
|
|
988
|
+
// No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
|
|
989
|
+
// pointer's relative `laws/universal.md` resolves with no prefix.
|
|
990
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
|
|
991
|
+
assertSourceComplete(cfg, 'opencode-global');
|
|
992
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
993
|
+
|
|
994
|
+
const owned = [];
|
|
995
|
+
const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
|
|
996
|
+
if (agentText !== null) {
|
|
997
|
+
// OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
|
|
998
|
+
// plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
|
|
999
|
+
// store are siblings, so `memory/` resolves from AGENT.md's own location and stays
|
|
1000
|
+
// hermetic — no absolute path a doctor check would flag as an escape.
|
|
1001
|
+
writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
|
|
1002
|
+
owned.push('AGENT.md');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
ensureAgentOverridesStub(cfg, cfgDir);
|
|
1006
|
+
const overrides = loadAgentOverrides(cfgDir);
|
|
1007
|
+
|
|
1008
|
+
// `manifestExisted` is deliberately not passed — the Python does not pass it either, so
|
|
1009
|
+
// the pre-manifest header line is unreachable from this emit on both sides.
|
|
1010
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
1011
|
+
items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
|
|
1012
|
+
{ host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
|
|
1013
|
+
for (const p of written) owned.push(relPosix(cfgDir, p));
|
|
1014
|
+
|
|
1015
|
+
const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
|
|
1016
|
+
if (primary) owned.push(relPosix(cfgDir, primary));
|
|
1017
|
+
|
|
1018
|
+
const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
|
|
1019
|
+
commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
|
|
1020
|
+
for (const p of commands) owned.push(relPosix(cfgDir, p));
|
|
1021
|
+
|
|
1022
|
+
owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
|
|
1023
|
+
for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
|
|
1024
|
+
owned.push(relPosix(cfgDir, p));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
|
|
1028
|
+
const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
|
|
1029
|
+
|
|
1030
|
+
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
1031
|
+
ensureMemoryIndex(path.join(cfgDir, 'memory'));
|
|
1032
|
+
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
1033
|
+
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
1034
|
+
ensureWikiStub(cfgDir);
|
|
1035
|
+
ensureRulesStub(cfgDir);
|
|
1036
|
+
ensureProfileStub(cfgDir);
|
|
1037
|
+
ensureExcludesStub(cfgDir);
|
|
1038
|
+
|
|
1039
|
+
writeVersion(cfg, cfgDir);
|
|
1040
|
+
owned.push(VERSION_MARKER);
|
|
1041
|
+
|
|
1042
|
+
if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
|
|
1043
|
+
|
|
1044
|
+
// WIRE — the one file of this emit the user co-owns, and the last render is now behind
|
|
1045
|
+
// us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same
|
|
1046
|
+
// file name), while the Python side keeps it a separate `_opencode_global_wire_py`:
|
|
1047
|
+
// that split is what `tests/test_emit_phase_order.py` walks, and it walks the Python.
|
|
1048
|
+
phaseLog('WIRE');
|
|
1049
|
+
const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
|
|
1050
|
+
agentPath));
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
owned,
|
|
1054
|
+
stats: {
|
|
1055
|
+
nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
|
|
1056
|
+
},
|
|
1057
|
+
memStatus,
|
|
1058
|
+
nbStatus,
|
|
1059
|
+
cfgName,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
// _emit_claude_core — both halves
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The RENDER half of `_build_global._emit_claude_core`: everything up to (not including)
|
|
1069
|
+
* the CLAUDE.md managed-block merge. WIRE, PRUNE, MANIFEST and VERIFY stay in Python.
|
|
1070
|
+
*
|
|
1071
|
+
* Six emits route through here — claude, bob and copilot at both scopes — so this one
|
|
1072
|
+
* job is what puts two thirds of the matrix on the seam.
|
|
1073
|
+
*
|
|
1074
|
+
* `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
|
|
1075
|
+
* needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
|
|
1076
|
+
* re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
|
|
1077
|
+
* there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
|
|
1078
|
+
* a path, not from a render.
|
|
1079
|
+
*/
|
|
1080
|
+
export function emitClaudeRender(cfg, job) {
|
|
1081
|
+
phaseLog('RENDER');
|
|
1082
|
+
const {
|
|
1083
|
+
theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
|
|
1084
|
+
oldOwned,
|
|
1085
|
+
} = job;
|
|
1086
|
+
|
|
1087
|
+
// `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
|
|
1088
|
+
// that Python answers '.' for an identical pair where `path.relative` answers ''.
|
|
1089
|
+
const relCfg = path.relative(path.dirname(claudeMd), cfgDir).split(path.sep).join('/')
|
|
1090
|
+
|| '.';
|
|
1091
|
+
const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
|
|
1092
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
|
|
1093
|
+
assertSourceComplete(cfg, `claude-${scope}`);
|
|
1094
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
1095
|
+
|
|
1096
|
+
/** `_emit_claude_core._prefixed_agent_text`. */
|
|
1097
|
+
const prefixedAgentText = (prefix) => {
|
|
1098
|
+
const tmplItem = items.find((i) => i.rel === 'AGENT.md');
|
|
1099
|
+
if (tmplItem === undefined) return null;
|
|
1100
|
+
// Deliberately NOT the same lookup as `agentText` below: that one skips an
|
|
1101
|
+
// AGENT.md whose text is null, this one does not. Unreachable today (`.md` is a
|
|
1102
|
+
// text suffix, so the render is never null) and reproduced anyway, because the two
|
|
1103
|
+
// spellings sit four lines apart in the Python and only one of them filters.
|
|
1104
|
+
//
|
|
1105
|
+
// The fast path itself is byte-inert, measured: removing it re-renders with a theme
|
|
1106
|
+
// whose DIR_* tokens gained an EMPTY prefix and with the same lawsPrefix, so it
|
|
1107
|
+
// produces exactly the item's own text. The mutation stays green, and that is
|
|
1108
|
+
// "nothing to detect" rather than "the gate cannot see it" — recorded the way
|
|
1109
|
+
// `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
|
|
1110
|
+
if (!prefix) return tmplItem.text;
|
|
1111
|
+
const ptheme = { ...theme };
|
|
1112
|
+
for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
|
|
1113
|
+
ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
|
|
1114
|
+
}
|
|
1115
|
+
// Same catalogue decision as the renderAll above — a re-render that forgot it would
|
|
1116
|
+
// quietly put the stripped tables back.
|
|
1117
|
+
return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
const owned = [];
|
|
1121
|
+
const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
|
|
1122
|
+
const isBob = host === 'bob';
|
|
1123
|
+
const isCopilot = host === 'copilot';
|
|
1124
|
+
|
|
1125
|
+
// Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
|
|
1126
|
+
// IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
|
|
1127
|
+
// a `../` prefix because it sits one level below the stores; at PROJECT scope it is the
|
|
1128
|
+
// slim shadow stub whose only job is to have the same filename.
|
|
1129
|
+
if (isBob && agentText !== null) {
|
|
1130
|
+
const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
|
|
1131
|
+
mkdirSync(path.dirname(rulesMd), { recursive: true });
|
|
1132
|
+
writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
|
|
1133
|
+
: stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
|
|
1134
|
+
owned.push('rules/geneseed.md');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
ensureAgentOverridesStub(cfg, cfgDir);
|
|
1138
|
+
// Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
|
|
1139
|
+
// `manifestExisted` is deliberately not passed: the Python does not pass it either, so
|
|
1140
|
+
// the pre-manifest header line is unreachable from this emit on both sides.
|
|
1141
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
1142
|
+
items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
|
|
1143
|
+
loadAgentOverrides(cfgDir),
|
|
1144
|
+
{ host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
|
|
1145
|
+
for (const p of written) owned.push(relPosix(cfgDir, p));
|
|
1146
|
+
|
|
1147
|
+
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
1148
|
+
ensureMemoryIndex(path.join(cfgDir, 'memory'));
|
|
1149
|
+
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
1150
|
+
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
1151
|
+
ensureWikiStub(cfgDir);
|
|
1152
|
+
ensureRulesStub(cfgDir);
|
|
1153
|
+
ensureProfileStub(cfgDir);
|
|
1154
|
+
ensureExcludesStub(cfgDir);
|
|
1155
|
+
|
|
1156
|
+
// Project hygiene, claim-on-create: an existing (possibly user-authored) .gitignore is
|
|
1157
|
+
// never rewritten, but one we created stays owned across re-emits.
|
|
1158
|
+
if (scope === 'project') {
|
|
1159
|
+
const gi = path.join(cfgDir, '.gitignore');
|
|
1160
|
+
const giLines = (host === 'claude' ? ['settings.local.json'] : [])
|
|
1161
|
+
.concat(['wiki.jsonc', 'agent-overrides.json']);
|
|
1162
|
+
if (!existsSync(gi)) {
|
|
1163
|
+
writeText(gi, `${giLines.join('\n')}\n`);
|
|
1164
|
+
owned.push('.gitignore');
|
|
1165
|
+
} else if (oldOwned.includes('.gitignore')) {
|
|
1166
|
+
owned.push('.gitignore');
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
writeVersion(cfg, cfgDir);
|
|
1171
|
+
owned.push(VERSION_MARKER);
|
|
1172
|
+
|
|
1173
|
+
if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
|
|
1174
|
+
|
|
1175
|
+
// The last render of the emit, and the only one whose product is not written here.
|
|
1176
|
+
// Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
|
|
1177
|
+
// the condition is the merge's, kept here so RENDER produces exactly what WIRE
|
|
1178
|
+
// consumes and no more.
|
|
1179
|
+
let claudeMdText = null;
|
|
1180
|
+
if (agentText !== null && !(isBob && scope === 'global')) {
|
|
1181
|
+
claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// WIRE — see `claudeWire` below. One child per emit, so the two halves are two
|
|
1185
|
+
// functions rather than two spawns; the seam between them is a call, not a process.
|
|
1186
|
+
const managed = claudeWire(job, claudeMdText, agentText !== null);
|
|
1187
|
+
|
|
1188
|
+
return {
|
|
1189
|
+
owned,
|
|
1190
|
+
stats: { nAgents, nSkills },
|
|
1191
|
+
memStatus,
|
|
1192
|
+
nbStatus,
|
|
1193
|
+
hasAgentText: agentText !== null,
|
|
1194
|
+
claudeMdText,
|
|
1195
|
+
managed,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* `_build_global._claude_wire_py` — the files the Claude-shaped emits do NOT own: the
|
|
1201
|
+
* CLAUDE.md/AGENTS.md managed block and settings(.local).json.
|
|
1202
|
+
*
|
|
1203
|
+
* Everything the render half wrote, it wrote wholesale. Everything here reconciles
|
|
1204
|
+
* Geneseed's claim with content it did not write. The return value is `managed`, the claim
|
|
1205
|
+
* set Python records in the manifest so every teardown path unwires exactly what was wired
|
|
1206
|
+
* — which is why WIRE must precede MANIFEST, and why `managed` travels back rather than
|
|
1207
|
+
* being recomputed.
|
|
1208
|
+
*
|
|
1209
|
+
* `preambleExclude` arrives decided and is NOT derived here. Python computes it from
|
|
1210
|
+
* `_PREAMBLE_CONFIG_DIR`, which resolves through `_build_core._claude_config_dir` — an
|
|
1211
|
+
* `_OWNED` name precisely because the suite redirects it at a sandbox. Resolving `~/.claude`
|
|
1212
|
+
* on this side would write the developer's REAL CLAUDE.md path into a test's exclude list
|
|
1213
|
+
* and suppress it. Third phase running that a name had to travel rather than be re-derived
|
|
1214
|
+
* (`STRUCTURE` at P2d, `capabilityLinkRe` at P2e); this one edits a file the user co-owns.
|
|
1215
|
+
*
|
|
1216
|
+
* `GENESEED_STACK_GLOBAL` is read from the environment on BOTH sides — the child inherits
|
|
1217
|
+
* `process.env`, so the opt-out reaches here without travelling in the job. `pyTruthy` is
|
|
1218
|
+
* not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
|
|
1219
|
+
* always a string, so `''` is the only falsy value either language sees.
|
|
1220
|
+
*/
|
|
1221
|
+
function claudeWire(job, claudeMdText, hasAgentText) {
|
|
1222
|
+
phaseLog('WIRE');
|
|
1223
|
+
const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
|
|
1224
|
+
const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
|
|
1225
|
+
? oldManaged : {};
|
|
1226
|
+
const isBob = host === 'bob';
|
|
1227
|
+
const isCopilot = host === 'copilot';
|
|
1228
|
+
const managed = {};
|
|
1229
|
+
|
|
1230
|
+
// CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
|
|
1231
|
+
// prose around it survives. `claudeMdText` is the render half's product; it is null
|
|
1232
|
+
// exactly when no block is to be written.
|
|
1233
|
+
// Exception — Bob GLOBAL: Bob never auto-loads a global ~/.bob/AGENTS.md, so a global
|
|
1234
|
+
// copy is pure disk weight; none is written, and a re-emit self-heals the one an older
|
|
1235
|
+
// install carries.
|
|
1236
|
+
if (claudeMdText !== null) {
|
|
1237
|
+
managedBlockWrite(claudeMd, claudeMdText);
|
|
1238
|
+
// No sticky "whole" flag: teardown always excises the block and deletes the file only
|
|
1239
|
+
// when nothing else remains — a whole-file delete would eat prose the user added AFTER
|
|
1240
|
+
// Geneseed created the file.
|
|
1241
|
+
//
|
|
1242
|
+
// `os.path.relpath(claude_md, cfg).replace(os.sep, '/')`. Python answers '.' where
|
|
1243
|
+
// `path.relative` answers '' for an identical pair, the same guard `lawsPrefix` needs
|
|
1244
|
+
// above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
|
|
1245
|
+
// never identical) and spelled anyway, because the two call sites are 400 lines apart
|
|
1246
|
+
// and only one of them is obviously safe.
|
|
1247
|
+
const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
|
|
1248
|
+
managed.claude_md = { rel };
|
|
1249
|
+
} else if (isBob && scope === 'global' && pyTruthy(get(old, 'claude_md'))) {
|
|
1250
|
+
const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
|
|
1251
|
+
// `.resolve()` on the Python side; `path.resolve` is its counterpart and both are
|
|
1252
|
+
// applied to a path already built from an absolute cfgDir.
|
|
1253
|
+
const victim = path.resolve(cfgDir, get(oldCm, 'rel') || path.basename(claudeMd));
|
|
1254
|
+
managedBlockRemove(victim);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
|
|
1258
|
+
// settings.local.json — the personal, untracked file — never the team-shared
|
|
1259
|
+
// settings.json, which would hand every teammate failing hooks pointing at this machine's
|
|
1260
|
+
// python. (Bob documents no local variant, so it keeps settings.json.) Copilot has NO
|
|
1261
|
+
// settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
|
|
1262
|
+
// written, no settings_* keys recorded for the lifecycle to unwire.
|
|
1263
|
+
if (!isCopilot) {
|
|
1264
|
+
const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
|
|
1265
|
+
const settingsPath = path.join(cfgDir, settingsName);
|
|
1266
|
+
managed.settings_file = settingsName;
|
|
1267
|
+
// Migration: an older install wired hooks/excludes into a different file — unwire the
|
|
1268
|
+
// recorded claims there, or they linger (and run) forever.
|
|
1269
|
+
const oldSf = get(old, 'settings_file') || 'settings.json';
|
|
1270
|
+
if (oldSf !== settingsName) {
|
|
1271
|
+
unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
|
|
1272
|
+
unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
|
|
1273
|
+
}
|
|
1274
|
+
// The merge prunes recorded groups that are no longer canonical and returns the
|
|
1275
|
+
// COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
|
|
1276
|
+
// stale claims.
|
|
1277
|
+
const [, managedHooks] = mergeClaudeSettings(
|
|
1278
|
+
settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
|
|
1279
|
+
);
|
|
1280
|
+
managed.settings_hooks = managedHooks;
|
|
1281
|
+
|
|
1282
|
+
// Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
|
|
1283
|
+
// ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
|
|
1284
|
+
// Written only when this run actually emitted the project's own preamble (never
|
|
1285
|
+
// suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
|
|
1286
|
+
// it set strips a prior exclude. Bob never gets one: its bypass is the same-named
|
|
1287
|
+
// workspace rules file.
|
|
1288
|
+
const priorRaw = get(old, 'settings_excludes');
|
|
1289
|
+
const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
|
|
1290
|
+
if (scope === 'project' && hasAgentText && preambleExclude) {
|
|
1291
|
+
const wantExcl = [preambleExclude];
|
|
1292
|
+
if (process.env.GENESEED_STACK_GLOBAL) {
|
|
1293
|
+
unwireClaudeExcludes(settingsPath, wantExcl);
|
|
1294
|
+
managed.settings_excludes = [];
|
|
1295
|
+
} else {
|
|
1296
|
+
const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
|
|
1297
|
+
// Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
|
|
1298
|
+
// in unconditionally would claim a user's own pre-existing exclude, and uninstall
|
|
1299
|
+
// would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
|
|
1300
|
+
// point and so does the default `Array.sort`, which is why no comparator is passed.
|
|
1301
|
+
managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
|
|
1302
|
+
}
|
|
1303
|
+
} else if (priorExcl.length && isBob) {
|
|
1304
|
+
// Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
|
|
1305
|
+
// claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
|
|
1306
|
+
// a re-emit removes it instead of carrying it forward.
|
|
1307
|
+
unwireClaudeExcludes(settingsPath, priorExcl);
|
|
1308
|
+
} else if (priorExcl.length) {
|
|
1309
|
+
managed.settings_excludes = priorExcl;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return managed;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
// The CLI — one job in, one JSON document out
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
|
|
1319
|
+
const KINDS = {
|
|
1320
|
+
build: (cfg, job) => {
|
|
1321
|
+
build(cfg, job.theme, job.out,
|
|
1322
|
+
{ footprint: job.footprint, nativeCatalog: job.nativeCatalog });
|
|
1323
|
+
return {};
|
|
1324
|
+
},
|
|
1325
|
+
opencode: emitOpencodeRender,
|
|
1326
|
+
'opencode-global': emitOpencodeGlobalRender,
|
|
1327
|
+
claude: emitClaudeRender,
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
function main(argv) {
|
|
1331
|
+
const captured = { stdout: '', stderr: '' };
|
|
1332
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
1333
|
+
|
|
1334
|
+
// Both streams become buffers for the whole run. See the module header: this is what
|
|
1335
|
+
// makes "the protocol owns stdout" a structural property rather than a convention
|
|
1336
|
+
// every future contributor has to remember.
|
|
1337
|
+
const sink = (key) => (chunk, encoding, cb) => {
|
|
1338
|
+
captured[key] += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
|
1339
|
+
const done = typeof encoding === 'function' ? encoding : cb;
|
|
1340
|
+
if (done) done();
|
|
1341
|
+
return true;
|
|
1342
|
+
};
|
|
1343
|
+
process.stdout.write = sink('stdout');
|
|
1344
|
+
process.stderr.write = sink('stderr');
|
|
1345
|
+
|
|
1346
|
+
let payload;
|
|
1347
|
+
try {
|
|
1348
|
+
const job = parseJson(readFileSync(argv[0], 'utf8'));
|
|
1349
|
+
const run = KINDS[job.kind];
|
|
1350
|
+
if (!run) throw new Error(`unknown job kind ${JSON.stringify(job.kind)}`);
|
|
1351
|
+
payload = { ok: true, ...run(job.cfg, job) };
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
// `exitCode` marks a deliberate refusal (assertSourceComplete) whose message is
|
|
1354
|
+
// already on the captured stderr; anything else is a crash and carries its stack, so
|
|
1355
|
+
// a port bug surfaces as a diagnosable failure instead of a truncated tree.
|
|
1356
|
+
payload = e && e.exitCode
|
|
1357
|
+
? { ok: false, exit: e.exitCode }
|
|
1358
|
+
: { ok: false, exit: 1, error: (e && e.stack) || String(e) };
|
|
1359
|
+
}
|
|
1360
|
+
payload.stdout = captured.stdout;
|
|
1361
|
+
payload.stderr = captured.stderr;
|
|
1362
|
+
|
|
1363
|
+
process.stdout.write = realWrite;
|
|
1364
|
+
realWrite(JSON.stringify(payload));
|
|
1365
|
+
return 0;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Library AND entry point: the parity gates import `build` directly, and running `main`
|
|
1369
|
+
// on import would eat their argv and print a payload nobody reads. `import.meta.main` is
|
|
1370
|
+
// Node >= 24 and this machine runs v22, so the comparison is spelled out.
|
|
1371
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
1372
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1373
|
+
}
|