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/settings.mjs
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_build_settings.py` in Node — the host-config WIRING layer.
|
|
3
|
+
*
|
|
4
|
+
* This is the half of the emit that edits files the USER co-owns: the JSONC reader, the
|
|
5
|
+
* `opencode.json` / `settings.json` merges, the hook shim, the managed-block machinery and
|
|
6
|
+
* the integrity check. It is the last unit of the generator to cross, and the reason it is
|
|
7
|
+
* last is that it is also the unit the RUNTIME drives: eleven of its names have a consumer
|
|
8
|
+
* outside the emit tree, ten of them in `rituals/_harness_mcp.py` (remerge, deactivate,
|
|
9
|
+
* reactivate, uninstall), two in `rituals/_harness_exclude.py`, one in
|
|
10
|
+
* `rituals/_harness_build.py`. Nothing imports this module yet — P3a proves the unit,
|
|
11
|
+
* P3b flips the call sites.
|
|
12
|
+
*
|
|
13
|
+
* THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
|
|
14
|
+
* a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
|
|
15
|
+
* like — see the shim comment below), so a stray byte printed on a hook path does not make
|
|
16
|
+
* noise, it silently disables a gate. Everything printed here is a generator-time message;
|
|
17
|
+
* the streams are compared byte for byte by `tests/test_settings_parity.py`, and the
|
|
18
|
+
* asymmetry between the two (`_warn_commented_jsonc` prints to STDOUT, every other message
|
|
19
|
+
* to stderr) is the Python's own and is reproduced, not tidied.
|
|
20
|
+
*
|
|
21
|
+
* HOOKS ARE APPEND-ONLY AND FAIL-CLOSED. Nothing here rewrites a hook group in place:
|
|
22
|
+
* `mergeClaudeSettings` appends what is missing and removes only groups the manifest
|
|
23
|
+
* recorded, and cleanup goes through `GENESEED_HOOK_SNIFF`, which has to keep recognising
|
|
24
|
+
* BOTH the legacy interpreter+checkout form and the shim form. Dropping either spelling
|
|
25
|
+
* strands live hooks in every config emitted before the change.
|
|
26
|
+
*
|
|
27
|
+
* ONE KNOWN DIVERGENCE, recorded rather than reproduced. JS objects order integer-like
|
|
28
|
+
* keys first, so a settings.json carrying a `"1"` or `"2"` at any level comes back from
|
|
29
|
+
* `JSON.parse` reordered and this module writes it back reordered — Python preserves the
|
|
30
|
+
* file's order. Measured (`{"b":1,"2":2,"a":3}` → `{"2":2,"b":1,"a":3}`); no key any
|
|
31
|
+
* writer here produces is numeric, and no data is lost, but a user's own numeric key moves.
|
|
32
|
+
* Fixing it means parsing into a Map and threading that through every accessor, which is a
|
|
33
|
+
* much larger change than the defect.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import os from 'node:os';
|
|
40
|
+
import {
|
|
41
|
+
jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, pyEq, pyRepr, indexOfEq,
|
|
42
|
+
pyPrintErr, readText, writeText,
|
|
43
|
+
} from './lib/fs.mjs';
|
|
44
|
+
|
|
45
|
+
export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
|
|
46
|
+
|
|
47
|
+
/** `dict.get(key)` with Python's semantics — OWN properties only.
|
|
48
|
+
*
|
|
49
|
+
* `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
|
|
50
|
+
* member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
|
|
51
|
+
* The same hazard `js/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
|
|
52
|
+
function get(obj, key) {
|
|
53
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function has(obj, key) {
|
|
57
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `except OSError` — an fs failure carries a `code`; anything else is a bug, so rethrow. */
|
|
61
|
+
function isOsError(e) {
|
|
62
|
+
return Boolean(e) && typeof e.code === 'string';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isDict(v) {
|
|
66
|
+
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `_build_settings._default_permission`. */
|
|
70
|
+
export function defaultPermission() {
|
|
71
|
+
return {
|
|
72
|
+
bash: {
|
|
73
|
+
'rm -rf *': 'ask',
|
|
74
|
+
'git commit*': 'ask',
|
|
75
|
+
'git push*': 'ask',
|
|
76
|
+
'git push --force*': 'ask',
|
|
77
|
+
'git push -f*': 'ask',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `Path.with_suffix(suffix)` for the one shape this module needs.
|
|
84
|
+
*
|
|
85
|
+
* `PurePath.suffix` and `path.extname` disagree on leading-dot and trailing-dot names
|
|
86
|
+
* (`.bashrc`, `x.`); the only caller passes `…/opencode.json`, and the emit's own callers
|
|
87
|
+
* pass a literal. Spelled off `path.extname` with the empty-extension case handled so a
|
|
88
|
+
* future caller with a suffix-less path appends rather than truncates.
|
|
89
|
+
*/
|
|
90
|
+
function withSuffix(p, suffix) {
|
|
91
|
+
const ext = path.extname(p);
|
|
92
|
+
return (ext ? p.slice(0, p.length - ext.length) : p) + suffix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `_build_settings._opencode_target`. */
|
|
96
|
+
export function opencodeTarget(jsonPath) {
|
|
97
|
+
const jsonc = withSuffix(jsonPath, '.jsonc');
|
|
98
|
+
return existsSync(jsonc) ? jsonc : jsonPath;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `_build_settings._read_jsonc` — an EXPORTED PRIMITIVE, deliberately.
|
|
103
|
+
*
|
|
104
|
+
* Four runtime call sites use it as a plain JSONC parser rather than as wiring, all four
|
|
105
|
+
* in `rituals/_harness_mcp.py` (P1b counted the call sites and named them callers; they
|
|
106
|
+
* are one module). Folding it inside a wire entry point would leave those four with
|
|
107
|
+
* nothing to call.
|
|
108
|
+
*
|
|
109
|
+
* Returns `[data, hadComments]`, with `data === null` for UNPARSEABLE input — distinct
|
|
110
|
+
* from a legitimately empty `{}`, because writers must refuse to rewrite a file they could
|
|
111
|
+
* not parse.
|
|
112
|
+
*/
|
|
113
|
+
export function readJsonc(text) {
|
|
114
|
+
const out = [];
|
|
115
|
+
let hadComments = false;
|
|
116
|
+
let i = 0;
|
|
117
|
+
let inStr = false;
|
|
118
|
+
const n = text.length;
|
|
119
|
+
while (i < n) {
|
|
120
|
+
const ch = text[i];
|
|
121
|
+
if (inStr) {
|
|
122
|
+
out.push(ch);
|
|
123
|
+
if (ch === '\\' && i + 1 < n) {
|
|
124
|
+
out.push(text[i + 1]);
|
|
125
|
+
i += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === '"') inStr = false;
|
|
129
|
+
i += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ch === '"') {
|
|
133
|
+
inStr = true;
|
|
134
|
+
out.push(ch);
|
|
135
|
+
i += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === '/' && i + 1 < n && text[i + 1] === '/') {
|
|
139
|
+
hadComments = true;
|
|
140
|
+
i += 2;
|
|
141
|
+
while (i < n && text[i] !== '\n' && text[i] !== '\r') i += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === '/' && i + 1 < n && text[i + 1] === '*') {
|
|
145
|
+
hadComments = true;
|
|
146
|
+
i += 2;
|
|
147
|
+
while (i + 1 < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
148
|
+
i += 2;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (ch === '}' || ch === ']') {
|
|
152
|
+
while (out.length && ' \t\r\n'.includes(out[out.length - 1])) out.pop();
|
|
153
|
+
if (out.length && out[out.length - 1] === ',') out.pop();
|
|
154
|
+
out.push(ch);
|
|
155
|
+
i += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
out.push(ch);
|
|
159
|
+
i += 1;
|
|
160
|
+
}
|
|
161
|
+
const stripped = out.join('');
|
|
162
|
+
try {
|
|
163
|
+
// `parseJson`, not `JSON.parse`: this file gets written back out, and a user's
|
|
164
|
+
// `"temperature": 1.0` would come back as `1` from a bare parse. The int/float
|
|
165
|
+
// distinction has to survive a round trip through somebody's real settings.json.
|
|
166
|
+
return [parseJson(stripped), hadComments];
|
|
167
|
+
} catch {
|
|
168
|
+
return [null, hadComments];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice. */
|
|
173
|
+
export function warnCommentedJsonc(target, agentPath, includePermission,
|
|
174
|
+
includeLsp = false, prefix = 'geneseed') {
|
|
175
|
+
process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
|
|
176
|
+
+ 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
|
|
177
|
+
process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
|
|
178
|
+
if (includePermission) {
|
|
179
|
+
process.stdout.write(`[${prefix}] and, for Geneseed's default ask-gates, a `
|
|
180
|
+
+ '"permission" key:\n');
|
|
181
|
+
for (const line of jsonDumpsIndent(defaultPermission()).split('\n')) {
|
|
182
|
+
process.stdout.write(`[${prefix}] ${line}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (includeLsp) {
|
|
186
|
+
process.stdout.write(`[${prefix}] and, to enable code intelligence, a top-level `
|
|
187
|
+
+ '"lsp": true\n');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `_build_settings._atomic_write_json`. Throws on failure, as the Python does. */
|
|
192
|
+
export function atomicWriteJson(p, config) {
|
|
193
|
+
const tmp = `${p}.geneseed-tmp`;
|
|
194
|
+
writeText(tmp, `${jsonDumpsIndent(config)}\n`);
|
|
195
|
+
try {
|
|
196
|
+
renameSync(tmp, p);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (!isOsError(e)) throw e;
|
|
199
|
+
rmSync(tmp, { force: true });
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `_build_settings._merge_opencode_json`. Returns the resolved target path.
|
|
205
|
+
*
|
|
206
|
+
* Zero runtime callers — re-measured this phase and still true. `rituals/_harness_mcp.py`
|
|
207
|
+
* re-implements the inverse itself rather than inherit this one's permission/lsp side
|
|
208
|
+
* effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
|
|
209
|
+
* `_build_global.emit_opencode_global`). */
|
|
210
|
+
export function mergeOpencodeJson(p, agentPath) {
|
|
211
|
+
const target = opencodeTarget(p);
|
|
212
|
+
let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
|
|
213
|
+
let hadComments = false;
|
|
214
|
+
if (existsSync(target)) {
|
|
215
|
+
let raw;
|
|
216
|
+
try {
|
|
217
|
+
raw = readText(target);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
if (!isOsError(e)) throw e;
|
|
220
|
+
process.stderr.write(`[geneseed] WARN: could not read ${target} (${pyOsError(e)}) `
|
|
221
|
+
+ `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
|
|
222
|
+
+ "hand once it's readable again.\n");
|
|
223
|
+
return target;
|
|
224
|
+
}
|
|
225
|
+
const [loaded, hc] = readJsonc(raw);
|
|
226
|
+
hadComments = hc;
|
|
227
|
+
if (loaded === null || !isDict(loaded)) {
|
|
228
|
+
process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
|
|
229
|
+
+ `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
|
|
230
|
+
+ '"instructions" once repaired.\n');
|
|
231
|
+
return target;
|
|
232
|
+
}
|
|
233
|
+
config = loaded;
|
|
234
|
+
}
|
|
235
|
+
if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
|
|
236
|
+
let instr = get(config, 'instructions');
|
|
237
|
+
if (!Array.isArray(instr)) instr = [];
|
|
238
|
+
const addInstr = indexOfEq(instr, agentPath) < 0;
|
|
239
|
+
if (addInstr) instr.push(agentPath);
|
|
240
|
+
config.instructions = instr;
|
|
241
|
+
const addPerm = !has(config, 'permission');
|
|
242
|
+
if (addPerm) config.permission = defaultPermission();
|
|
243
|
+
const addLsp = !has(config, 'lsp');
|
|
244
|
+
if (addLsp) config.lsp = true;
|
|
245
|
+
if (!addInstr && !addPerm && !addLsp) return target;
|
|
246
|
+
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
247
|
+
warnCommentedJsonc(target, agentPath, addPerm, addLsp);
|
|
248
|
+
return target;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
atomicWriteJson(target, config);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (!isOsError(e)) throw e;
|
|
254
|
+
process.stderr.write(`[geneseed] WARN: could not write ${target} (${pyOsError(e)}) — `
|
|
255
|
+
+ `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
|
|
256
|
+
+ 'to its "instructions" array by hand.\n');
|
|
257
|
+
}
|
|
258
|
+
return target;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `str(OSError)` — what Python interpolates for `except OSError as e: f"({e})"`.
|
|
263
|
+
*
|
|
264
|
+
* Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
|
|
265
|
+
* `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the
|
|
266
|
+
* parity gate does not drive a cell through a real fs failure — it exercises the branch
|
|
267
|
+
* with an injectable failure instead and compares everything except the OS's own wording.
|
|
268
|
+
* Named here so the gap is a decision rather than a surprise at GA.
|
|
269
|
+
*/
|
|
270
|
+
function pyOsError(e) {
|
|
271
|
+
return e.message;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- the hook shim ----------------------------------------------------------------
|
|
275
|
+
// `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
|
|
276
|
+
// rule-gate return 0 on EVERY path and signal their verdict as a JSON object on stdout.
|
|
277
|
+
// The `|| exit 0` is there because a hook that fails to LAUNCH (a moved checkout, a dead
|
|
278
|
+
// interpreter) must not block the tool call. A stray byte on stdout — a cmd.exe command
|
|
279
|
+
// echo, a "created ~/.geneseed" notice — corrupts that JSON and the gate stops gating
|
|
280
|
+
// while still reporting success. Hence `@echo off`, and a shim that prints nothing.
|
|
281
|
+
//
|
|
282
|
+
// The marker lives in the FILENAME, not the directory: GENESEED_HOME relocates the dir,
|
|
283
|
+
// and a relocated install's hooks must stay recognisable to `GENESEED_HOOK_SNIFF`.
|
|
284
|
+
export const SHIM_MARK = 'geneseed-hook';
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* `_build_settings._SHIM_ARGV` — the tokens in a shim body that are NOT paths.
|
|
288
|
+
*
|
|
289
|
+
* `shimProblems` reads every quoted token back out of the body and requires each to name an
|
|
290
|
+
* existing file. On Windows that is exactly the two baked paths (`%*` is bare); on POSIX the
|
|
291
|
+
* body forwards argv as `"$@"`, quoted, so both implementations' `doctor` reported a healthy
|
|
292
|
+
* shim as dead on every Linux and macOS host. A defect the port faithfully inherited, and
|
|
293
|
+
* one no Windows cell could see.
|
|
294
|
+
*/
|
|
295
|
+
export const SHIM_ARGV = new Set(['$@', '%*']);
|
|
296
|
+
|
|
297
|
+
/** `_SHIM_REL`, with the platform as an argument.
|
|
298
|
+
*
|
|
299
|
+
* Python computes it at IMPORT time from `sys.platform`, which is what makes the other
|
|
300
|
+
* platform's branch unreachable on any one machine. Taking it as a parameter is what lets
|
|
301
|
+
* the parity gate compare both branches in one run (Python's side is reachable because
|
|
302
|
+
* `_hook_shim_body` reads `sys.platform` at CALL time). */
|
|
303
|
+
export function shimRel(platform = process.platform) {
|
|
304
|
+
return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** `_build_settings._shim_home`. */
|
|
308
|
+
export function shimHome() {
|
|
309
|
+
const env = process.env.GENESEED_HOME;
|
|
310
|
+
return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* `Path(x).expanduser()` — private twin of `js/hosts.mjs`'s `expanduser`, same guard.
|
|
315
|
+
*
|
|
316
|
+
* `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
|
|
317
|
+
* has the identical failure mode (a hook shim written under a literal `~user` directory) and
|
|
318
|
+
* gets the identical fix — refuse rather than pass through. See that file's docblock for why
|
|
319
|
+
* the refusal lives IN the primitive rather than at `shimHome`'s caller.
|
|
320
|
+
*/
|
|
321
|
+
function expanduser(p) {
|
|
322
|
+
if (p === '~') return os.homedir();
|
|
323
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
|
|
324
|
+
if (p.startsWith('~')) {
|
|
325
|
+
const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
|
|
326
|
+
+ 'absolute path, or "~" / "~/…" for your own home directory';
|
|
327
|
+
pyPrintErr(`${msg}\n`);
|
|
328
|
+
const e = new Error(msg);
|
|
329
|
+
e.exitCode = 1;
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
return p;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `_build_settings._hook_shim_path`. */
|
|
336
|
+
export function hookShimPath(platform = process.platform) {
|
|
337
|
+
return path.join(shimHome(), ...shimRel(platform));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
|
|
342
|
+
*
|
|
343
|
+
* This is the one function in the unit whose Python output is legitimately
|
|
344
|
+
* runtime-dependent: it bakes `sys.executable` and `<checkout>/rituals/harness.py`, and a
|
|
345
|
+
* Node twin at GA bakes `process.execPath` and a different entry point. Golden already
|
|
346
|
+
* refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
|
|
347
|
+
* `_shim_health` instead.
|
|
348
|
+
*
|
|
349
|
+
* So the parity gate's answer here is NOT "skip it". Taking `runner` and `entry` as
|
|
350
|
+
* arguments makes the BODY a pure function of three inputs, and the gate feeds Node the
|
|
351
|
+
* same two values Python computed — which makes every byte of it comparable: the `@echo
|
|
352
|
+
* off`, the CRLF, `exit /b` vs `exec`, the quoting that keeps `--root "<cfg>"` intact. What
|
|
353
|
+
* the gate does not prove is WHICH values a Node driver will pass, and that is a one-line
|
|
354
|
+
* decision at P3b's call site rather than anything in this body.
|
|
355
|
+
*/
|
|
356
|
+
export function hookShimBody(runner, entry, platform = process.platform) {
|
|
357
|
+
if (platform === 'win32') {
|
|
358
|
+
// Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
|
|
359
|
+
// time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
|
|
360
|
+
// the emitted `|| exit 0` would never get to evaluate.
|
|
361
|
+
return '@echo off\r\n'
|
|
362
|
+
+ 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
|
|
363
|
+
+ 'setlocal\r\n'
|
|
364
|
+
+ `"${runner}" "${entry}" %*\r\n`
|
|
365
|
+
+ 'exit /b\r\n';
|
|
366
|
+
}
|
|
367
|
+
return '#!/bin/sh\n'
|
|
368
|
+
+ '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
|
|
369
|
+
+ `exec "${runner}" "${entry}" "$@"\n`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `_build_settings._write_hook_shim` — create or refresh the shim, or null on failure.
|
|
374
|
+
*
|
|
375
|
+
* The path and body are arguments for the same reason `hookShimBody`'s two values are: the
|
|
376
|
+
* routine itself has no runtime dependency at all once they are supplied, so all of it is
|
|
377
|
+
* comparable — the unchanged-content fast path (a Windows shim a hook is executing right
|
|
378
|
+
* now cannot be replaced), the newline-folded comparison that makes that fast path
|
|
379
|
+
* reachable, the pid-suffixed temp name that keeps concurrent emits from unlinking each
|
|
380
|
+
* other's file, and the chmod.
|
|
381
|
+
*/
|
|
382
|
+
export function writeHookShim(p, body, platform = process.platform) {
|
|
383
|
+
try {
|
|
384
|
+
// Newline-normalised: the body carries explicit CRLF on Windows but `read_text()`
|
|
385
|
+
// translates back to `\n`, so a raw `===` would never match and the "unchanged" fast
|
|
386
|
+
// path — the whole point of this branch — would never be taken.
|
|
387
|
+
if (existsSync(p) && statSync(p).isFile()
|
|
388
|
+
&& readText(p).replaceAll('\r\n', '\n') === body.replaceAll('\r\n', '\n')) {
|
|
389
|
+
return p;
|
|
390
|
+
}
|
|
391
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
392
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
393
|
+
// `newline=''` on the Python side: raw, so the CRLF in the body survives verbatim.
|
|
394
|
+
writeFileSync(tmp, body, 'utf8');
|
|
395
|
+
if (platform !== 'win32') chmodSync(tmp, 0o755);
|
|
396
|
+
renameSync(tmp, p);
|
|
397
|
+
return p;
|
|
398
|
+
} catch (e) {
|
|
399
|
+
if (!isOsError(e)) throw e;
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
|
|
406
|
+
* with, falling back to the pre-shim direct form when the shim cannot be written.
|
|
407
|
+
*
|
|
408
|
+
* The fallback is strictly no worse than the old behaviour and far better than emitting
|
|
409
|
+
* commands naming a shim that does not exist — those fail on every hook (9009 under
|
|
410
|
+
* cmd.exe, 127 under sh) and take both gates down with them.
|
|
411
|
+
*/
|
|
412
|
+
export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
|
|
413
|
+
// NOT a default. `runner`/`entry` have no computable fallback on this side — the child's
|
|
414
|
+
// own `process.execPath` is node and the hooks it wires are Python — and the failure mode
|
|
415
|
+
// of letting them through undefined is the worst kind this unit has: `hookShimBody` bakes
|
|
416
|
+
// `"undefined" "undefined" %*`, the shim is rewritten with it, every hook in the install
|
|
417
|
+
// dies, and because the hooks signal through stdout and return 0 on every path, nothing
|
|
418
|
+
// reports it. The shim is also excluded from golden's byte comparison by name, so no
|
|
419
|
+
// acceptance gate would catch it either. Throw where it is cheap to see.
|
|
420
|
+
if (!runner || !entry) {
|
|
421
|
+
throw new Error('hookPrefix: runner and entry are required — the emitted hook shim '
|
|
422
|
+
+ 'bakes them, and there is no correct value to guess from inside Node');
|
|
423
|
+
}
|
|
424
|
+
const p = hookShimPath(platform);
|
|
425
|
+
const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
|
|
426
|
+
if (shim !== null) return `"${shim}"`;
|
|
427
|
+
process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
|
|
428
|
+
+ 'hooks that call the interpreter directly. They will break if this checkout moves; '
|
|
429
|
+
+ 're-run the build to repair them.\n');
|
|
430
|
+
return `"${runner}" "${entry}"`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event. */
|
|
434
|
+
export function claudeHookGroups(cfg, hookOpts) {
|
|
435
|
+
const run = hookPrefix(hookOpts);
|
|
436
|
+
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
437
|
+
// --root carries the install's own dir so a GLOBAL hook can stand down when a project
|
|
438
|
+
// install of the same host sits at/above cwd (project-bypasses-global).
|
|
439
|
+
const context = `${run} context --root "${cfg}" || exit 0`;
|
|
440
|
+
const gate = `${run} git-gate --root "${cfg}"`;
|
|
441
|
+
const ruleGate = `${run} rule-gate --root "${cfg}"`;
|
|
442
|
+
const learn = `${run} learn ${mem} || exit 0`;
|
|
443
|
+
return {
|
|
444
|
+
PreToolUse: [
|
|
445
|
+
{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
|
|
446
|
+
{
|
|
447
|
+
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
448
|
+
hooks: [{ type: 'command', command: ruleGate }],
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
SessionStart: [
|
|
452
|
+
{ matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
|
|
453
|
+
{ matcher: 'resume', hooks: [{ type: 'command', command: context }] },
|
|
454
|
+
],
|
|
455
|
+
// `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
|
|
456
|
+
// is not a command and the swallow-failures intent would invert into a 9009 error.
|
|
457
|
+
Stop: [{ hooks: [{ type: 'command', command: learn }] }],
|
|
458
|
+
// Same command as Stop: `learn` reads the payload's hook_event_name and routes a
|
|
459
|
+
// SubagentStop to the per-agent lesson path.
|
|
460
|
+
SubagentStop: [{ hooks: [{ type: 'command', command: learn }] }],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* `_build_settings._merge_claude_settings` — returns `[target, managed]`.
|
|
466
|
+
*
|
|
467
|
+
* Surgical: every other key and the user's own hook entries survive. `priorHooks` is the
|
|
468
|
+
* manifest's previously-recorded managed groups; any still in the file but no longer
|
|
469
|
+
* canonical (a moved checkout, the pre-`|| exit 0` hook form) is PRUNED, because without
|
|
470
|
+
* that a re-emit stacks the new group beside the stale one and `learn` runs twice per Stop.
|
|
471
|
+
* `managed` is the complete current claim set, so unwire removes exactly those.
|
|
472
|
+
*
|
|
473
|
+
* `scope` is accepted and unused, exactly as in the Python.
|
|
474
|
+
*/
|
|
475
|
+
export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}) {
|
|
476
|
+
const prior = (priorHooks || []).filter(isDict);
|
|
477
|
+
let config = {};
|
|
478
|
+
let hadComments = false;
|
|
479
|
+
if (existsSync(p)) {
|
|
480
|
+
try {
|
|
481
|
+
const [loaded, hc] = readJsonc(readText(p));
|
|
482
|
+
hadComments = hc;
|
|
483
|
+
if (loaded === null) {
|
|
484
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
|
|
485
|
+
+ 'rewriting it (fix the syntax, then re-run). Hooks were not wired.\n');
|
|
486
|
+
return [p, prior];
|
|
487
|
+
}
|
|
488
|
+
if (isDict(loaded)) config = loaded;
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (!isOsError(e)) throw e;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let hooks = get(config, 'hooks');
|
|
494
|
+
if (!isDict(hooks)) hooks = {};
|
|
495
|
+
const canonical = claudeHookGroups(path.dirname(p), hookOpts);
|
|
496
|
+
const canonFlat = [];
|
|
497
|
+
for (const [event, groups] of Object.entries(canonical)) {
|
|
498
|
+
for (const g of groups) canonFlat.push({ event, group: g });
|
|
499
|
+
}
|
|
500
|
+
let pruned = false;
|
|
501
|
+
for (const rec of prior) {
|
|
502
|
+
if (indexOfEq(canonFlat, rec) >= 0) continue;
|
|
503
|
+
const event = get(rec, 'event');
|
|
504
|
+
const group = get(rec, 'group');
|
|
505
|
+
const arr = get(hooks, event);
|
|
506
|
+
if (Array.isArray(arr)) {
|
|
507
|
+
const at = indexOfEq(arr, group);
|
|
508
|
+
if (at >= 0) {
|
|
509
|
+
arr.splice(at, 1);
|
|
510
|
+
pruned = true;
|
|
511
|
+
if (!arr.length) delete hooks[event];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const added = [];
|
|
516
|
+
for (const [event, newGroups] of Object.entries(canonical)) {
|
|
517
|
+
let arr = get(hooks, event);
|
|
518
|
+
if (!Array.isArray(arr)) arr = [];
|
|
519
|
+
for (const g of newGroups) {
|
|
520
|
+
if (indexOfEq(arr, g) >= 0) continue;
|
|
521
|
+
arr.push(g);
|
|
522
|
+
added.push({ event, group: g });
|
|
523
|
+
}
|
|
524
|
+
hooks[event] = arr;
|
|
525
|
+
}
|
|
526
|
+
const survivors = prior.filter((r) => indexOfEq(canonFlat, r) >= 0);
|
|
527
|
+
const managedNow = survivors.concat(added.filter((a) => indexOfEq(survivors, a) < 0));
|
|
528
|
+
if (!added.length && !pruned) return [p, managedNow];
|
|
529
|
+
if (hadComments) {
|
|
530
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
|
|
531
|
+
+ "(your edits are kept). Add Geneseed's hooks by hand from "
|
|
532
|
+
+ 'adapters/claude-code/settings.json.\n');
|
|
533
|
+
return [p, prior];
|
|
534
|
+
}
|
|
535
|
+
// The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
|
|
536
|
+
// carries four events, so by the time control reaches here `hooks` is never empty. A
|
|
537
|
+
// mutation keeping the empty block instead of deleting it is the one of thirty-three that
|
|
538
|
+
// stays green, and it stays green because there is nothing to detect — recorded the way
|
|
539
|
+
// `themed_rel` and `lstripNewlines` are, so "the gate cannot see it" and "there is
|
|
540
|
+
// nothing to see" keep their distance.
|
|
541
|
+
if (Object.keys(hooks).length) config.hooks = hooks;
|
|
542
|
+
else delete config.hooks;
|
|
543
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
544
|
+
atomicWriteJson(p, config);
|
|
545
|
+
return [p, managedNow];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* `_build_settings._unwire_claude_settings` — remove exactly the recorded groups.
|
|
550
|
+
*
|
|
551
|
+
* Returns true when the file was actually rewritten, false when it bailed, so uninstall
|
|
552
|
+
* can report reality instead of assuming success.
|
|
553
|
+
*/
|
|
554
|
+
export function unwireClaudeSettings(p, added) {
|
|
555
|
+
if (!existsSync(p) || !added || !added.length) return false;
|
|
556
|
+
let loaded;
|
|
557
|
+
let hadComments;
|
|
558
|
+
try {
|
|
559
|
+
[loaded, hadComments] = readJsonc(readText(p));
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (!isOsError(e)) throw e;
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
if (hadComments || !isDict(loaded)) return false;
|
|
565
|
+
const hooks = get(loaded, 'hooks');
|
|
566
|
+
if (!isDict(hooks)) return false;
|
|
567
|
+
for (const rec of added) {
|
|
568
|
+
const event = get(rec, 'event');
|
|
569
|
+
const group = get(rec, 'group');
|
|
570
|
+
let arr = get(hooks, event);
|
|
571
|
+
if (Array.isArray(arr)) {
|
|
572
|
+
const at = indexOfEq(arr, group);
|
|
573
|
+
if (at >= 0) arr.splice(at, 1);
|
|
574
|
+
}
|
|
575
|
+
arr = get(hooks, event);
|
|
576
|
+
if (Array.isArray(arr) && !arr.length) delete hooks[event];
|
|
577
|
+
}
|
|
578
|
+
if (!Object.keys(hooks).length) delete loaded.hooks;
|
|
579
|
+
try {
|
|
580
|
+
atomicWriteJson(p, loaded);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
if (!isOsError(e)) throw e;
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* `_build_settings._wire_claude_excludes` — append-if-absent into `claudeMdExcludes`.
|
|
590
|
+
* Returns the entries actually written.
|
|
591
|
+
*/
|
|
592
|
+
export function wireClaudeExcludes(p, excludes) {
|
|
593
|
+
const want = (excludes || []).filter(Boolean);
|
|
594
|
+
if (!want.length) return [];
|
|
595
|
+
let config = {};
|
|
596
|
+
let hadComments = false;
|
|
597
|
+
if (existsSync(p)) {
|
|
598
|
+
try {
|
|
599
|
+
const [loaded, hc] = readJsonc(readText(p));
|
|
600
|
+
hadComments = hc;
|
|
601
|
+
if (loaded === null) {
|
|
602
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
|
|
603
|
+
+ 'rewriting it (fix the syntax, then re-run). Excludes were not wired.\n');
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
if (isDict(loaded)) config = loaded;
|
|
607
|
+
} catch (e) {
|
|
608
|
+
if (!isOsError(e)) throw e;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
let cur = get(config, 'claudeMdExcludes');
|
|
612
|
+
if (!Array.isArray(cur)) cur = [];
|
|
613
|
+
const added = want.filter((e) => indexOfEq(cur, e) < 0);
|
|
614
|
+
if (!added.length) return [];
|
|
615
|
+
if (hadComments) {
|
|
616
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
|
|
617
|
+
+ '(your edits are kept). Add to its "claudeMdExcludes" array by hand: '
|
|
618
|
+
+ `${jsonDumpsCompact(added)}\n`);
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
cur.push(...added);
|
|
622
|
+
config.claudeMdExcludes = cur;
|
|
623
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
624
|
+
try {
|
|
625
|
+
atomicWriteJson(p, config);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
if (!isOsError(e)) throw e;
|
|
628
|
+
process.stderr.write(`[geneseed] WARN: could not write ${p} (${pyOsError(e)}) — `
|
|
629
|
+
+ 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
|
|
630
|
+
+ `${jsonDumpsCompact(added)}\n`);
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
return added;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** `_build_settings._unwire_claude_excludes`. */
|
|
637
|
+
export function unwireClaudeExcludes(p, excludes) {
|
|
638
|
+
if (!existsSync(p) || !excludes || !excludes.length) return;
|
|
639
|
+
let loaded;
|
|
640
|
+
let hadComments;
|
|
641
|
+
try {
|
|
642
|
+
[loaded, hadComments] = readJsonc(readText(p));
|
|
643
|
+
} catch (e) {
|
|
644
|
+
if (!isOsError(e)) throw e;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (hadComments || !isDict(loaded)) return;
|
|
648
|
+
const cur = get(loaded, 'claudeMdExcludes');
|
|
649
|
+
if (!Array.isArray(cur)) return;
|
|
650
|
+
for (const e of excludes) {
|
|
651
|
+
const at = indexOfEq(cur, e);
|
|
652
|
+
if (at >= 0) cur.splice(at, 1);
|
|
653
|
+
}
|
|
654
|
+
if (cur.length) loaded.claudeMdExcludes = cur;
|
|
655
|
+
else delete loaded.claudeMdExcludes;
|
|
656
|
+
try {
|
|
657
|
+
atomicWriteJson(p, loaded);
|
|
658
|
+
} catch (e) {
|
|
659
|
+
if (!isOsError(e)) throw e;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---- Settings integrity check -----------------------------------------------------
|
|
664
|
+
// Substrings that mark a hook command as Geneseed's. An ARRAY, not one string, because two
|
|
665
|
+
// shapes are in the wild and both must stay recognisable: the legacy direct form
|
|
666
|
+
// (interpreter + checkout harness.py) written by every install emitted before the shim, and
|
|
667
|
+
// the shim form. Dropping the legacy entry makes every not-yet-migrated install invisible
|
|
668
|
+
// to the orphan scan — the one place a stranded hook can still surface — so cleanup would
|
|
669
|
+
// silently leave live hooks behind in every config emitted before the shim landed.
|
|
670
|
+
export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
|
|
671
|
+
|
|
672
|
+
// P10d. `GENESEED_HOOK_SNIFF` answers "is this hook Geneseed's?". A MIGRATION needs the other
|
|
673
|
+
// question — "is this Geneseed's OLD one?" — and the two are not the same sniff.
|
|
674
|
+
//
|
|
675
|
+
// THREE SHAPES ARE IN THE WILD, AND TWO OF THEM ARE IDENTICAL IN THE SETTINGS FILE:
|
|
676
|
+
// 1. legacy-direct the command itself names `harness.py` (pre-shim, P0 and earlier)
|
|
677
|
+
// 2. shim-python the command names the shim; the shim BODY runs python + harness.py
|
|
678
|
+
// 3. shim-node the command names the shim; the shim BODY runs node + the .mjs entry
|
|
679
|
+
//
|
|
680
|
+
// 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
|
|
681
|
+
// call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
|
|
682
|
+
// exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
|
|
683
|
+
export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
|
|
687
|
+
*
|
|
688
|
+
* PURE, so `tests/test_settings_parity.py` runs it over a corpus of real config text no cell
|
|
689
|
+
* can seed. 'none' is not a fault: a Copilot install wires no hooks, and a user's own
|
|
690
|
+
* settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
|
|
691
|
+
* "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
|
|
692
|
+
* config on any machine.
|
|
693
|
+
*/
|
|
694
|
+
export function migrateShape(commands, shimBody) {
|
|
695
|
+
if (commands.some((c) => c.includes('harness.py'))) return 'legacy';
|
|
696
|
+
if (commands.some((c) => c.includes(SHIM_MARK))) {
|
|
697
|
+
return shimBody.includes(SHIM_ENTRY_MARK) ? 'current' : 'legacy';
|
|
698
|
+
}
|
|
699
|
+
return 'none';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
|
|
704
|
+
*
|
|
705
|
+
* NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — `SETUP.md` tells the user to create them
|
|
706
|
+
* by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
|
|
707
|
+
* `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
|
|
708
|
+
* already states: an entry the manifest does not claim is "possibly user-authored; left alone".
|
|
709
|
+
*
|
|
710
|
+
* Both platforms' paths on both platforms, deliberately — the scan is a read that misses
|
|
711
|
+
* harmlessly, and returning only the host platform's would make the macOS arm unreachable
|
|
712
|
+
* from the Windows machine this port is developed on: an ungated branch dressed as a fork.
|
|
713
|
+
*/
|
|
714
|
+
export function autostartPaths() {
|
|
715
|
+
const home = os.homedir();
|
|
716
|
+
return [
|
|
717
|
+
path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu',
|
|
718
|
+
'Programs', 'Startup', 'geneseed-web.vbs'),
|
|
719
|
+
path.join(home, 'Library', 'LaunchAgents', 'dev.geneseed.web.plist'),
|
|
720
|
+
];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* `_build_settings._autostart_stale` — the entry names a Geneseed launcher somewhere else.
|
|
725
|
+
*
|
|
726
|
+
* Weak in one direction and strict in the other on purpose: it fires only when the file
|
|
727
|
+
* mentions geneseed at all (an unrelated Startup entry is never named), and clears only when
|
|
728
|
+
* the CURRENT root appears verbatim. A false positive costs one printed line; a false negative
|
|
729
|
+
* leaves a login task pointing at a checkout npm is about to make stale.
|
|
730
|
+
*/
|
|
731
|
+
export function autostartStale(text, root) {
|
|
732
|
+
if (!text.toLowerCase().includes('geneseed')) return false;
|
|
733
|
+
return !text.includes(root) && !text.includes(root.replaceAll('\\', '/'));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
|
|
737
|
+
export function settingsHookGroups(loaded) {
|
|
738
|
+
const hooks = get(loaded, 'hooks');
|
|
739
|
+
if (!isDict(hooks)) return [];
|
|
740
|
+
const out = [];
|
|
741
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
742
|
+
if (Array.isArray(groups)) {
|
|
743
|
+
for (const g of groups) if (isDict(g)) out.push([event, g]);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* `_build_settings._settings_integrity_check` — does the file match what the manifest
|
|
751
|
+
* claims was wired (`expect='present'`) or unwired (`expect='absent'`)?
|
|
752
|
+
*
|
|
753
|
+
* Returns the problems and ALSO prints them; never raises. A COMMENTED file IS still
|
|
754
|
+
* checked: emit and unwire both refuse to touch one, which is exactly how hooks linger
|
|
755
|
+
* after an uninstall, so the one state they will not write is the one that must not escape
|
|
756
|
+
* verification.
|
|
757
|
+
*/
|
|
758
|
+
export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
759
|
+
const problems = [];
|
|
760
|
+
const flush = () => {
|
|
761
|
+
for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
|
|
762
|
+
return problems;
|
|
763
|
+
};
|
|
764
|
+
const m = isDict(managed) ? managed : {};
|
|
765
|
+
if (!existsSync(p)) {
|
|
766
|
+
if (expect === 'present') {
|
|
767
|
+
problems.push(`${p}: file does not exist, but hooks/excludes were supposed to be `
|
|
768
|
+
+ 'wired into it');
|
|
769
|
+
}
|
|
770
|
+
return flush();
|
|
771
|
+
}
|
|
772
|
+
let loaded;
|
|
773
|
+
try {
|
|
774
|
+
[loaded] = readJsonc(readText(p));
|
|
775
|
+
} catch (e) {
|
|
776
|
+
if (!isOsError(e)) throw e;
|
|
777
|
+
problems.push(`${p}: could not read the file (${pyOsError(e)})`);
|
|
778
|
+
return flush();
|
|
779
|
+
}
|
|
780
|
+
if (!isDict(loaded)) {
|
|
781
|
+
problems.push(`${p}: not a JSON object — cannot verify hooks/excludes`);
|
|
782
|
+
return flush();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const presentGroups = settingsHookGroups(loaded);
|
|
786
|
+
const recordedHooks = (get(m, 'settings_hooks') || []).filter(isDict);
|
|
787
|
+
// Plain deep equality here (not the sorted-dump key the orphan scan below uses) is
|
|
788
|
+
// deliberate: both sides came out of a JSON parse, so it is already order-independent.
|
|
789
|
+
// The orphan scan needs the dumped-string form because it builds a set for O(1)
|
|
790
|
+
// membership, which a dict cannot join.
|
|
791
|
+
for (const rec of recordedHooks) {
|
|
792
|
+
const event = get(rec, 'event');
|
|
793
|
+
const group = get(rec, 'group');
|
|
794
|
+
const hit = presentGroups.some(([e, g]) => pyEq(e, event) && pyEq(g, group));
|
|
795
|
+
if (expect === 'present' && !hit) {
|
|
796
|
+
problems.push(`${p}: recorded hook group missing — event=${pyRepr(event ?? null)} `
|
|
797
|
+
+ `group=${jsonDumpsCompact(group ?? null)}`);
|
|
798
|
+
} else if (expect === 'absent' && hit) {
|
|
799
|
+
problems.push(`${p}: recorded hook group still present after unwire — `
|
|
800
|
+
+ `event=${pyRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let exclCur = get(loaded, 'claudeMdExcludes');
|
|
805
|
+
exclCur = Array.isArray(exclCur) ? exclCur : [];
|
|
806
|
+
for (const entry of (get(m, 'settings_excludes') || [])) {
|
|
807
|
+
const hit = indexOfEq(exclCur, entry) >= 0;
|
|
808
|
+
if (expect === 'present' && !hit) {
|
|
809
|
+
problems.push(`${p}: recorded claudeMdExcludes entry missing: ${pyRepr(entry)}`);
|
|
810
|
+
} else if (expect === 'absent' && hit) {
|
|
811
|
+
problems.push(`${p}: recorded claudeMdExcludes entry still present after unwire: `
|
|
812
|
+
+ `${pyRepr(entry)}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Geneseed-PATTERN entries present but not in the recorded claim set — warn only, never
|
|
817
|
+
// auto-delete (they may be user-authored, or a claim this run legitimately did not
|
|
818
|
+
// inherit).
|
|
819
|
+
const recordedSet = new Set(recordedHooks.map(
|
|
820
|
+
(r) => `${pyRepr(get(r, 'event') ?? null)}\u0000`
|
|
821
|
+
+ `${jsonDumpsCompact(get(r, 'group') ?? null, { sortKeys: true })}`));
|
|
822
|
+
for (const [event, group] of presentGroups) {
|
|
823
|
+
const key = `${pyRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
|
|
824
|
+
if (recordedSet.has(key)) continue;
|
|
825
|
+
const cmds = (get(group, 'hooks') || []).filter(isDict)
|
|
826
|
+
.map((h) => (has(h, 'command') ? h.command : ''));
|
|
827
|
+
if (cmds.some((c) => typeof c === 'string'
|
|
828
|
+
&& GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
|
|
829
|
+
problems.push(`${p}: Geneseed-pattern hook present but NOT recorded in the manifest `
|
|
830
|
+
+ `(event=${pyRepr(event)}) — possibly user-authored; left alone`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return flush();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const BLOCK_BEGIN = '<!-- BEGIN {id} -->';
|
|
838
|
+
const BLOCK_END = '<!-- END {id} -->';
|
|
839
|
+
|
|
840
|
+
function delimiters(blockId) {
|
|
841
|
+
return [BLOCK_BEGIN.replace('{id}', blockId), BLOCK_END.replace('{id}', blockId)];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* `_build_settings._managed_block_write` — 'created' | 'updated' | 'merged'.
|
|
846
|
+
* Idempotent: a re-emit replaces the block, never stacks them.
|
|
847
|
+
*/
|
|
848
|
+
export function managedBlockWrite(p, content, blockId = 'GENESEED') {
|
|
849
|
+
const [begin, end] = delimiters(blockId);
|
|
850
|
+
const block = `${begin}\n${pyRstrip(content)}\n${end}\n`;
|
|
851
|
+
if (!existsSync(p)) {
|
|
852
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
853
|
+
writeText(p, block);
|
|
854
|
+
return 'created';
|
|
855
|
+
}
|
|
856
|
+
const existing = readText(p);
|
|
857
|
+
if (existing.includes(begin) && existing.includes(end)) {
|
|
858
|
+
const pre = existing.split(begin)[0];
|
|
859
|
+
const post = existing.slice(existing.indexOf(end) + end.length);
|
|
860
|
+
writeText(p, pre + block + lstripNewlines(post));
|
|
861
|
+
return 'updated';
|
|
862
|
+
}
|
|
863
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
864
|
+
writeText(p, `${existing + sep}\n${block}`);
|
|
865
|
+
return 'merged';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** `_build_settings._managed_block_remove`. */
|
|
869
|
+
export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
|
|
870
|
+
if (!existsSync(p)) return;
|
|
871
|
+
if (whole) {
|
|
872
|
+
unlinkSync(p);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const [begin, end] = delimiters(blockId);
|
|
876
|
+
const existing = readText(p);
|
|
877
|
+
if (!existing.includes(begin) || !existing.includes(end)) return;
|
|
878
|
+
const pre = existing.split(begin)[0];
|
|
879
|
+
const post = existing.slice(existing.indexOf(end) + end.length);
|
|
880
|
+
const rest = pyStrip(`${pyRstripNewlines(pre)}\n${lstripNewlines(post)}`);
|
|
881
|
+
if (rest) writeText(p, `${rest}\n`);
|
|
882
|
+
else unlinkSync(p);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** `_build_settings._managed_block_read` — the block's inner content, or null. */
|
|
886
|
+
export function managedBlockRead(p, blockId = 'GENESEED') {
|
|
887
|
+
if (!existsSync(p)) return null;
|
|
888
|
+
const [begin, end] = delimiters(blockId);
|
|
889
|
+
const text = readText(p);
|
|
890
|
+
if (!text.includes(begin) || !text.includes(end)) return null;
|
|
891
|
+
const inner = text.slice(text.indexOf(begin) + begin.length);
|
|
892
|
+
return stripNewlines(inner.slice(0, inner.indexOf(end)));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// `str.strip()` and friends. JS's `trim()` is NOT `strip()`, and the difference runs both
|
|
896
|
+
// ways: `trim()` eats U+FEFF, which Python does not strip, and Python strips U+0085 and
|
|
897
|
+
// U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
|
|
898
|
+
// Geneseed shares with the user, so the set is spelled out rather than approximated — and
|
|
899
|
+
// spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
|
|
900
|
+
const PY_SPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
|
|
901
|
+
+ '\u2028\u2029\u202f\u205f\u3000]';
|
|
902
|
+
|
|
903
|
+
const RSTRIP_RE = new RegExp(`${PY_SPACE}+$`);
|
|
904
|
+
const LSTRIP_RE = new RegExp(`^${PY_SPACE}+`);
|
|
905
|
+
|
|
906
|
+
function pyRstrip(s) {
|
|
907
|
+
return s.replace(RSTRIP_RE, '');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function pyStrip(s) {
|
|
911
|
+
return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function lstripNewlines(s) {
|
|
915
|
+
return s.replace(/^\n+/, '');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function pyRstripNewlines(s) {
|
|
919
|
+
return s.replace(/\n+$/, '');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function stripNewlines(s) {
|
|
923
|
+
return lstripNewlines(pyRstripNewlines(s));
|
|
924
|
+
}
|