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/excludes.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rituals/_harness_exclude.py` — the sovereign-repo exclusion WRITER.
|
|
3
|
+
*
|
|
4
|
+
* THE READER WAS ALREADY HERE. `js/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
|
|
5
|
+
* and `EXCLUDE_DIRS` since P5a: every hook call reads `excludes.json` and stands down when
|
|
6
|
+
* cwd is under an excluded folder. This module is the other half — `harness exclude
|
|
7
|
+
* add|remove|list`, which maintains that file across every global install and wires the
|
|
8
|
+
* host-native per-repo suppression into the excluded repo.
|
|
9
|
+
*
|
|
10
|
+
* THE PYTHON WRITER STAYS, AND THAT IS A DECISION RATHER THAN AN OMISSION.
|
|
11
|
+
* `rituals/_web_actions.py:371-390` calls `excludes_snapshot()`, `exclude_add` and
|
|
12
|
+
* `exclude_remove` "verbatim: this endpoint owns no exclusion logic of its own", and the web
|
|
13
|
+
* server is P6. So until that phase there are two writers for one user-owned file. What
|
|
14
|
+
* makes that survivable is what makes it survivable for the machine-wide hook shim: the two
|
|
15
|
+
* implementations are held byte-equal by `tests/harness_golden.py`, which runs every cell
|
|
16
|
+
* against both and compares stdout, stderr, the exit code and every file left behind. What
|
|
17
|
+
* it does NOT cover is two processes writing at the same instant — `_write_excludes` is
|
|
18
|
+
* temp-plus-rename on both sides, so a concurrent pair loses one edit rather than tearing
|
|
19
|
+
* the file, which is the same guarantee the single-writer version had against itself.
|
|
20
|
+
*
|
|
21
|
+
* Every path a hook takes on this file is READ-only, so nothing here is on the PreToolUse
|
|
22
|
+
* latency budget — which is why it lives beside the CLI and not in `js/hooks.mjs`.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync }
|
|
25
|
+
from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from './emit.mjs';
|
|
28
|
+
import { wireClaudeExcludes, unwireClaudeExcludes } from './settings.mjs';
|
|
29
|
+
import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
|
|
30
|
+
import {
|
|
31
|
+
readText, writeText, parseJson, jsonDumpsIndent, normcase, pyPathStr, pyPrint, pyPrintErr,
|
|
32
|
+
} from './lib/fs.mjs';
|
|
33
|
+
|
|
34
|
+
const get = (o, k) => (o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
35
|
+
const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
36
|
+
const isOsError = (e) => Boolean(e) && typeof e.code === 'string';
|
|
37
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
38
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
39
|
+
|
|
40
|
+
/** `_global_installs` — every host whose GLOBAL config dir carries a Geneseed manifest. */
|
|
41
|
+
export function globalInstalls() {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const { host, configDir } of HOSTS) {
|
|
44
|
+
let cfg;
|
|
45
|
+
try {
|
|
46
|
+
cfg = configDir();
|
|
47
|
+
} catch {
|
|
48
|
+
continue; // a broken resolver must not kill the CLI, as the Python's bare `except`
|
|
49
|
+
}
|
|
50
|
+
if (isFile(path.join(cfg, GLOBAL_MANIFEST))) out.push({ host, cfg });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `_read_excludes` — the file, or the seeded stub's shape when it is absent or unusable. */
|
|
56
|
+
function readExcludes(cfg) {
|
|
57
|
+
try {
|
|
58
|
+
const data = parseJson(readText(path.join(cfg, EXCLUDES_FILE)));
|
|
59
|
+
if (isDict(data) && Array.isArray(get(data, 'excludes'))) return data;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
if (!isOsError(e) && !(e instanceof SyntaxError)) throw e;
|
|
62
|
+
}
|
|
63
|
+
return parseJson(EXCLUDES_STUB); // one source of truth with the seeded stub
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `_write_excludes` — atomic (temp + rename), because a torn `excludes.json` would degrade
|
|
68
|
+
* every hook to "not excluded" until repaired.
|
|
69
|
+
*
|
|
70
|
+
* `writeText`, not `writeFileSync`: `Path.write_text` translates the trailing `\n` to CRLF on
|
|
71
|
+
* Windows, and this file is compared byte-for-byte in every cell that writes one.
|
|
72
|
+
*/
|
|
73
|
+
function writeExcludes(cfg, data) {
|
|
74
|
+
const dest = path.join(cfg, EXCLUDES_FILE);
|
|
75
|
+
const tmp = `${dest}.tmp`;
|
|
76
|
+
writeText(tmp, `${jsonDumpsIndent(data)}\n`);
|
|
77
|
+
renameSync(tmp, dest);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `_canon` — `Path(p).expanduser().resolve()`. */
|
|
81
|
+
const canon = (p) => pyResolve(p);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `_same` — is this stored entry the same folder as `repo`?
|
|
85
|
+
*
|
|
86
|
+
* `pyPathStr` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
|
|
87
|
+
* `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
|
|
88
|
+
* there. Getting that backwards makes `exclude remove` unwire a repo the reference left
|
|
89
|
+
* alone.
|
|
90
|
+
*/
|
|
91
|
+
function same(stored, repo) {
|
|
92
|
+
if (typeof stored !== 'string' || !stored) return false;
|
|
93
|
+
return normcase(pyPathStr(stored)) === normcase(repo);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `exclude_add` — add `path` to every global install's list and wire the per-repo
|
|
98
|
+
* suppression. Idempotent; returns `{ok, path, messages}`.
|
|
99
|
+
*/
|
|
100
|
+
export function excludeAdd(target) {
|
|
101
|
+
const repo = canon(target);
|
|
102
|
+
const messages = [];
|
|
103
|
+
if (!isDir(repo)) messages.push(`warning: ${repo} does not exist (excluded anyway)`);
|
|
104
|
+
const installs = globalInstalls();
|
|
105
|
+
if (!installs.length) {
|
|
106
|
+
return { ok: false, path: repo,
|
|
107
|
+
messages: ['no global Geneseed install found — nothing to exclude from'] };
|
|
108
|
+
}
|
|
109
|
+
for (const { host, cfg } of installs) {
|
|
110
|
+
const data = readExcludes(cfg);
|
|
111
|
+
const prior = data.excludes.find((e) => isDict(e) && same(get(e, 'path'), repo));
|
|
112
|
+
const priorWired = (isDict(prior) ? get(prior, 'wired') : null) || {};
|
|
113
|
+
const wired = {};
|
|
114
|
+
if (host === 'claude' && isDir(repo)) {
|
|
115
|
+
const entry = pyResolve(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
|
|
116
|
+
const added = wireClaudeExcludes(
|
|
117
|
+
path.join(repo, '.claude', 'settings.local.json'), [entry]);
|
|
118
|
+
if (added.length) {
|
|
119
|
+
wired.claude_md_excludes = added;
|
|
120
|
+
} else if (get(priorWired, 'claude_md_excludes')) {
|
|
121
|
+
// Idempotent re-add: OUR own prior excludeAdd call genuinely wired this entry
|
|
122
|
+
// (confirmed by the manifest record), so ownership carries forward even though
|
|
123
|
+
// wireClaudeExcludes reports nothing NEW this time.
|
|
124
|
+
wired.claude_md_excludes = get(priorWired, 'claude_md_excludes');
|
|
125
|
+
}
|
|
126
|
+
// else: the entry was already present for some OTHER reason (e.g. a project install's
|
|
127
|
+
// own project-bypasses-global wiring, or a bailed write) — do NOT fabricate ownership,
|
|
128
|
+
// or a later excludeRemove would strip wiring we never created.
|
|
129
|
+
if (!added.length) messages.push(`${host}: claudeMdExcludes already present (kept)`);
|
|
130
|
+
}
|
|
131
|
+
if (host === 'bob' && isDir(repo)) {
|
|
132
|
+
const stub = path.join(repo, '.bob', 'rules', 'geneseed.md');
|
|
133
|
+
if (existsSync(stub)) {
|
|
134
|
+
// Ownership is decided by CONTENT, not mere existence — a re-add finds a stub that
|
|
135
|
+
// already exists because WE wrote it last time; an existence-only check would misread
|
|
136
|
+
// that as user content and orphan it on the next remove. Exact match means ours.
|
|
137
|
+
let ours;
|
|
138
|
+
try {
|
|
139
|
+
ours = readText(stub) === BOB_RULES_STUB;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (!isOsError(e)) throw e;
|
|
142
|
+
ours = false;
|
|
143
|
+
}
|
|
144
|
+
wired.bob_rules_stub = ours;
|
|
145
|
+
if (!ours) messages.push(`${host}: rules/geneseed.md already exists (kept, not ours)`);
|
|
146
|
+
} else {
|
|
147
|
+
mkdirSync(path.dirname(stub), { recursive: true });
|
|
148
|
+
writeText(stub, BOB_RULES_STUB);
|
|
149
|
+
wired.bob_rules_stub = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (host === 'copilot') {
|
|
153
|
+
messages.push('copilot: no native suppression exists — the global '
|
|
154
|
+
+ 'copilot-instructions.md still loads there (documented limitation)');
|
|
155
|
+
}
|
|
156
|
+
const entries = data.excludes.filter((e) => !(isDict(e) && same(get(e, 'path'), repo)));
|
|
157
|
+
entries.push({ path: repo.split(path.sep).join('/'), wired });
|
|
158
|
+
data.excludes = entries;
|
|
159
|
+
writeExcludes(cfg, data);
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, path: repo, messages };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `exclude_remove` — reverse `excludeAdd`: unwire exactly what `wired` recorded, then drop
|
|
166
|
+
* the entry from every install's list. Hand-deleted wiring is reported, not fatal.
|
|
167
|
+
*/
|
|
168
|
+
export function excludeRemove(target) {
|
|
169
|
+
const repo = canon(target);
|
|
170
|
+
const messages = [];
|
|
171
|
+
let found = false;
|
|
172
|
+
for (const { host, cfg } of globalInstalls()) {
|
|
173
|
+
const data = readExcludes(cfg);
|
|
174
|
+
const keep = [];
|
|
175
|
+
let mine = null;
|
|
176
|
+
for (const e of data.excludes) {
|
|
177
|
+
if (isDict(e) && same(get(e, 'path'), repo)) mine = e;
|
|
178
|
+
else keep.push(e);
|
|
179
|
+
}
|
|
180
|
+
if (mine === null) continue;
|
|
181
|
+
found = true;
|
|
182
|
+
const wired = get(mine, 'wired') || {};
|
|
183
|
+
if (get(wired, 'claude_md_excludes')) {
|
|
184
|
+
unwireClaudeExcludes(path.join(repo, '.claude', 'settings.local.json'),
|
|
185
|
+
get(wired, 'claude_md_excludes'));
|
|
186
|
+
}
|
|
187
|
+
if (get(wired, 'bob_rules_stub')) {
|
|
188
|
+
const stub = path.join(repo, '.bob', 'rules', 'geneseed.md');
|
|
189
|
+
try {
|
|
190
|
+
if (isFile(stub)) {
|
|
191
|
+
unlinkSync(stub);
|
|
192
|
+
if (!readdirSync(path.dirname(stub)).length) rmdirSync(path.dirname(stub));
|
|
193
|
+
} else {
|
|
194
|
+
messages.push(`${host}: shadow stub already gone`);
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (!isOsError(e)) throw e;
|
|
198
|
+
// KNOWN DIVERGENCE, deliberately not papered over: Python interpolates
|
|
199
|
+
// `str(OSError)` — `[Errno 13] Permission denied: '<path>'` — and `e.message` is
|
|
200
|
+
// Node's own wording. No cell can reach this branch (`unlink` on a file that
|
|
201
|
+
// `isFile` just confirmed, `rmdir` on a directory just measured empty), so a
|
|
202
|
+
// translation table here would be an unobservable guess. Same class as P5b's
|
|
203
|
+
// untested `die()` newline; `js/settings.mjs`'s `pyOsError` is the same stub for
|
|
204
|
+
// the same reason.
|
|
205
|
+
messages.push(`${host}: could not remove ${stub} (${e.message})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
data.excludes = keep;
|
|
209
|
+
writeExcludes(cfg, data);
|
|
210
|
+
}
|
|
211
|
+
if (!found) messages.push(`${repo} was not excluded`);
|
|
212
|
+
return { ok: found, path: repo, messages };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `excludes_snapshot` — the union view across installs, shared by the CLI `list` and (on the
|
|
217
|
+
* Python side) the web endpoint. Also flags installs whose lists diverge: a global emit made
|
|
218
|
+
* after the exclusions were added starts with an empty stub.
|
|
219
|
+
*/
|
|
220
|
+
export function excludesSnapshot() {
|
|
221
|
+
const installs = globalInstalls();
|
|
222
|
+
const byPath = new Map();
|
|
223
|
+
for (const { host, cfg } of installs) {
|
|
224
|
+
for (const e of readExcludes(cfg).excludes) {
|
|
225
|
+
const p = isDict(e) ? get(e, 'path') : e;
|
|
226
|
+
if (typeof p !== 'string' || !p) continue;
|
|
227
|
+
const key = normcase(pyPathStr(p));
|
|
228
|
+
if (!byPath.has(key)) {
|
|
229
|
+
byPath.set(key, { path: p, hosts: [],
|
|
230
|
+
wired: (isDict(e) ? get(e, 'wired') : null) || {} });
|
|
231
|
+
}
|
|
232
|
+
byPath.get(key).hosts.push(host);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const excludes = [...byPath.values()].sort(
|
|
236
|
+
(a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
237
|
+
return { excludes, installs: installs.map(({ host, cfg }) => ({ host, cfg })) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `cmd_exclude`. */
|
|
241
|
+
export function cmdExclude(args) {
|
|
242
|
+
if (args.action === 'list') {
|
|
243
|
+
const snap = excludesSnapshot();
|
|
244
|
+
if (!snap.installs.length) {
|
|
245
|
+
pyPrint('[geneseed] no global install found.\n');
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
if (!snap.excludes.length) {
|
|
249
|
+
pyPrint('[geneseed] no folders excluded.\n');
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
const allHosts = new Set(snap.installs.map((i) => i.host));
|
|
253
|
+
for (const rec of snap.excludes) {
|
|
254
|
+
const missing = [...allHosts].filter((h) => !rec.hosts.includes(h)).sort();
|
|
255
|
+
const flag = missing.length
|
|
256
|
+
? ` (MISSING from: ${missing.join(', ')} — re-run \`harness exclude add\`)` : '';
|
|
257
|
+
pyPrint(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
if (!args.path) {
|
|
262
|
+
pyPrintErr('[geneseed] exclude add/remove needs a folder path.\n');
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
const res = args.action === 'add' ? excludeAdd(args.path) : excludeRemove(args.path);
|
|
266
|
+
for (const m of res.messages) pyPrintErr(`[geneseed] ${m}\n`);
|
|
267
|
+
const verb = args.action === 'add' ? 'excluded' : 're-included';
|
|
268
|
+
pyPrint(res.ok ? `[geneseed] ${res.path} ${verb}.\n`
|
|
269
|
+
: `[geneseed] nothing done for ${res.path}.\n`);
|
|
270
|
+
return res.ok ? 0 : 1;
|
|
271
|
+
}
|
package/js/generate.mjs
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rituals/_harness_build.py`'s three cheap verbs — `build`, `prompt` and `theme`.
|
|
3
|
+
*
|
|
4
|
+
* These are the harness's GENERATOR FACE: the subcommands whose whole job is to drive the
|
|
5
|
+
* thing `bin/geneseed.mjs` already is. That is why they are the cheapest slice left and why
|
|
6
|
+
* they are the last one that can be measured by a closure walk alone — `cmd_build`'s body is
|
|
7
|
+
* three lines because the work is a `subprocess`, and a name walk cannot see through one.
|
|
8
|
+
*
|
|
9
|
+
* WHY `build` IMPORTS THE DRIVER RATHER THAN SPAWNING IT. The Python is
|
|
10
|
+
* `run([sys.executable, BUILD, *extra]).returncode` — a passthrough to a second process.
|
|
11
|
+
* Reproducing that shape here would mean spawning `node bin/geneseed.mjs`, and
|
|
12
|
+
* `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
|
|
13
|
+
* entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
|
|
14
|
+
* will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
|
|
15
|
+
* a verb that does not. It is also the wrong shape on its own terms. Python needs a second
|
|
16
|
+
* process because `build.py` is a different PROGRAM; `bin/geneseed.mjs` is a module in this
|
|
17
|
+
* one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
|
|
18
|
+
* the passthrough the whole port has been removing. So the driver's `main` is exported and
|
|
19
|
+
* called directly, and the observable contract — the tree, the streams, the exit code — is
|
|
20
|
+
* what `build/*` in `tests/harness_golden.py` compares.
|
|
21
|
+
*
|
|
22
|
+
* WHAT NO CELL CAN REACH HERE, stated rather than left implicit. `fenceFor`'s real arm is
|
|
23
|
+
* unreachable from the matrix: measured over the whole rendered tree, the longest backtick
|
|
24
|
+
* run in any source text is 3, so `max(4, longest + 1)` picks 4 for all 96 files, and a port
|
|
25
|
+
* that hardcoded four backticks is byte-identical in every prompt cell. Reaching the other
|
|
26
|
+
* arm needs a file in `src/`, and `src/` is the tree no fixture can redirect (P5d). It is
|
|
27
|
+
* therefore gated as a pure function over a corpus in `tests/snapshot/pure_snapshot.test.mjs`, with the
|
|
28
|
+
* unreachability measured in BOTH directions rather than asserted: one test proves the corpus
|
|
29
|
+
* leaves the floor, and one re-renders every theme × posture × mode of the live tree and proves
|
|
30
|
+
* `src/` still does not. The 96 above is one render — the sweep is 13 440 files.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { main as driverMain } from '../bin/geneseed.mjs';
|
|
36
|
+
import { makeCfg } from './checkout.mjs';
|
|
37
|
+
import { opencodeConfigDir, pyResolve } from './hosts.mjs';
|
|
38
|
+
import {
|
|
39
|
+
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, footprintOfDir, installState,
|
|
40
|
+
installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
|
|
41
|
+
} from './installs.mjs';
|
|
42
|
+
import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
|
|
43
|
+
import { renderAll } from './render.mjs';
|
|
44
|
+
import {
|
|
45
|
+
jsonDumpsIndent, parseJson, pyPrint, pyPrintErr, pyRepr, readText, writeText,
|
|
46
|
+
} from './lib/fs.mjs';
|
|
47
|
+
|
|
48
|
+
/** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
|
|
49
|
+
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `raise SystemExit(msg)` — the message on stderr, exit 1.
|
|
53
|
+
*
|
|
54
|
+
* The convention `js/emit.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
|
|
55
|
+
* honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
|
|
56
|
+
* and let anything unmarked keep its stack. Python prints a traceback for an unhandled
|
|
57
|
+
* exception, so dressing a crash as a one-liner would hide a bug.
|
|
58
|
+
*/
|
|
59
|
+
function sysExit(msg) {
|
|
60
|
+
// `pyPrintErr`, NOT a local newline translation. The first draft inlined the `\n` ->
|
|
61
|
+
// `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
|
|
62
|
+
// `subprocess`'s universal-newline decode and cannot see the difference — the same
|
|
63
|
+
// transport hole P5b measured. A second copy of a translation is exactly what P5d's
|
|
64
|
+
// `pyStrPath`/`pyPathStr` split was, and it is ungated in precisely the same way.
|
|
65
|
+
pyPrintErr(`${msg}\n`);
|
|
66
|
+
const e = new Error(msg);
|
|
67
|
+
e.exitCode = 1;
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
72
|
+
|
|
73
|
+
// --------------------------------------------------------------------------------------
|
|
74
|
+
// build
|
|
75
|
+
// --------------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `_harness_build.cmd_build` — render `src/` into `./Harness`.
|
|
79
|
+
*
|
|
80
|
+
* `--out` defaults to the RELATIVE `"Harness"` in the generator and the Python passes no
|
|
81
|
+
* `cwd=`, so the bundle lands under the invocation's directory rather than the checkout's.
|
|
82
|
+
* Calling the driver in-process keeps that: `resolveOut` resolves against `process.cwd()`
|
|
83
|
+
* the same way. `build/the-bundle-follows-cwd-not-the-checkout` is the cell.
|
|
84
|
+
*
|
|
85
|
+
* Every other flag is the generator's own default, read from `harness.config.json` by the
|
|
86
|
+
* driver — `harness build` forwards `--theme` and nothing else, and its own `--theme`
|
|
87
|
+
* carries no `choices`, so an unknown one is refused by the generator rather than here.
|
|
88
|
+
*/
|
|
89
|
+
export function cmdBuild(args) {
|
|
90
|
+
return driverMain(args.theme ? ['--theme', args.theme] : []);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --------------------------------------------------------------------------------------
|
|
94
|
+
// rebuild-all
|
|
95
|
+
// --------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `_harness_setup._setup_build_args` — the generator argv for one selection. Pure.
|
|
99
|
+
*
|
|
100
|
+
* The elision rules are not symmetric and the Python says why: `footprint` is ALWAYS passed,
|
|
101
|
+
* because omitting it when it matched the generator's default silently coupled the answer to
|
|
102
|
+
* whatever that default happened to be — the moment the default moved, picking the old one
|
|
103
|
+
* produced the new one. `posture` and `mode` still elide at theirs, and neither has moved.
|
|
104
|
+
*/
|
|
105
|
+
export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
|
|
106
|
+
posture = 'peer', mode = 'direct') {
|
|
107
|
+
const argv = ['--theme', theme, '--emit', emit];
|
|
108
|
+
if (!['opencode-global', 'claude-global', 'bob-global', 'copilot-global'].includes(emit)) {
|
|
109
|
+
if (out) argv.push('--out', out);
|
|
110
|
+
if (root) argv.push('--root', root);
|
|
111
|
+
}
|
|
112
|
+
if (footprint) argv.push('--footprint', footprint);
|
|
113
|
+
if (posture && posture !== 'peer') argv.push('--posture', posture);
|
|
114
|
+
if (mode && mode !== 'direct') argv.push('--mode', mode);
|
|
115
|
+
return argv;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `_harness_build._DEFAULT_EMIT` — the emit to use for an install carrying NO marker.
|
|
120
|
+
*
|
|
121
|
+
* A pre-marker install still has a (host, scope), which is what the walk found it by, so it
|
|
122
|
+
* is rebuilt in its own mode rather than defaulted to OpenCode.
|
|
123
|
+
*/
|
|
124
|
+
export const DEFAULT_EMIT = new Map([
|
|
125
|
+
['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
|
|
126
|
+
['claude global', 'claude-global'], ['claude project', 'claude'],
|
|
127
|
+
['bob global', 'bob-global'], ['bob project', 'bob'],
|
|
128
|
+
['copilot global', 'copilot-global'], ['copilot project', 'copilot'],
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `_harness_build.cmd_rebuild_all` — re-emit every ACTIVE install in place, best-effort.
|
|
133
|
+
*
|
|
134
|
+
* WHY THIS IS THE VERB THAT MADE THE DRIVER'S `die` A THROW. The Python spawns one
|
|
135
|
+
* `build.py` per install and reads its return code, so "continue past a failure" is what a
|
|
136
|
+
* subprocess gives you for nothing. Calling `main` in-process — the route P5e established for
|
|
137
|
+
* `build`, and the only one the `child_process` ban leaves — makes a `process.exit` inside
|
|
138
|
+
* the generator's flag parser end the whole loop instead of one install. So `die` throws and
|
|
139
|
+
* `main` converts, and `rebuild-all/one-broken-install-does-not-stop-the-rest` is the cell
|
|
140
|
+
* that fails if either half is undone.
|
|
141
|
+
*
|
|
142
|
+
* FIVE VALUES ARE READ BACK OFF EACH INSTALL, not defaulted. Theme and emit and footprint
|
|
143
|
+
* come from markers; posture and mode are detected from the `**<Name>**` lead in the
|
|
144
|
+
* deployed carrier's prose, because they were never markered. A rebuild that defaulted any
|
|
145
|
+
* of them would silently re-emit an install into something its owner did not choose — which
|
|
146
|
+
* is the same failure mode `_footprint_of_dir`'s WARN exists to prevent, at a larger scale.
|
|
147
|
+
*
|
|
148
|
+
* THE MARKER IS TRUSTED ONLY FOR ITS OWN HOST. There is one `.geneseed-emit` per root and the
|
|
149
|
+
* last deploy wins, so in a dual-host repo (`.opencode/` and `.claude/` in one cwd) the other
|
|
150
|
+
* host's row would otherwise rebuild with the WRONG emit — turning a Claude install into an
|
|
151
|
+
* OpenCode one. `rebuild-all/a-dual-host-repo-rebuilds-each-row-in-its-own-emit` is the cell.
|
|
152
|
+
*/
|
|
153
|
+
export function cmdRebuildAll() {
|
|
154
|
+
const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
|
|
155
|
+
if (!targets.length) {
|
|
156
|
+
pyPrint('[rebuild-all] no active installs detected.\n');
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
const failures = [];
|
|
160
|
+
for (const [host, scope, root] of targets) {
|
|
161
|
+
const raw = readMaybe(path.join(root, '.geneseed-emit'));
|
|
162
|
+
let marker = raw === null ? '' : raw.trim();
|
|
163
|
+
if (marker && (EMIT_HOST_SCOPE.get(marker) ?? ['', ''])[0] !== host) marker = '';
|
|
164
|
+
const emit = marker || DEFAULT_EMIT.get(`${host} ${scope}`) || 'opencode-global';
|
|
165
|
+
const theme = themeOfDir(root) || defaultTheme();
|
|
166
|
+
const footprint = footprintOfDir(root);
|
|
167
|
+
const posture = postureOfDir(root) || defaultPosture();
|
|
168
|
+
const mode = modeOfDir(root) || defaultMode();
|
|
169
|
+
const out = scope === 'global' ? null : root;
|
|
170
|
+
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode);
|
|
171
|
+
const label = `${host}:${scope} (${root})`;
|
|
172
|
+
pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
|
|
173
|
+
+ `posture=${posture} mode=${mode}\n`);
|
|
174
|
+
const rc = driverMain(argv);
|
|
175
|
+
if (rc !== 0) {
|
|
176
|
+
failures.push(label);
|
|
177
|
+
pyPrintErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (failures.length) {
|
|
181
|
+
pyPrintErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
|
|
182
|
+
+ `${failures.join(', ')}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
pyPrint(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --------------------------------------------------------------------------------------
|
|
190
|
+
// prompt
|
|
191
|
+
// --------------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `_harness_build._fence_for` — a backtick fence longer than the longest run inside `text`,
|
|
195
|
+
* so an embedded code fence can never close the wrapper. Minimum four.
|
|
196
|
+
*
|
|
197
|
+
* Iterates by CODE UNIT deliberately: a backtick is ASCII, so `for...of` over code points
|
|
198
|
+
* would only cost a surrogate decode per astral character in a 400 KB document, and the
|
|
199
|
+
* count is identical either way.
|
|
200
|
+
*/
|
|
201
|
+
export function fenceFor(text) {
|
|
202
|
+
let longest = 0;
|
|
203
|
+
let run = 0;
|
|
204
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
205
|
+
run = text[i] === '`' ? run + 1 : 0;
|
|
206
|
+
if (run > longest) longest = run;
|
|
207
|
+
}
|
|
208
|
+
return '`'.repeat(Math.max(4, longest + 1));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `_harness_build.build_prompt` — the whole rendered tree as one install document. */
|
|
212
|
+
export function buildPrompt(cfg, themeName) {
|
|
213
|
+
const { items } = renderAll(cfg, themeName);
|
|
214
|
+
const nText = items.filter((it) => it.text !== null).length;
|
|
215
|
+
const out = [
|
|
216
|
+
`# Geneseed Harness — install prompt (theme: ${themeName})`,
|
|
217
|
+
'',
|
|
218
|
+
'You are an AI agent. Recreate the Geneseed harness file tree below, writing',
|
|
219
|
+
'every file **verbatim**. No Python or build step is required.',
|
|
220
|
+
'',
|
|
221
|
+
'## Target directory',
|
|
222
|
+
'Write all files under the directory the user specifies. If none was given, ask',
|
|
223
|
+
'for it, defaulting to the current repository root. Preserve the exact relative',
|
|
224
|
+
'path shown in each file heading, creating subfolders as needed.',
|
|
225
|
+
'',
|
|
226
|
+
'## Rules',
|
|
227
|
+
"- Copy each file's content exactly — do not summarise, reflow, or edit it.",
|
|
228
|
+
'- After writing, create an empty context.json at the repo root if absent, and list the repo\'s docs in it.',
|
|
229
|
+
'- When finished, list every file you created.',
|
|
230
|
+
'',
|
|
231
|
+
`## Files (${nText} text files)`,
|
|
232
|
+
];
|
|
233
|
+
for (const { rel, text } of items) {
|
|
234
|
+
if (text === null) {
|
|
235
|
+
out.push(`\n### \`${rel}\` (binary — copy it from the Geneseed repo)`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const fence = fenceFor(text);
|
|
239
|
+
// `text.rstrip("\n")` — trailing newlines ONLY, so a file ending in spaces keeps them.
|
|
240
|
+
out.push(`\n### \`${rel}\``, '', fence, text.replace(/\n+$/, ''), fence);
|
|
241
|
+
}
|
|
242
|
+
return `${out.join('\n')}\n`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `_harness_build.cmd_prompt`.
|
|
247
|
+
*
|
|
248
|
+
* Both output paths translate newlines: the Python writes the document through
|
|
249
|
+
* `sys.stdout.write` and `Path.write_text`, and BOTH are `newline=None` translators on
|
|
250
|
+
* Windows. At ~10,000 newlines in a 400 KB document that is the largest byte difference this
|
|
251
|
+
* port could ship, and `harness_golden` can only see half of it — the FILE half is compared
|
|
252
|
+
* directly, the stdout half is folded by `subprocess`'s universal-newline decode, which is
|
|
253
|
+
* why `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `prompt` row.
|
|
254
|
+
*/
|
|
255
|
+
export function cmdPrompt(args) {
|
|
256
|
+
// `args.theme or "neutral"` — a literal in the Python, NOT the config's theme, so this
|
|
257
|
+
// deliberately does not go through `configDefaults()`.
|
|
258
|
+
const themeName = args.theme || 'neutral';
|
|
259
|
+
const text = buildPrompt(makeCfg(), themeName);
|
|
260
|
+
if (args.out) {
|
|
261
|
+
const dest = path.dirname(args.out);
|
|
262
|
+
if (dest) mkdirSync(dest, { recursive: true });
|
|
263
|
+
writeText(args.out, text);
|
|
264
|
+
// The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
|
|
265
|
+
pyPrint(`[prompt] wrote ${args.out} (${themeName})\n`);
|
|
266
|
+
} else {
|
|
267
|
+
pyPrint(text);
|
|
268
|
+
}
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------------------------------
|
|
273
|
+
// theme
|
|
274
|
+
// --------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
|
|
277
|
+
export function resolveThemesDir(args) {
|
|
278
|
+
// `Path(args.dir).expanduser().resolve()` — `pyResolve` already expands a leading `~`.
|
|
279
|
+
if (args.dir) return pyResolve(args.dir);
|
|
280
|
+
const repo = path.join(process.cwd(), '.opencode');
|
|
281
|
+
if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
|
|
282
|
+
return path.join(opencodeConfigDir(), 'themes');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `_harness_build._load_user_palette` — `--from` seeds, `--palette` overlays, then validate.
|
|
287
|
+
*
|
|
288
|
+
* The palette is a **Map** because `pal`'s iteration order is OBSERVABLE: it is the order
|
|
289
|
+
* of the bad-value list in the refusal message, and JS hoists integer-like keys to the front
|
|
290
|
+
* of a plain object while Python's dict keeps insertion order (the P3a hazard).
|
|
291
|
+
*
|
|
292
|
+
* THE MAP IS NOT A COMPLETE FIX AND SAYING SO IS THE POINT. `JSON.parse` has already
|
|
293
|
+
* reordered by the time anything here runs — measured, not assumed: its reviver is invoked
|
|
294
|
+
* in the hoisted order too, so no parse-time hook can recover the text's order. The Map
|
|
295
|
+
* only preserves what it is handed. A palette carrying an integer-like key AND a second bad
|
|
296
|
+
* value therefore still diverges:
|
|
297
|
+
*
|
|
298
|
+
* {"zzz": "y", "0": "x"} python: zzz='y', 0='x' node: 0='x', zzz='y'
|
|
299
|
+
*
|
|
300
|
+
* That is this port's oldest known difference becoming REACHABLE for the first time — every
|
|
301
|
+
* phase since P3a has carried it as a hazard no cell could reach. It is recorded rather than
|
|
302
|
+
* fixed: recovering source order means hand-scanning the JSON text, which is a reproduction
|
|
303
|
+
* of a parser (P5c's rule: those need a corpus, and this one would need one for every
|
|
304
|
+
* escaping rule) traded for the ORDER OF TWO NAMES in an error message that both
|
|
305
|
+
* implementations still emit, for the same values, with the same exit code.
|
|
306
|
+
*/
|
|
307
|
+
export function loadUserPalette(args, cfg) {
|
|
308
|
+
const pal = new Map();
|
|
309
|
+
if (args.fromTheme) {
|
|
310
|
+
const src = path.join(cfg.colorThemes, `${args.fromTheme}.json`);
|
|
311
|
+
if (!existsSync(src) || !statSync(src).isFile()) {
|
|
312
|
+
// `p.stem` — the filename without its extension, in `color_theme_files()` order.
|
|
313
|
+
const avail = colorThemeFiles(cfg)
|
|
314
|
+
.map((p) => path.basename(p, path.extname(p))).join(', ');
|
|
315
|
+
sysExit(`[theme] no shipped theme '${args.fromTheme}'. available: ${avail}`);
|
|
316
|
+
}
|
|
317
|
+
const spec = parseJson(readText(src));
|
|
318
|
+
for (const [k, v] of Object.entries(spec.palette ?? {})) pal.set(k, v);
|
|
319
|
+
}
|
|
320
|
+
if (args.palette) {
|
|
321
|
+
// `Path(args.palette)` — relative to cwd, with no expanduser and no resolve.
|
|
322
|
+
const raw = parseJson(readText(args.palette));
|
|
323
|
+
// `raw.get("palette", raw)` — a document with no "palette" key IS the role map.
|
|
324
|
+
const map = Object.prototype.hasOwnProperty.call(raw, 'palette') ? raw.palette : raw;
|
|
325
|
+
for (const [k, v] of Object.entries(map)) pal.set(k, v);
|
|
326
|
+
}
|
|
327
|
+
if (pal.size === 0) {
|
|
328
|
+
sysExit('[theme] need a palette: pass --from <shipped> and/or --palette <file.json>');
|
|
329
|
+
}
|
|
330
|
+
const missing = [...PALETTE_ROLES].filter((r) => !pal.has(r)).sort();
|
|
331
|
+
if (missing.length) {
|
|
332
|
+
sysExit(`[theme] palette missing role(s): ${missing.join(', ')} `
|
|
333
|
+
+ '(see themes/opencode/README.md; --from seeds them all)');
|
|
334
|
+
}
|
|
335
|
+
// `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
|
|
336
|
+
const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
|
|
337
|
+
.map(([r, v]) => `${r}=${pyRepr(v)}`);
|
|
338
|
+
if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
|
|
339
|
+
return pal;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `_harness_build.cmd_theme` — a user colour theme in both flavours, into the live dir.
|
|
344
|
+
*
|
|
345
|
+
* The palette is validated BEFORE the destination is resolved and before `mkdir`, so a
|
|
346
|
+
* refusal leaves no directory behind — matching the Python's statement order.
|
|
347
|
+
*
|
|
348
|
+
* **THAT ORDER IS NOT OBSERVABLE, and the first draft of this comment claimed it was.**
|
|
349
|
+
* Swapping the two halves so a refusal creates the directory first is GREEN across all 166
|
|
350
|
+
* cells (M16), because `golden._snapshot` walks FILES and an empty directory leaves no file
|
|
351
|
+
* to compare. So the order is preserved because the reference has it, not because anything
|
|
352
|
+
* checks — INDISTINGUISHABLE rather than unreachable, and closing it would mean a sixth
|
|
353
|
+
* expectation kind (`expect_absent_files`) for one empty directory.
|
|
354
|
+
* `theme/an-explicit-dir-is-created` is the positive control that the `mkdir` happens at all.
|
|
355
|
+
*/
|
|
356
|
+
export function cmdTheme(args) {
|
|
357
|
+
const name = args.name;
|
|
358
|
+
const full = name.startsWith('geneseed-') ? name : `geneseed-${name}`;
|
|
359
|
+
const cfg = makeCfg();
|
|
360
|
+
const pal = loadUserPalette(args, cfg);
|
|
361
|
+
const destDir = resolveThemesDir(args);
|
|
362
|
+
mkdirSync(destDir, { recursive: true });
|
|
363
|
+
const flavours = args.solidOnly ? [['', false]]
|
|
364
|
+
: args.transparentOnly ? [['-transparent', true]]
|
|
365
|
+
: [['', false], ['-transparent', true]];
|
|
366
|
+
const written = [];
|
|
367
|
+
for (const [suffix, transparent] of flavours) {
|
|
368
|
+
const dest = path.join(destDir, `${full}${suffix}.json`);
|
|
369
|
+
// `Object.fromEntries` hoists integer-like keys too — harmless here and nowhere else,
|
|
370
|
+
// because `colorThemeJson` only LOOKS UP the fixed role names and never iterates the
|
|
371
|
+
// palette; its own output order comes from `SLOT_ROLE`.
|
|
372
|
+
writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
|
|
373
|
+
written.push(dest);
|
|
374
|
+
}
|
|
375
|
+
pyPrint(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
|
|
376
|
+
pyPrint(`[theme] select in OpenCode with: /theme ${full}`
|
|
377
|
+
+ (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
|
|
378
|
+
return 0;
|
|
379
|
+
}
|