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/hosts.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four host config dirs, and the two path primitives they are built on.
|
|
3
|
+
*
|
|
4
|
+
* EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/geneseed.mjs`
|
|
5
|
+
* owned these; `bin/geneseed-cli.mjs` needs the same four to find a global install, and a
|
|
6
|
+
* resolver that decides WHERE a driver writes is the last thing that should exist twice.
|
|
7
|
+
* `js/hooks.mjs` carries a third copy of `opencodeConfigDir` on purpose — the hook path is
|
|
8
|
+
* deliberately dependency-free and says so — but the CLI has no such reason, so this is one
|
|
9
|
+
* owner for both drivers rather than a second exception.
|
|
10
|
+
*
|
|
11
|
+
* The move is a pure one, which is why it could be made at all: `tests/golden.py` runs
|
|
12
|
+
* `bin/geneseed.mjs` over 259 cells and compares the tree byte-for-byte, and roughly half
|
|
13
|
+
* of those resolve a global config dir. A move that changed one of these by a character
|
|
14
|
+
* fails ~126 cells, not zero.
|
|
15
|
+
*/
|
|
16
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import { pyPathStr, pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
|
|
20
|
+
|
|
21
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
22
|
+
|
|
23
|
+
/** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
|
|
24
|
+
export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/emit.mjs` and
|
|
28
|
+
* `js/diff.mjs` each had a private const of their own and `js/uninstall.mjs` would have made
|
|
29
|
+
* a third copy of a value whose whole job is being the same string everywhere. Same
|
|
30
|
+
* arithmetic that put `GLOBAL_MANIFEST` above it.
|
|
31
|
+
*/
|
|
32
|
+
export const VERSION_MARKER = '.geneseed-version';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `Path.expanduser()` — a LEADING `~` only, and `~` alone counts.
|
|
36
|
+
*
|
|
37
|
+
* The docblock here used to claim "Python does not expand `~user` on Windows the way a
|
|
38
|
+
* shell would". That claim was FALSE, and `921384f`'s widened probe corpus caught it:
|
|
39
|
+
* `ntpath.expanduser('~someuser/x')` swaps the last component of `%USERPROFILE%` and
|
|
40
|
+
* returns `C:\Users\someuser\x` — it happily expands a name that may not even be a real
|
|
41
|
+
* account.
|
|
42
|
+
*
|
|
43
|
+
* AND THE POSIX HALF OF THAT SENTENCE WAS WRONG IN ITS TURN, corrected here while the
|
|
44
|
+
* reference is still on the machine to ask. `posixpath.expanduser` does the analogous thing
|
|
45
|
+
* through the password database and returns the path unchanged when the account is unknown
|
|
46
|
+
* — but the reference does not call it. It calls `Path.expanduser`, which re-checks the
|
|
47
|
+
* result and RAISES `RuntimeError: Could not determine home directory.` So the divergence
|
|
48
|
+
* below is WINDOWS-ONLY: on POSIX the reference and this port already agree in kind — both
|
|
49
|
+
* refuse — and differ only in which exception says so.
|
|
50
|
+
*
|
|
51
|
+
* This port deliberately does NEITHER. Reproducing `ntpath`'s swap faithfully needs its own
|
|
52
|
+
* corpus, and the Python reference is being deleted, so byte-parity on this one input stops
|
|
53
|
+
* mattering the moment it is. What matters instead is that a `~user` form never resolves
|
|
54
|
+
* SILENTLY to the wrong place: the old code below returned it untouched, so
|
|
55
|
+
* `--target ~someone/x` created a literal `~someone` directory in whatever cwd the user ran
|
|
56
|
+
* from. It now REFUSES the form outright. This is the single primitive every config-dir env
|
|
57
|
+
* var and every `--target`/`--dir`/`--bundle`/`--out` argument passes through on the way to
|
|
58
|
+
* becoming a filesystem path, so refusing here — rather than at each of those call sites —
|
|
59
|
+
* is one guard instead of several, and none of the paths that already exist there. `~` alone
|
|
60
|
+
* and `~/…`/`~\…` are unaffected; only a name after the tilde is rejected.
|
|
61
|
+
*/
|
|
62
|
+
export function expanduser(p) {
|
|
63
|
+
if (p === '~') return os.homedir();
|
|
64
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
|
|
65
|
+
if (p.startsWith('~')) {
|
|
66
|
+
const msg = `refusing '${p}': a '~user' path is not expanded by this port (Python's `
|
|
67
|
+
+ "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
|
|
68
|
+
+ 'pass an absolute path, or "~" / "~/…" for your own home directory';
|
|
69
|
+
pyPrintErr(`${msg}\n`);
|
|
70
|
+
const e = new Error(msg);
|
|
71
|
+
e.exitCode = 1;
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `Path.resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
|
|
79
|
+
*
|
|
80
|
+
* `path.resolve` does none of the last two and `realpathSync` THROWS on a path that does not
|
|
81
|
+
* exist yet, which is the normal case here: the first `--emit opencode-global` on a machine
|
|
82
|
+
* resolves a config dir nobody has created. Python's `resolve(strict=False)` canonicalises
|
|
83
|
+
* the part that exists and appends the rest verbatim, so that is what this reproduces.
|
|
84
|
+
* It matters because the resolved directory is printed on stdout and compared byte-for-byte.
|
|
85
|
+
*/
|
|
86
|
+
export function pyResolve(p) {
|
|
87
|
+
let cur = path.resolve(expanduser(p));
|
|
88
|
+
const tail = [];
|
|
89
|
+
for (;;) {
|
|
90
|
+
try {
|
|
91
|
+
return tail.length ? path.join(realpathSync.native(cur), ...tail) : realpathSync.native(cur);
|
|
92
|
+
} catch {
|
|
93
|
+
const parent = path.dirname(cur);
|
|
94
|
+
if (parent === cur) return path.resolve(expanduser(p)); // nothing on this path exists
|
|
95
|
+
tail.unshift(path.basename(cur));
|
|
96
|
+
cur = parent;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `_build_core._opencode_config_dir` — and the four resolvers like it are the reason
|
|
103
|
+
* `bin/geneseed.mjs` exists rather than a child doing the work.
|
|
104
|
+
*
|
|
105
|
+
* P3c's rule was "the child must never resolve this": a render child that did would write
|
|
106
|
+
* 135 files into the developer's real `~/.config/opencode`. A driver is the PARENT, so the
|
|
107
|
+
* rule inverts — resolving it there is exactly the job. Precedence is the env var (which
|
|
108
|
+
* relocates the whole dir), then `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`.
|
|
109
|
+
*/
|
|
110
|
+
export function opencodeConfigDir() {
|
|
111
|
+
const env = process.env.OPENCODE_CONFIG_DIR;
|
|
112
|
+
if (env) return pyResolve(env);
|
|
113
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
114
|
+
const base = xdg ? expanduser(xdg) : path.join(os.homedir(), '.config');
|
|
115
|
+
return pyResolve(path.join(base, 'opencode'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `_build_core._copilot_config_dir` — `~/.copilot`, relocatable via `$COPILOT_CONFIG_DIR`.
|
|
120
|
+
*
|
|
121
|
+
* Geneseed's own knob, mirroring `$BOB_CONFIG_DIR`: Copilot documents no such variable, but
|
|
122
|
+
* tests, doctor and locked-down setups still need to re-point the target. Like every
|
|
123
|
+
* relocation variable it is CLEARED by `golden.cell_env`, so no cell can observe whether
|
|
124
|
+
* a driver honours it — see `test_the_relocation_var_moves_the_global_target`.
|
|
125
|
+
*/
|
|
126
|
+
export function copilotConfigDir() {
|
|
127
|
+
const env = process.env.COPILOT_CONFIG_DIR;
|
|
128
|
+
if (env) return pyResolve(env);
|
|
129
|
+
return pyResolve(path.join(os.homedir(), '.copilot'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `_build_core._claude_config_dir` — `~/.claude`, and there is NO env branch BY DESIGN.
|
|
134
|
+
*
|
|
135
|
+
* Its three siblings all check a `*_CONFIG_DIR` variable first; this one does not, because
|
|
136
|
+
* Claude Code documents none and inventing one here would make the two CLIs disagree about
|
|
137
|
+
* where a global install lives. The absence is the specification, so it is asserted rather
|
|
138
|
+
* than merely not implemented — `test_every_relocation_var_moves_its_global_target` carries
|
|
139
|
+
* an INVERSE row for this host: setting `$CLAUDE_CONFIG_DIR` must NOT move the target.
|
|
140
|
+
* Without that row the table could only say "no cell covers Claude", which reads the same
|
|
141
|
+
* as an omission.
|
|
142
|
+
*/
|
|
143
|
+
export function claudeConfigDir() {
|
|
144
|
+
return pyResolve(path.join(os.homedir(), '.claude'));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `_build_core._bob_config_dir` — `~/.bob`, relocatable via `$BOB_CONFIG_DIR`. */
|
|
148
|
+
export function bobConfigDir() {
|
|
149
|
+
const env = process.env.BOB_CONFIG_DIR;
|
|
150
|
+
if (env) return pyResolve(env);
|
|
151
|
+
return pyResolve(path.join(os.homedir(), '.bob'));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `_build_global.HOSTS`, reduced to the two columns a non-emitting caller needs, and IN ITS
|
|
156
|
+
* ORDER — opencode, claude, bob, copilot.
|
|
157
|
+
*
|
|
158
|
+
* An array rather than an object because the order is observable output, not an
|
|
159
|
+
* implementation detail: `harness exclude add` walks it and prints one message per host, so
|
|
160
|
+
* a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
|
|
161
|
+
* `bin/geneseed.mjs` receives those decisions from its own dispatch rather than reading a
|
|
162
|
+
* table.)
|
|
163
|
+
*
|
|
164
|
+
* `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
|
|
165
|
+
* install of host H", and `_claude_cfg` asks "which subdir holds a project install's
|
|
166
|
+
* manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.github` value the Python reads
|
|
167
|
+
* out of `build.HOSTS[host]["project_marker"]`, so it belongs beside the config dir rather
|
|
168
|
+
* than in a second table in `js/installs.mjs`.
|
|
169
|
+
*
|
|
170
|
+
* `agentFile` joined in P5h, and for one caller: `cmd_uninstall` names the managed block's
|
|
171
|
+
* carrier in its "removes:" preamble (`the CLAUDE.md managed block`, `the AGENTS.md managed
|
|
172
|
+
* block`). It is a column and not a literal in the message for the reason every other column
|
|
173
|
+
* here is one — Bob and Copilot both answer `AGENTS.md` while Claude answers `CLAUDE.md`, and
|
|
174
|
+
* a host added later must not need the message edited to stay true.
|
|
175
|
+
*/
|
|
176
|
+
export const HOSTS = [
|
|
177
|
+
{ host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
|
|
178
|
+
{ host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
|
|
179
|
+
{ host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
|
|
180
|
+
{ host: 'copilot', configDir: copilotConfigDir, projectMarker: '.github', agentFile: 'AGENTS.md' },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
|
|
185
|
+
* model by itself?
|
|
186
|
+
*
|
|
187
|
+
* The third column of `HOSTS`, and the reason the docblock above no longer says
|
|
188
|
+
* `native_catalog` stays out: `doctor`'s `_rendered_problems` has to know, because a host
|
|
189
|
+
* that catalogues natively gets AGENT.md's tables collapsed to a pointer and comparing the
|
|
190
|
+
* portable shape against its bundle reports AGENT.md stale on every run forever. It is a
|
|
191
|
+
* column and not a set literal so that the capability is still declared in exactly one place
|
|
192
|
+
* — the Python's own reason for the helper.
|
|
193
|
+
*
|
|
194
|
+
* Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
|
|
195
|
+
* user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
|
|
196
|
+
* bundle rather than raise.
|
|
197
|
+
*/
|
|
198
|
+
const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
|
|
199
|
+
|
|
200
|
+
export function hostCatalogsNatively(host) {
|
|
201
|
+
return Boolean(NATIVE_CATALOG[host]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */
|
|
205
|
+
const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* `_harness_learn._resolve_memory_dir` — where `learn` dedups and indexes, and what
|
|
209
|
+
* `status` reports on its memory row.
|
|
210
|
+
*
|
|
211
|
+
* Precedence: `--memory` > `$GENESEED_MEMORY` > a `memory/` (or `anamnesis/`) beside cwd or
|
|
212
|
+
* under `./Harness` > `$GENESEED_HARNESS/memory` > the OpenCode GLOBAL config dir's store.
|
|
213
|
+
* The last two matter for the recommended opencode-global install, whose store lives in
|
|
214
|
+
* `~/.config/opencode` rather than beside any repo. null => stdout-only.
|
|
215
|
+
*
|
|
216
|
+
* HERE rather than in `js/hooks.mjs`, where it was written, because P5d gave it a second
|
|
217
|
+
* caller that cannot import that file: `bin/geneseed-cli.mjs` is under a transitive
|
|
218
|
+
* `child_process` ban and `learn` spawns the model CLI. This module is the one both can
|
|
219
|
+
* reach, and it already owns the `opencodeConfigDir` the last fallback needs — so the move
|
|
220
|
+
* DELETED a duplicate resolver rather than adding a shared one.
|
|
221
|
+
*/
|
|
222
|
+
export function resolveMemoryDir(explicit) {
|
|
223
|
+
if (explicit) {
|
|
224
|
+
const p = pyPathStr(explicit);
|
|
225
|
+
return isDir(p) ? p : null;
|
|
226
|
+
}
|
|
227
|
+
const env = process.env.GENESEED_MEMORY;
|
|
228
|
+
if (env && isDir(env)) return pyPathStr(env);
|
|
229
|
+
const cwd = process.cwd();
|
|
230
|
+
const bases = [cwd, path.join(cwd, 'Harness')];
|
|
231
|
+
const gh = process.env.GENESEED_HARNESS;
|
|
232
|
+
if (gh) {
|
|
233
|
+
// PER BASE, and the walk continues. `$GENESEED_HARNESS` is the FOURTH user-controlled
|
|
234
|
+
// tilde input and the one the `~user` refusal was never swept onto: `expanduser` throws
|
|
235
|
+
// on `~someone/x`, and this resolver is reached by `geneseed status` (js/status.mjs's
|
|
236
|
+
// `resolveMemoryDir(null)`) and by the `learn` hook, whose entry point
|
|
237
|
+
// (`bin/geneseed-hook.mjs`) has NO top-level try — so an unguarded throw here is a stack
|
|
238
|
+
// trace on a hook path and a non-zero exit, the exact failure the other three guards
|
|
239
|
+
// exist to prevent. Skipping the base degrades to "no memory store from $GENESEED_HARNESS",
|
|
240
|
+
// which is what an unset variable already does. `withDiscardableStderr` because the
|
|
241
|
+
// refusal prints at the RAISE SITE and both callers byte-compare stderr; the reference
|
|
242
|
+
// (`rituals/_harness_learn.py`, same guard) prints nothing here.
|
|
243
|
+
try { bases.push(withDiscardableStderr(() => expanduser(gh))); } catch { /* skip it */ }
|
|
244
|
+
}
|
|
245
|
+
try { bases.push(opencodeConfigDir()); } catch { /* best-effort, as the Python */ }
|
|
246
|
+
for (const base of bases) {
|
|
247
|
+
for (const name of MEMORY_DIR_NAMES) {
|
|
248
|
+
const cand = path.join(base, name);
|
|
249
|
+
if (isDir(cand)) return cand;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
package/js/installs.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a DEPLOYED install is recognised — `_harness_setup`'s detection half and
|
|
3
|
+
* `_harness_mcp`'s registry-reading half, which are one subject in two Python files.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts.mjs` and
|
|
6
|
+
* `js/checkout.mjs`: this is the third verb-set to need it. P5d wrote `themeOfDir` and
|
|
7
|
+
* `footprintOfDir` inside `js/status.mjs` because `status` was the only caller; `diff`
|
|
8
|
+
* renders its 'expected' copy in the theme and footprint the deployment actually uses, and
|
|
9
|
+
* `rebuild-all` re-emits every install in its own theme, emit, footprint, posture and mode.
|
|
10
|
+
* Three callers of one detector is where a private helper becomes a module.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE MOVE DID NOT DO, and it is the P5f brief's second blind spot answered by
|
|
13
|
+
* measurement. `installedDefaults` still omitted posture and mode after P5f, because
|
|
14
|
+
* `cmd_rebuild_all` never calls `_installed_defaults`: it calls `_posture_of_dir(root)` and
|
|
15
|
+
* `_mode_of_dir(root)` directly, per install root, which is a different question from "what
|
|
16
|
+
* did this machine install". **P5i is where the debt came due** — `collectSetupLines`
|
|
17
|
+
* pre-selects three pickers off it — and the missing keys are now returned.
|
|
18
|
+
* `status/posture-and-mode-in-the-carrier-change-nothing` stays exactly as valid as it was:
|
|
19
|
+
* it is the positive control that the PANEL prints neither, and the panel reads two keys of
|
|
20
|
+
* the five whatever the other three hold.
|
|
21
|
+
*
|
|
22
|
+
* WHAT A CELL CANNOT REACH HERE. `installTargets` walks `process.cwd()` and the four host
|
|
23
|
+
* config dirs, and both are fenceable — `golden.cell_env` redirects HOME/XDG and every cell
|
|
24
|
+
* chooses its own `cwd`. What it also walks is the persistent registry, which lives under
|
|
25
|
+
* the redirected `$XDG_CONFIG_HOME` and is therefore fenceable too. So unlike `status`, none
|
|
26
|
+
* of this module's inputs is the unfenceable `ROOT`; the `rebuild-all` cells seed real
|
|
27
|
+
* installs and get real answers.
|
|
28
|
+
*
|
|
29
|
+
* That sentence was FALSE until wave 2 of the P0/P1 review: `installedDefaults` walked
|
|
30
|
+
* `ROOT / "Harness"` and `ROOT.parent / "Harness"`, so on a checkout carrying a built
|
|
31
|
+
* bundle every cell that seeded no emit marker read this developer's own install — four
|
|
32
|
+
* web cells recorded it. Both candidates are gone (the argument is in the Python), and the
|
|
33
|
+
* claim is now true rather than aspirational.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
import { CONFIG, THEMES, discoverNames } from './checkout.mjs';
|
|
39
|
+
import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
|
|
40
|
+
import { registryRoots } from './registry.mjs';
|
|
41
|
+
import { comparePaths, pyPrintErr, pyRepr, readText } from './lib/fs.mjs';
|
|
42
|
+
|
|
43
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
44
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
|
|
48
|
+
*
|
|
49
|
+
* `readText`, not a bare `readFileSync`: `read_text` opens in TEXT mode, so Python's
|
|
50
|
+
* universal-newline decoding folds `\r\n` and a lone `\r` to `\n` before the caller ever
|
|
51
|
+
* sees them, and `readFileSync` does not. Every caller here reads a single-line marker and
|
|
52
|
+
* trims it, which is why the divergence sat latent through five phases — P6b's
|
|
53
|
+
* `/api/profile` is the first consumer that hands the whole decoded text back out, and
|
|
54
|
+
* `profile/a-seeded-profile-carries-its-fingerprint` failed on the CRLF the seeder wrote
|
|
55
|
+
* AND on the sha256 of it. Fixed here rather than at that one call site: the multi-line
|
|
56
|
+
* readers (`installs.mjs`'s AGENT.md sigil scan and carrier read, `uninstall.mjs`'s two)
|
|
57
|
+
* all mirror a Python `read_text` too, so every one of them was wrong in the same way and
|
|
58
|
+
* a guard at the new caller would have left them wrong.
|
|
59
|
+
*/
|
|
60
|
+
export function readMaybe(p) {
|
|
61
|
+
try { return readText(p); } catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `json.loads(...)` of a file, or null — Python catches JSONDecodeError and OSError alike. */
|
|
65
|
+
export function readJsonMaybe(p) {
|
|
66
|
+
const txt = readMaybe(p);
|
|
67
|
+
if (txt === null) return null;
|
|
68
|
+
try { return JSON.parse(txt); } catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- the configured defaults (`_harness_setup._default_*`) --------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `_harness_setup._default_theme` / `_default_posture` / `_default_mode`.
|
|
75
|
+
*
|
|
76
|
+
* One reader for three keys where the Python has three near-identical functions. The fallback
|
|
77
|
+
* differs per key and is the argument, so nothing is shared that is not actually the same:
|
|
78
|
+
* each is `harness.config.json`'s value or a literal, with an unreadable or non-JSON file
|
|
79
|
+
* falling through to the literal. NOT `configDefaults()` from the driver, which warns on a
|
|
80
|
+
* corrupt file — these three are silent, as their Python originals are.
|
|
81
|
+
*/
|
|
82
|
+
function configuredDefault(key, fallback) {
|
|
83
|
+
if (existsSync(CONFIG)) {
|
|
84
|
+
const doc = readJsonMaybe(CONFIG);
|
|
85
|
+
if (doc && typeof doc === 'object') return doc[key] ?? fallback;
|
|
86
|
+
}
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const defaultTheme = () => configuredDefault('theme', 'neutral');
|
|
91
|
+
export const defaultPosture = () => configuredDefault('posture', 'peer');
|
|
92
|
+
export const defaultMode = () => configuredDefault('mode', 'direct');
|
|
93
|
+
|
|
94
|
+
// ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `_build_render.theme_files` — shipped themes, `_`-prefixed scaffolds excluded.
|
|
98
|
+
*
|
|
99
|
+
* `dir` defaults to the checkout's own `themes/`, which is every caller but one: P2's
|
|
100
|
+
* `syncThemes` is driven over a FIXTURE directory by the cross-implementation corpus in
|
|
101
|
+
* `tests/test_maintainer_tools_parity.py`, because the tool it belongs to rewrites committed
|
|
102
|
+
* files and cannot be gated against the real ones. A parameter, not a second enumeration —
|
|
103
|
+
* a scaffold mistaken for a theme is exactly what this function exists to prevent, once.
|
|
104
|
+
*
|
|
105
|
+
* Sorted with `comparePaths` and not `.sort()`: the Python sorts `Path` objects, which compare
|
|
106
|
+
* through `_str_normcase`, so on Windows `Cyberpunk.json` sorts before `bar.json` there and
|
|
107
|
+
* after it under a bare code-unit sort. No shipped theme has an upper-case name, so no
|
|
108
|
+
* emitted byte moves — it is the fixture corpus that can reach a name that does.
|
|
109
|
+
*/
|
|
110
|
+
export function themeFiles(dir = THEMES) {
|
|
111
|
+
let names;
|
|
112
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
113
|
+
return names.filter((n) => n.endsWith('.json') && !n.startsWith('_'))
|
|
114
|
+
.map((n) => path.join(dir, n)).sort(comparePaths);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `_harness_setup._theme_from_agent` — match a theme's unique LOADED_SIGIL in a carrier. */
|
|
118
|
+
function themeFromAgent(agentMd) {
|
|
119
|
+
const text = readMaybe(agentMd);
|
|
120
|
+
if (text === null) return null;
|
|
121
|
+
for (const tf of themeFiles()) {
|
|
122
|
+
const doc = readJsonMaybe(tf);
|
|
123
|
+
const sig = (doc && typeof doc === 'object' ? doc.LOADED_SIGIL : '') || '';
|
|
124
|
+
if (sig && text.includes(sig)) return path.basename(tf, '.json');
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `_harness_setup.CARRIERS` — the five instruction carriers, in the Python's order.
|
|
131
|
+
*
|
|
132
|
+
* A shared table rather than three copies of the same list: `_theme_of_dir`,
|
|
133
|
+
* `_posture_of_dir` and `_mode_of_dir` each walk it, and the order is observable (the first
|
|
134
|
+
* carrier that answers wins). `rules/geneseed.md` is spelled with `path.join` at each use
|
|
135
|
+
* because the Python writes `d / "rules" / "geneseed.md"` for that one entry.
|
|
136
|
+
*/
|
|
137
|
+
const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
|
|
138
|
+
'copilot-instructions.md', 'AGENTS.md'];
|
|
139
|
+
|
|
140
|
+
/** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
|
|
141
|
+
export function themeOfDir(d) {
|
|
142
|
+
const marker = path.join(d, '.geneseed-theme');
|
|
143
|
+
if (isFile(marker)) {
|
|
144
|
+
const name = (readMaybe(marker) ?? '').trim();
|
|
145
|
+
if (name) return name;
|
|
146
|
+
}
|
|
147
|
+
for (const carrier of CARRIERS) {
|
|
148
|
+
const found = themeFromAgent(path.join(d, carrier));
|
|
149
|
+
if (found) return found;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `_harness_setup.FOOTPRINTS`. */
|
|
155
|
+
const FOOTPRINTS = ['lean', 'full'];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `_harness_setup._footprint_of_dir`.
|
|
159
|
+
*
|
|
160
|
+
* `status` reads no footprint row at all; the WARN is why this function is ported there, and
|
|
161
|
+
* `diff` and `rebuild-all` read the RETURN value as well. An unrecognised marker is reported
|
|
162
|
+
* rather than swallowed (Rule VII), and a compared stderr is what makes that observable —
|
|
163
|
+
* `status/a-bogus-footprint-marker-warns-on-stderr` is the cell.
|
|
164
|
+
*/
|
|
165
|
+
export function footprintOfDir(d) {
|
|
166
|
+
const marker = path.join(d, '.geneseed-footprint');
|
|
167
|
+
if (isFile(marker)) {
|
|
168
|
+
const v = (readMaybe(marker) ?? '').trim();
|
|
169
|
+
if (FOOTPRINTS.includes(v)) return v;
|
|
170
|
+
if (v) {
|
|
171
|
+
pyPrintErr(`[geneseed] WARN: ${marker} holds unknown footprint ${pyRepr(v)} — `
|
|
172
|
+
+ `reading it as 'full'. Known: ${FOOTPRINTS.join(', ')}.\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return 'full';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `_harness_setup._posture_of_dir` / `_mode_of_dir` — CONTENT-detected, not markered.
|
|
180
|
+
*
|
|
181
|
+
* The inlined `## Posture` / `## Mode` section opens with a distinctive `**<Name>**` lead, so
|
|
182
|
+
* a deployed install carries its register in the prose and needs no extra marker file. The
|
|
183
|
+
* two Python functions are identical but for which name list they scan, which is the argument
|
|
184
|
+
* here — and the list comes from `discoverNames`, the same call the driver's `--posture`
|
|
185
|
+
* choices come from, so a new posture file is detectable and buildable in one change.
|
|
186
|
+
*
|
|
187
|
+
* `str.capitalize()` is NOT `s[0].toUpperCase() + s.slice(1)`: Python lowercases the REST of
|
|
188
|
+
* the string, so a posture file named `Foreman` renders `**Foreman**` and one named `FOREMAN`
|
|
189
|
+
* would be scanned for as `**Foreman**` by both implementations only if this reproduces the
|
|
190
|
+
* lowercasing. Every shipped name is already lowercase, which is exactly why the difference
|
|
191
|
+
* would go unnoticed — the corpus in `tests/test_pure_function_parity.py` is what covers it.
|
|
192
|
+
*/
|
|
193
|
+
export function pyCapitalize(s) {
|
|
194
|
+
return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function leadOfDir(d, names) {
|
|
198
|
+
for (const carrier of CARRIERS) {
|
|
199
|
+
const text = readMaybe(path.join(d, carrier));
|
|
200
|
+
if (text === null) continue;
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
if (text.includes(`**${pyCapitalize(name)}**`)) return name;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
|
|
209
|
+
export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
|
|
210
|
+
|
|
211
|
+
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
212
|
+
|
|
213
|
+
/** `_harness_mcp._claude_read_manifest`. */
|
|
214
|
+
export function claudeReadManifest(cfgDir) {
|
|
215
|
+
const data = readJsonMaybe(path.join(cfgDir, GLOBAL_MANIFEST));
|
|
216
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `_harness_mcp._manifest_is_claude` — keyed on the `managed` MAP, not on `claude_md`: a Bob
|
|
221
|
+
* global emit ships no AGENTS.md and records no `claude_md`, and still needs the
|
|
222
|
+
* Claude-style reversal.
|
|
223
|
+
*/
|
|
224
|
+
export function manifestIsClaude(cfgDir) {
|
|
225
|
+
const mg = claudeReadManifest(cfgDir).managed;
|
|
226
|
+
return typeof mg === 'object' && mg !== null && !Array.isArray(mg);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). */
|
|
230
|
+
export const EMIT_HOST_SCOPE = new Map([
|
|
231
|
+
['opencode', ['opencode', 'project']], ['opencode-global', ['opencode', 'global']],
|
|
232
|
+
['claude', ['claude', 'project']], ['claude-global', ['claude', 'global']],
|
|
233
|
+
['bob', ['bob', 'project']], ['bob-global', ['bob', 'global']],
|
|
234
|
+
['copilot', ['copilot', 'project']], ['copilot-global', ['copilot', 'global']],
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/** `_harness_mcp._emit_host_scope_of` — `root`'s own marker, resolved, or null. */
|
|
238
|
+
export function emitHostScopeOf(root) {
|
|
239
|
+
const emit = readMaybe(path.join(root, '.geneseed-emit'));
|
|
240
|
+
if (emit === null) return null;
|
|
241
|
+
return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `_harness_mcp.DISABLED_STASH` — a sibling dir whose presence means "disabled". */
|
|
245
|
+
export const DISABLED_STASH = '.geneseed-disabled';
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
|
|
249
|
+
*
|
|
250
|
+
* The config dir itself for a global install; `<repo>/<project_marker>` for a project one.
|
|
251
|
+
* Host-aware so Bob and Copilot ride the whole Claude lifecycle with only the subdir
|
|
252
|
+
* differing, and the marker comes from `HOSTS` rather than a literal for that reason.
|
|
253
|
+
*/
|
|
254
|
+
export function claudeCfg(root, scope, host = 'claude') {
|
|
255
|
+
if (scope === 'global') return root;
|
|
256
|
+
const spec = HOSTS.find((h) => h.host === host);
|
|
257
|
+
return path.join(root, spec ? spec.projectMarker : '.claude');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `_harness_mcp._claude_state`. */
|
|
261
|
+
function claudeState(root, scope = 'global', host = 'claude') {
|
|
262
|
+
const cfg = claudeCfg(root, scope, host);
|
|
263
|
+
if (isDir(path.join(cfg, DISABLED_STASH, host))) return 'disabled';
|
|
264
|
+
return existsSync(path.join(cfg, GLOBAL_MANIFEST)) ? 'active' : 'absent';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `_harness_mcp._install_kind` — 'global' | 'project' | null. */
|
|
268
|
+
export function installKind(root) {
|
|
269
|
+
if (existsSync(path.join(root, GLOBAL_MANIFEST))) return 'global';
|
|
270
|
+
if (isDir(path.join(root, '.opencode'))) return 'project';
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
|
|
275
|
+
export function installState(root, host = 'opencode', scope = 'global') {
|
|
276
|
+
if (['claude', 'bob', 'copilot'].includes(host)) return claudeState(root, scope, host);
|
|
277
|
+
if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
|
|
278
|
+
return installKind(root) !== null ? 'active' : 'absent';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `_harness_mcp._registered_targets` — (host, scope, root) for every registered root. */
|
|
282
|
+
export function registeredTargets() {
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const root of registryRoots()) {
|
|
285
|
+
const hs = emitHostScopeOf(root);
|
|
286
|
+
if (hs) out.push([hs[0], hs[1], root]);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `_harness_mcp._install_targets` — every (host, scope, root) an install may live at,
|
|
293
|
+
* most-local first.
|
|
294
|
+
*
|
|
295
|
+
* De-duplicated on the (host, RESOLVED path) pair, which is three behaviours in one rule: a
|
|
296
|
+
* cwd carrying both `.opencode/` and `.claude/` yields two correctly-typed rows, a cwd that
|
|
297
|
+
* IS a global config dir never doubles its own host's row, and a registered root that is also
|
|
298
|
+
* the cwd never doubles either. `pyResolve` rather than `path.resolve` because Python's
|
|
299
|
+
* `Path.resolve()` returns the filesystem's own casing — on Windows two spellings of the same
|
|
300
|
+
* directory differ as strings and not as installs, and this Set is keyed by string.
|
|
301
|
+
*/
|
|
302
|
+
export function installTargets() {
|
|
303
|
+
const out = [];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
|
|
306
|
+
const add = (host, scope, root) => {
|
|
307
|
+
// A "project" whose marker dir IS this host's global config dir is the global install
|
|
308
|
+
// seen from its parent (the daemon's cwd is $HOME, where $HOME/.claude == ~/.claude) —
|
|
309
|
+
// not a separate project. Surfacing it would alias the global's files.
|
|
310
|
+
if (scope !== 'global') {
|
|
311
|
+
try {
|
|
312
|
+
const spec = HOSTS.find((h) => h.host === host);
|
|
313
|
+
if (pyResolve(path.join(root, spec.projectMarker)) === pyResolve(spec.configDir())) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
} catch { /* as the Python's bare `except Exception: pass` */ }
|
|
317
|
+
}
|
|
318
|
+
const key = `${host}\0${pyResolve(root)}`;
|
|
319
|
+
if (!seen.has(key)) { seen.add(key); out.push([host, scope, root]); }
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const cwd = process.cwd();
|
|
323
|
+
for (const { host, projectMarker } of HOSTS) {
|
|
324
|
+
if (isDir(path.join(cwd, projectMarker))) add(host, 'project', cwd);
|
|
325
|
+
}
|
|
326
|
+
for (const { host, configDir } of HOSTS) {
|
|
327
|
+
try { add(host, 'global', configDir()); } catch { /* a missing host dir is not fatal */ }
|
|
328
|
+
}
|
|
329
|
+
for (const [host, scope, root] of registeredTargets()) add(host, scope, root);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* `_harness_setup._installed_defaults` — all five keys, as of P5i.
|
|
337
|
+
*
|
|
338
|
+
* It carried only `theme` and `emit` for four phases because those are the two `status`
|
|
339
|
+
* prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
|
|
340
|
+
* asserted partition", not "the Python has it". `setup` is the caller the debt was recorded
|
|
341
|
+
* against: `collectSetupLines` pre-selects the posture, mode and footprint picker at
|
|
342
|
+
* `inst.posture` / `inst.mode` / `inst.footprint`, so a three-key answer would have silently
|
|
343
|
+
* pre-selected the CONFIGURED default over the DEPLOYED one on three of its five questions.
|
|
344
|
+
* Nothing in `status` or `doctor` moves: both read the keys they already read.
|
|
345
|
+
*/
|
|
346
|
+
export function installedDefaults() {
|
|
347
|
+
const found = { theme: null, posture: null, mode: null, emit: null, footprint: null };
|
|
348
|
+
const candidates = [];
|
|
349
|
+
for (const { host, configDir } of HOSTS) {
|
|
350
|
+
try { candidates.push([configDir(), `${host}-global`]); } catch { /* as the Python */ }
|
|
351
|
+
}
|
|
352
|
+
// One cwd-relative bundle candidate — `ROOT / "Harness"` and `ROOT.parent / "Harness"`
|
|
353
|
+
// left with the Python's, see `_harness_setup._installed_defaults` for the argument.
|
|
354
|
+
candidates.push([path.join(process.cwd(), 'Harness'), null]);
|
|
355
|
+
for (const [base, knownEmit] of candidates) {
|
|
356
|
+
if (found.emit === null) {
|
|
357
|
+
const em = path.join(base, '.geneseed-emit');
|
|
358
|
+
if (isFile(em)) {
|
|
359
|
+
found.emit = (readMaybe(em) ?? '').trim() || null;
|
|
360
|
+
} else if (isFile(path.join(base, GLOBAL_MANIFEST))) {
|
|
361
|
+
// A manifest with no emit marker: a known config dir names its own host; a bundle
|
|
362
|
+
// tells Claude from OpenCode by shape.
|
|
363
|
+
found.emit = knownEmit
|
|
364
|
+
?? (manifestIsClaude(base) ? 'claude-global' : 'opencode-global');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (found.theme === null) found.theme = themeOfDir(base) ?? null;
|
|
368
|
+
if (found.posture === null) found.posture = postureOfDir(base) ?? null;
|
|
369
|
+
if (found.mode === null) found.mode = modeOfDir(base) ?? null;
|
|
370
|
+
// `found["footprint"] is None` in the Python, so only the FIRST marker on the walk
|
|
371
|
+
// speaks — and `footprintOfDir` never answers null, which is why a boolean stood in for
|
|
372
|
+
// the value while nothing read it.
|
|
373
|
+
if (found.footprint === null && isFile(path.join(base, '.geneseed-footprint'))) {
|
|
374
|
+
found.footprint = footprintOfDir(base);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return found;
|
|
378
|
+
}
|