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/hooks.mjs
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four HOOK verbs — `context`, `git-gate`, `rule-gate`, `learn` — in Node.
|
|
3
|
+
*
|
|
4
|
+
* These are the commands the emitted `settings.json` actually invokes on a user's machine,
|
|
5
|
+
* every session: a SessionStart injector, two PreToolUse gates, and a Stop/SubagentStop
|
|
6
|
+
* distiller. Since P5b they are also what the emitted hooks name: `bin/geneseed.mjs` bakes
|
|
7
|
+
* `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
|
|
8
|
+
* driver emits has no Python in its hook path at all — which is what let the interpreter
|
|
9
|
+
* discovery and its exit-4 refusal be deleted rather than merely bypassed.
|
|
10
|
+
*
|
|
11
|
+
* Ported from `rituals/_harness_context.py` and `rituals/_harness_learn.py`, whose shared
|
|
12
|
+
* primitives live in `_harness_core.py`. `rituals/harness.py` links all fourteen
|
|
13
|
+
* submodules into one namespace at import time, so a Python `harness context` loads
|
|
14
|
+
* ~11,600 lines and the generator behind them. This module loads none of that, and that is
|
|
15
|
+
* deliberate rather than incidental: the Python originals already refuse to import `build`
|
|
16
|
+
* for the marker filenames they need ("literals keep the hook dependency-free"), and
|
|
17
|
+
* `bin/geneseed.mjs` could not be imported anyway, since its last statement runs the
|
|
18
|
+
* generator. What that rule does NOT license is a second copy of a shared resolver under a
|
|
19
|
+
* different name — see the primitives section below, where P5d found one that had silently
|
|
20
|
+
* diverged from its twin.
|
|
21
|
+
*
|
|
22
|
+
* THE CONTRACT EVERY VERB HOLDS. Exit 0 on every path, and signal through stdout. A hook
|
|
23
|
+
* that fails is a tool call that fails, so an unreadable payload, a missing file, a
|
|
24
|
+
* malformed user config and a bypass all end the same way: quietly, with 0. The single
|
|
25
|
+
* exception is `learn`, which returns the model CLI's own exit code — an auth or quota
|
|
26
|
+
* failure has to be diagnosable, and its hook command carries `|| exit 0` for exactly that
|
|
27
|
+
* reason.
|
|
28
|
+
*
|
|
29
|
+
* The gate is `tests/harness_golden.py`, which runs each verb through both CLIs in a
|
|
30
|
+
* seeded sandbox and compares stdout, stderr, the exit code and every file left behind.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync }
|
|
33
|
+
from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { readText, writeText, jsonDumpsCompact, normcase, comparePaths, pyPathStr, pyPrint,
|
|
38
|
+
pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
|
|
39
|
+
import { NO_WINDOW } from './lib/proc.mjs';
|
|
40
|
+
// `harness status` reports the memory store, and `bin/geneseed-cli.mjs` cannot import THIS
|
|
41
|
+
// module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
|
|
42
|
+
// spawns. So the resolver has one owner in `js/hosts.mjs` rather than a second copy — and
|
|
43
|
+
// with it went this file's private `opencodeConfigDir`, whose only caller it was.
|
|
44
|
+
import { resolveMemoryDir, expanduser } from './hosts.mjs';
|
|
45
|
+
|
|
46
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
47
|
+
|
|
48
|
+
// ---- Python path and text primitives -------------------------------------------------
|
|
49
|
+
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
50
|
+
//
|
|
51
|
+
// THE RULE, CORRECTED IN P5d. It used to read "duplicating four helpers is cheaper than
|
|
52
|
+
// coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
|
|
53
|
+
// exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
|
|
54
|
+
// implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
|
|
55
|
+
// `pyPathStr`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
|
|
56
|
+
// where `PurePath` keeps it, fixed `pyPathStr`, and gated it with a 25-path corpus — and
|
|
57
|
+
// none of that reached this file, because a corpus finds what it is pointed at and nothing
|
|
58
|
+
// named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
|
|
59
|
+
// `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
|
|
60
|
+
// down for a repo Python still gates. `context/bypass-does-not-match-a-dotdot-entry` is
|
|
61
|
+
// the cell.
|
|
62
|
+
//
|
|
63
|
+
// So the line is not "duplicate rather than couple" — it is "one owner for anything that
|
|
64
|
+
// reproduces a language primitive, wherever it is used". `fs.mjs` carries no
|
|
65
|
+
// `child_process` and costs nothing at hook latency. What stays local is what is genuinely
|
|
66
|
+
// only the hook's (`fnmatch`, the transcript readers, `pyOsError`'s errno table).
|
|
67
|
+
//
|
|
68
|
+
// AND `expanduser` WAS THE SECOND VIOLATION, closed here. This file carried its own, which
|
|
69
|
+
// returned a `~user` path UNCHANGED while `js/hosts.mjs`'s single owner REFUSES the form —
|
|
70
|
+
// an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
|
|
71
|
+
// was quietly exempt from because it had a private copy. It now imports the owner. The
|
|
72
|
+
// refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
|
|
73
|
+
// call: a hook that CRASHES reports success, because these gates exit 0 and signal on
|
|
74
|
+
// stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
|
|
75
|
+
// Claude. `pyResolve` just below is the THIRD copy of a primitive `js/hosts.mjs` also
|
|
76
|
+
// exports, and is the next item in this series.
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
|
|
80
|
+
*
|
|
81
|
+
* `realpathSync` throws on a path that does not exist, which is routine here (an
|
|
82
|
+
* `excludes.json` may name a folder that was deleted), so the existing prefix is
|
|
83
|
+
* canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
|
|
84
|
+
*/
|
|
85
|
+
function pyResolve(p) {
|
|
86
|
+
let cur = path.resolve(expanduser(p));
|
|
87
|
+
const tail = [];
|
|
88
|
+
for (;;) {
|
|
89
|
+
try {
|
|
90
|
+
return tail.length ? path.join(realpathSync.native(cur), ...tail)
|
|
91
|
+
: realpathSync.native(cur);
|
|
92
|
+
} catch {
|
|
93
|
+
const parent = path.dirname(cur);
|
|
94
|
+
if (parent === cur) return path.resolve(expanduser(p));
|
|
95
|
+
tail.unshift(path.basename(cur));
|
|
96
|
+
cur = parent;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
102
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
103
|
+
|
|
104
|
+
/** `(p, *p.parents)` — the path and every ancestor, nearest first. */
|
|
105
|
+
function selfAndParents(p) {
|
|
106
|
+
const out = [p];
|
|
107
|
+
let cur = p;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const up = path.dirname(cur);
|
|
110
|
+
if (up === cur) return out;
|
|
111
|
+
out.push(up);
|
|
112
|
+
cur = up;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `str.splitlines()`.
|
|
118
|
+
*
|
|
119
|
+
* `split('\n')` is not it: Python breaks on eleven boundaries, and a transcript or a
|
|
120
|
+
* memory file carrying a form feed or U+2028 would be split by one implementation and not
|
|
121
|
+
* the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
|
|
122
|
+
* `""` is none).
|
|
123
|
+
*/
|
|
124
|
+
function pySplitlines(text) {
|
|
125
|
+
if (!text) return [];
|
|
126
|
+
const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
127
|
+
if (parts.length && parts[parts.length - 1] === '') parts.pop();
|
|
128
|
+
return parts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
132
|
+
function pyWords(s) {
|
|
133
|
+
return s.split(/\s+/).filter(Boolean);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `s[-n:]` in CODE POINTS. JS slices UTF-16 units and can cut a surrogate pair in half. */
|
|
137
|
+
function tail(s, n) {
|
|
138
|
+
const cps = Array.from(s);
|
|
139
|
+
return cps.length <= n ? s : cps.slice(-n).join('');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
|
|
144
|
+
*
|
|
145
|
+
* This is a compared string, not a log line: `cmd_context` prints it verbatim when an
|
|
146
|
+
* eager entry is missing, so `ENOENT: no such file or directory, open '…'` (Node's
|
|
147
|
+
* wording, Node's quoting, no errno) is a whole-line difference in that cell. Only the
|
|
148
|
+
* codes a verb here can actually raise are mapped; anything else falls back to Node's
|
|
149
|
+
* message, which is at least honest about being different.
|
|
150
|
+
*/
|
|
151
|
+
const _ERRNO = {
|
|
152
|
+
ENOENT: [2, 'No such file or directory'],
|
|
153
|
+
EACCES: [13, 'Permission denied'],
|
|
154
|
+
EISDIR: [21, 'Is a directory'],
|
|
155
|
+
ENOTDIR: [20, 'Not a directory'],
|
|
156
|
+
EPERM: [1, 'Operation not permitted'],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function pyOsError(e, filename) {
|
|
160
|
+
const hit = _ERRNO[e && e.code];
|
|
161
|
+
if (!hit) return String((e && e.message) || e);
|
|
162
|
+
// Python reprs the filename, so a Windows path comes out with its backslashes doubled.
|
|
163
|
+
return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
|
|
164
|
+
.replace(/\\"/g, '"')}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `fnmatch.fnmatch(name, pattern)` — case-folded on Windows on BOTH sides, and `*` matches
|
|
169
|
+
* a separator (it is shell-glob semantics, not path-glob: `docs/*.md` really does select
|
|
170
|
+
* `docs/nested/deep.md`, in both implementations).
|
|
171
|
+
*/
|
|
172
|
+
function fnmatch(name, pattern) {
|
|
173
|
+
return fnTranslate(normcase(pattern)).test(normcase(name));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fnTranslate(pat) {
|
|
177
|
+
let out = '';
|
|
178
|
+
for (let i = 0; i < pat.length; i += 1) {
|
|
179
|
+
const c = pat[i];
|
|
180
|
+
if (c === '*') { out += '[\\s\\S]*'; continue; }
|
|
181
|
+
if (c === '?') { out += '[\\s\\S]'; continue; }
|
|
182
|
+
if (c === '[') {
|
|
183
|
+
let j = i + 1;
|
|
184
|
+
if (pat[j] === '!') j += 1;
|
|
185
|
+
if (pat[j] === ']') j += 1;
|
|
186
|
+
while (j < pat.length && pat[j] !== ']') j += 1;
|
|
187
|
+
// An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
|
|
188
|
+
if (j >= pat.length) { out += '\\['; continue; }
|
|
189
|
+
let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
|
|
190
|
+
if (body.startsWith('!')) body = `^${body.slice(1)}`;
|
|
191
|
+
out += `[${body}]`;
|
|
192
|
+
i = j;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
196
|
+
}
|
|
197
|
+
return new RegExp(`^(?:${out})$`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** stdin, decoded as UTF-8 with Python's universal newlines. Absent stdin reads empty. */
|
|
201
|
+
function readStdin() {
|
|
202
|
+
try {
|
|
203
|
+
return readFileSync(0, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
204
|
+
} catch {
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The two output funnels, with Python's newline translation reproduced. They moved to
|
|
210
|
+
// `js/lib/fs.mjs` in P5c — beside `writeText`, which is the same rule for FILES — when
|
|
211
|
+
// `js/excludes.mjs` became the second caller; see that docblock for why the translation
|
|
212
|
+
// exists and which gate can see it.
|
|
213
|
+
const out = pyPrint;
|
|
214
|
+
const err = pyPrintErr;
|
|
215
|
+
|
|
216
|
+
// ======================================================================================
|
|
217
|
+
// context — project-context discovery (Rule XVIII)
|
|
218
|
+
// ======================================================================================
|
|
219
|
+
|
|
220
|
+
// Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
|
|
221
|
+
const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
|
|
222
|
+
'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
|
|
223
|
+
const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
|
|
224
|
+
const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
|
|
225
|
+
'target', '.venv', '__pycache__', '.opencode', '.harness']);
|
|
226
|
+
|
|
227
|
+
const CLAUDE_MARKERS = ['.claude', '.bob'];
|
|
228
|
+
const GENESEED_MANIFEST = '.geneseed-manifest.json';
|
|
229
|
+
const EXCLUDES_FILE = 'excludes.json';
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `_global_hook_standing_down` — project-bypasses-global.
|
|
233
|
+
*
|
|
234
|
+
* A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
|
|
235
|
+
* of the SAME host sits at or above cwd, the project's hook injects and this one must not
|
|
236
|
+
* double up.
|
|
237
|
+
*/
|
|
238
|
+
export function globalHookStandingDown(hookRoot, cwd) {
|
|
239
|
+
const marker = path.basename(hookRoot);
|
|
240
|
+
if (!CLAUDE_MARKERS.includes(marker)) return false;
|
|
241
|
+
for (const d of selfAndParents(cwd)) {
|
|
242
|
+
const cand = path.join(d, marker);
|
|
243
|
+
if (isFile(path.join(cand, GENESEED_MANIFEST))) {
|
|
244
|
+
// Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
|
|
245
|
+
// the same install there and two different ones on Linux.
|
|
246
|
+
return normcase(pyResolve(cand)) !== normcase(pyResolve(hookRoot));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
|
|
254
|
+
* takes effect without a re-emit. Every failure mode degrades to false: a hook must never
|
|
255
|
+
* fail or block on a file the user owns.
|
|
256
|
+
*/
|
|
257
|
+
export function sovereignBypass(root) {
|
|
258
|
+
if (!root) return false;
|
|
259
|
+
let entries;
|
|
260
|
+
try {
|
|
261
|
+
const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
|
|
262
|
+
entries = Array.isArray(data && data.excludes) ? data.excludes : [];
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
let cwd;
|
|
267
|
+
try {
|
|
268
|
+
cwd = normcase(pyResolve(process.cwd()));
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
|
|
274
|
+
if (typeof raw !== 'string' || !raw.trim()) continue;
|
|
275
|
+
let base;
|
|
276
|
+
try {
|
|
277
|
+
// `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
|
|
278
|
+
// test below cannot be fooled by a trailing slash in the user's file.
|
|
279
|
+
//
|
|
280
|
+
// `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
|
|
281
|
+
// and this caller catches: the reference prints nothing here, so replaying the
|
|
282
|
+
// message would be a stderr divergence on every hook call for one bad entry.
|
|
283
|
+
base = withDiscardableStderr(
|
|
284
|
+
() => normcase(pyPathStr(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
|
|
285
|
+
} catch {
|
|
286
|
+
// PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
|
|
287
|
+
// must not decide the whole function — `expanduser` refuses a `~user` form, and the
|
|
288
|
+
// reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
|
|
289
|
+
// Skipping errs toward the gate staying ACTIVE, which is the safe direction: this
|
|
290
|
+
// feeds `git-gate`/`rule-gate`, which exit 0 and signal on stdout, so a crash here
|
|
291
|
+
// would read as a clean pass while gating nothing.
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (cwd === base || cwd.startsWith(base + path.sep)) return true;
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `_disp` — relative to the repo root when it sits under it, else verbatim. */
|
|
300
|
+
function disp(pathStr, root) {
|
|
301
|
+
// `os.path.relpath` raises ValueError across drives and the Python falls back to the
|
|
302
|
+
// path as given; `path.relative` silently returns an absolute path instead, which would
|
|
303
|
+
// print a DIFFERENT string rather than the same one.
|
|
304
|
+
if (path.parse(path.resolve(pathStr)).root.toLowerCase()
|
|
305
|
+
!== path.parse(path.resolve(root)).root.toLowerCase()) {
|
|
306
|
+
return pathStr;
|
|
307
|
+
}
|
|
308
|
+
return path.relative(root, pathStr).split(path.sep).join('/');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** `Path.glob`/`rglob` order: `sorted()` over paths, which is case-folded on Windows. */
|
|
312
|
+
function sortPaths(list) {
|
|
313
|
+
return list.slice().sort(comparePaths);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function listDir(dir) {
|
|
317
|
+
try {
|
|
318
|
+
return readdirSync(dir);
|
|
319
|
+
} catch {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rglobMd(dir, acc = []) {
|
|
325
|
+
for (const name of listDir(dir)) {
|
|
326
|
+
const full = path.join(dir, name);
|
|
327
|
+
if (isDir(full)) rglobMd(full, acc);
|
|
328
|
+
else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
|
|
329
|
+
}
|
|
330
|
+
return acc;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
|
|
335
|
+
* Root entry docs are eager; other root markdown, the doc trees and monorepo package
|
|
336
|
+
* READMEs are lazy.
|
|
337
|
+
*/
|
|
338
|
+
export function discoverContext(root) {
|
|
339
|
+
const eager = new Map();
|
|
340
|
+
const lazy = new Map();
|
|
341
|
+
for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
|
|
342
|
+
if (!isFile(full)) continue;
|
|
343
|
+
const name = path.basename(full);
|
|
344
|
+
if (EAGER_ROOT.includes(name)) eager.set(full, null);
|
|
345
|
+
else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
|
|
346
|
+
}
|
|
347
|
+
for (const d of LAZY_DIRS) {
|
|
348
|
+
const sub = path.join(root, d);
|
|
349
|
+
if (!isDir(sub)) continue;
|
|
350
|
+
for (const md of sortPaths(rglobMd(sub))) {
|
|
351
|
+
const parts = path.relative(root, md).split(/[\\/]/);
|
|
352
|
+
if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
|
|
353
|
+
if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const group of ['packages', 'apps']) {
|
|
357
|
+
const base = path.join(root, group);
|
|
358
|
+
if (!isDir(base)) continue;
|
|
359
|
+
for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
|
|
360
|
+
if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
|
|
361
|
+
const readme = path.join(pkg, 'README.md');
|
|
362
|
+
if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return [
|
|
366
|
+
[...eager.keys()].map((p) => ({ path: p, description: '' })),
|
|
367
|
+
[...lazy.keys()].filter((p) => !eager.has(p)).map((p) => ({ path: p, description: '' })),
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* `_resolve_context_sets` — an explicit manifest wins, `"extend": true` layers it on top
|
|
373
|
+
* of discovery, and an empty stub falls through to pure discovery.
|
|
374
|
+
*
|
|
375
|
+
* `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
|
|
376
|
+
* these keys are absolute paths whose iteration order becomes the printed order.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveContextSets(root) {
|
|
379
|
+
let manifest = null;
|
|
380
|
+
const env = process.env.GENESEED_CONTEXT;
|
|
381
|
+
if (env && isFile(env)) {
|
|
382
|
+
// `Path(env)`, and the label is printed — so the separators are folded exactly the
|
|
383
|
+
// way `str(Path(...))` folds them, or the source line differs by every slash in it.
|
|
384
|
+
manifest = pyPathStr(env);
|
|
385
|
+
} else {
|
|
386
|
+
for (const cand of [path.join(root, '.harness', 'context.json'),
|
|
387
|
+
path.join(root, 'context.json')]) {
|
|
388
|
+
if (isFile(cand)) { manifest = cand; break; }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (manifest === null) {
|
|
393
|
+
const [e, l] = discoverContext(root);
|
|
394
|
+
return [e, l, `auto-discovery [${root}]`];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let raw;
|
|
398
|
+
try {
|
|
399
|
+
raw = readText(manifest);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
err(`[context] could not read ${manifest}: ${pyOsError(e, manifest)}\n`);
|
|
402
|
+
return [[], [], String(manifest)];
|
|
403
|
+
}
|
|
404
|
+
let entries;
|
|
405
|
+
let extend;
|
|
406
|
+
try {
|
|
407
|
+
let data = JSON.parse(raw);
|
|
408
|
+
// Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
|
|
409
|
+
// would not have thrown here (`[].context` is merely undefined), which is exactly why
|
|
410
|
+
// the guard is written out rather than left to fall out of the language: without it,
|
|
411
|
+
// the two CLIs disagree about a file a user really does hand-edit.
|
|
412
|
+
data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
413
|
+
entries = data.context || [];
|
|
414
|
+
if (!Array.isArray(entries)) entries = [];
|
|
415
|
+
extend = Boolean(data.extend);
|
|
416
|
+
} catch {
|
|
417
|
+
// See the Python original for why the decoder's own message is not quoted: the two
|
|
418
|
+
// engines disagree on the wording and on the offset, and V8 supplies no offset at all
|
|
419
|
+
// for the commonest shapes. Reporting the file is what the user needs; reporting
|
|
420
|
+
// WHICH decoder found it is what makes two implementations impossible.
|
|
421
|
+
err(`[context] ${manifest} is not valid JSON — no context was loaded `
|
|
422
|
+
+ '(fix the syntax, then re-run).\n');
|
|
423
|
+
return [[], [], String(manifest)];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!entries.length && !extend) {
|
|
427
|
+
const [e, l] = discoverContext(root);
|
|
428
|
+
return [e, l, `auto-discovery [${root}] (empty ${path.basename(manifest)})`];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const recs = new Map();
|
|
432
|
+
const put = (pathStr, load, desc) => {
|
|
433
|
+
const prev = recs.get(pathStr);
|
|
434
|
+
recs.set(pathStr, { path: pathStr, load, description: desc || (prev ? prev.description : '') });
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
if (extend) {
|
|
438
|
+
const [de, dl] = discoverContext(root);
|
|
439
|
+
for (const x of de) put(x.path, 'eager', '');
|
|
440
|
+
for (const x of dl) put(x.path, 'lazy', '');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
445
|
+
const raw = typeof entry.path === 'string' ? entry.path.trim() : '';
|
|
446
|
+
if (!raw) continue;
|
|
447
|
+
const load = entry.load === undefined ? 'eager' : entry.load;
|
|
448
|
+
const desc = entry.description === undefined ? '' : entry.description;
|
|
449
|
+
if (raw.includes('*')) {
|
|
450
|
+
// A glob RECLASSIFIES files already known; it never pulls in new ones.
|
|
451
|
+
for (const [pathStr, rec] of [...recs.entries()]) {
|
|
452
|
+
if (fnmatch(disp(pathStr, root), raw)) put(pathStr, load, desc || rec.description);
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const abs = path.isAbsolute(raw) ? raw : pyResolve(path.join(root, raw));
|
|
457
|
+
put(abs, load, desc);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const eager = [];
|
|
461
|
+
const lazy = [];
|
|
462
|
+
for (const r of recs.values()) {
|
|
463
|
+
(r.load === 'eager' ? eager : lazy).push({ path: r.path, description: r.description });
|
|
464
|
+
}
|
|
465
|
+
return [eager, lazy, String(manifest)];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function cmdContext(args) {
|
|
469
|
+
// Discovery runs against the project root the hook was launched from — Claude runs
|
|
470
|
+
// SessionStart hooks with cwd = repo root — not the harness package dir.
|
|
471
|
+
// The only two `pyResolve` calls in this file whose argument can be a `~user` path the
|
|
472
|
+
// user typed — `$GENESEED_ROOT` and `--root` — and `pyResolve` expands, so both can now
|
|
473
|
+
// throw. Every other caller either builds its argument by `path.join` from an absolute
|
|
474
|
+
// root (so no leading tilde survives) or already sits inside a `try`. Refused input
|
|
475
|
+
// injects nothing, which is the same degrade as "nothing to load" below.
|
|
476
|
+
let root;
|
|
477
|
+
let hookRoot;
|
|
478
|
+
try {
|
|
479
|
+
({ root, hookRoot } = withDiscardableStderr(() => ({
|
|
480
|
+
root: pyResolve(process.env.GENESEED_ROOT || process.cwd()),
|
|
481
|
+
hookRoot: args.root ? pyResolve(args.root) : null,
|
|
482
|
+
})));
|
|
483
|
+
} catch {
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
if (hookRoot && sovereignBypass(hookRoot)) return 0;
|
|
487
|
+
if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
|
|
488
|
+
&& globalHookStandingDown(hookRoot, root)) return 0;
|
|
489
|
+
const [eager, lazy, source] = resolveContextSets(root);
|
|
490
|
+
if (!eager.length && !lazy.length) {
|
|
491
|
+
err(`[context] nothing to load for ${root} `
|
|
492
|
+
+ '(no docs discovered, no manifest entries).\n');
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const lines = [
|
|
497
|
+
`=== PROJECT CONTEXT \u2014 binding for this repo per Rule XVIII (via ${source}) ===`,
|
|
498
|
+
'',
|
|
499
|
+
];
|
|
500
|
+
for (const entry of eager) {
|
|
501
|
+
const p = entry.path === undefined ? '' : entry.path;
|
|
502
|
+
const desc = entry.description === undefined ? '' : entry.description;
|
|
503
|
+
const target = path.isAbsolute(p) ? p : path.join(root, p);
|
|
504
|
+
lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`);
|
|
505
|
+
try {
|
|
506
|
+
lines.push(readText(target).replace(/\n+$/, ''));
|
|
507
|
+
} catch (e) {
|
|
508
|
+
lines.push(`[context] MISSING eager file: ${pyOsError(e, target)}`);
|
|
509
|
+
}
|
|
510
|
+
lines.push('');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (lazy.length) {
|
|
514
|
+
lines.push('--- Lazy entries (load only when the task needs them) ---');
|
|
515
|
+
for (const entry of lazy) {
|
|
516
|
+
const p = entry.path === undefined ? '' : entry.path;
|
|
517
|
+
const desc = entry.description === undefined ? '' : entry.description;
|
|
518
|
+
lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}`);
|
|
519
|
+
}
|
|
520
|
+
lines.push('');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
out(`${lines.join('\n')}\n`);
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ======================================================================================
|
|
528
|
+
// git-gate — Law XX's tool-boundary backstop
|
|
529
|
+
// ======================================================================================
|
|
530
|
+
|
|
531
|
+
// A `git` verb ANYWHERE in the command trips the gate, including the chained
|
|
532
|
+
// (`git add . && git commit … && git push`) and `-C <path>` forms, so a compound
|
|
533
|
+
// one-liner can never slip a commit past the prompt. `[^\n]*` is the boundary: a `git` on
|
|
534
|
+
// one line and a `commit` on the next are two commands, not one.
|
|
535
|
+
const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
|
|
536
|
+
|
|
537
|
+
/** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
|
|
538
|
+
function askDecision(reason) {
|
|
539
|
+
return `${jsonDumpsCompact({
|
|
540
|
+
hookSpecificOutput: {
|
|
541
|
+
hookEventName: 'PreToolUse',
|
|
542
|
+
permissionDecision: 'ask',
|
|
543
|
+
permissionDecisionReason: reason,
|
|
544
|
+
},
|
|
545
|
+
})}\n`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function cmdGitGate(args) {
|
|
549
|
+
if (sovereignBypass(args.root)) return 0;
|
|
550
|
+
let command;
|
|
551
|
+
try {
|
|
552
|
+
const payload = JSON.parse(readStdin() || '{}');
|
|
553
|
+
command = ((payload && payload.tool_input) || {}).command;
|
|
554
|
+
} catch {
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
if (typeof command !== 'string' || !GIT_GATE_RE.test(command)) return 0;
|
|
558
|
+
out(askDecision('Geneseed Law XX \u2014 every git commit/push needs explicit approval'));
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ======================================================================================
|
|
563
|
+
// rule-gate — Law VI's tool-boundary backstop
|
|
564
|
+
// ======================================================================================
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* `_rule_gate_target` — name the store a write would land in, or null for ordinary work.
|
|
568
|
+
*
|
|
569
|
+
* A deployed install's folder and file names are theme-INDEPENDENT by design, so this
|
|
570
|
+
* matches literal names and needs no install lookup. `user-rules.md` and `MEMORY.md` are
|
|
571
|
+
* Geneseed's own coinage and match anywhere; any other markdown matches only inside THIS
|
|
572
|
+
* install's `<root>/memory/`.
|
|
573
|
+
*/
|
|
574
|
+
export function ruleGateTarget(p, root) {
|
|
575
|
+
if (typeof p !== 'string') return null;
|
|
576
|
+
// Split on BOTH separators rather than taking a basename: a payload carrying a Windows
|
|
577
|
+
// path reaches a POSIX host with its backslashes intact, and `path.basename` there
|
|
578
|
+
// returns the whole string — so the two coined names would go unrecognised exactly
|
|
579
|
+
// where they are meant to match on whichever host reads them.
|
|
580
|
+
const name = p.split(/[\\/]/).pop().toLowerCase();
|
|
581
|
+
if (name === 'user-rules.md') return 'user-rules.md';
|
|
582
|
+
if (name === 'memory.md') return 'the memory index';
|
|
583
|
+
if (!root || !name.endsWith('.md')) return null;
|
|
584
|
+
try {
|
|
585
|
+
const store = normcase(pyResolve(path.join(root, 'memory')));
|
|
586
|
+
// `.parents`, so the store dir itself is not one of its own ancestors.
|
|
587
|
+
return selfAndParents(pyResolve(p)).slice(1).some((d) => normcase(d) === store)
|
|
588
|
+
? 'memory' : null;
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function cmdRuleGate(args) {
|
|
595
|
+
if (sovereignBypass(args.root)) return 0;
|
|
596
|
+
let p;
|
|
597
|
+
try {
|
|
598
|
+
const payload = JSON.parse(readStdin() || '{}');
|
|
599
|
+
const ti = (payload && payload.tool_input) || {};
|
|
600
|
+
p = ti.file_path || ti.path || '';
|
|
601
|
+
} catch {
|
|
602
|
+
return 0;
|
|
603
|
+
}
|
|
604
|
+
if (typeof p !== 'string' || !p) return 0;
|
|
605
|
+
const target = ruleGateTarget(p, args.root);
|
|
606
|
+
if (!target) return 0;
|
|
607
|
+
out(askDecision(`Geneseed Law VI \u2014 writing to ${target}: a standing rule, or a fact to `
|
|
608
|
+
+ "remember? That choice is the user's. Run the rule skill first."));
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ======================================================================================
|
|
613
|
+
// learn — distil notes/transcripts into deduped memory entries
|
|
614
|
+
// ======================================================================================
|
|
615
|
+
|
|
616
|
+
const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
|
|
617
|
+
const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
|
|
618
|
+
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
619
|
+
const MAX_AGENT_BULLETS = 100;
|
|
620
|
+
const MAX_NOTES_CHARS = 16000;
|
|
621
|
+
// Host payloads name the finished subagent inconsistently; try each.
|
|
622
|
+
const SUBAGENT_NAME_FIELDS = ['agent_name', 'agent_type', 'subagent_type', 'agent',
|
|
623
|
+
'agent_id'];
|
|
624
|
+
|
|
625
|
+
const PLUGIN_LEARN = path.join(ROOT, 'adapters', 'opencode', 'plugins', 'geneseed-learn.js');
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* `_load_learn_prompt_head` / `_load_agent_lesson_prompt`.
|
|
629
|
+
*
|
|
630
|
+
* The single source of truth for both prompts is the OpenCode plugin — the artifact that
|
|
631
|
+
* ships to the primary runtime — and both CLIs extract the template literal from its
|
|
632
|
+
* SOURCE rather than keeping a copy. Reading it means this port cannot drift from the
|
|
633
|
+
* plugin either; a copied constant here would be a third place to edit.
|
|
634
|
+
*/
|
|
635
|
+
function pluginLiteral(name, fallback) {
|
|
636
|
+
try {
|
|
637
|
+
const m = new RegExp(`const ${name} = \`([\\s\\S]*?)\``).exec(readText(PLUGIN_LEARN));
|
|
638
|
+
if (m) return m[1];
|
|
639
|
+
} catch { /* unreadable plugin must never crash a Stop hook */ }
|
|
640
|
+
return fallback;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export const LEARN_PROMPT_HEAD = pluginLiteral('LEARN_PROMPT_HEAD',
|
|
644
|
+
'Distil at most one durable, reusable memory from the notes below. '
|
|
645
|
+
+ 'When in doubt, output exactly: NOTHING.');
|
|
646
|
+
const AGENT_LESSON_PROMPT = pluginLiteral('AGENT_LESSON_PROMPT',
|
|
647
|
+
'Output AT MOST ONE line: a durable lesson about how this agent should '
|
|
648
|
+
+ 'operate on a future dispatch. Else output exactly: NOTHING.');
|
|
649
|
+
|
|
650
|
+
/** `resolve_agent_name` — a safe agent slug from a raw field value, or null. */
|
|
651
|
+
export function resolveAgentName(raw) {
|
|
652
|
+
const name = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
|
|
653
|
+
return name && AGENT_NAME_RE.test(name) ? name : null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
|
|
657
|
+
export function appendAgentLesson(memDir, agent, lesson) {
|
|
658
|
+
const d = path.join(memDir, 'agents');
|
|
659
|
+
mkdirSync(d, { recursive: true });
|
|
660
|
+
const f = path.join(d, `${agent}.md`);
|
|
661
|
+
let bullets = [];
|
|
662
|
+
if (existsSync(f)) bullets = pySplitlines(readText(f)).filter((l) => l.startsWith('- '));
|
|
663
|
+
const now = new Date();
|
|
664
|
+
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
|
|
665
|
+
+ `${String(now.getDate()).padStart(2, '0')}`;
|
|
666
|
+
bullets.push(`- ${day}: ${pyWords(lesson).join(' ')}`);
|
|
667
|
+
bullets = bullets.slice(-MAX_AGENT_BULLETS);
|
|
668
|
+
writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
|
|
669
|
+
return f;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** The lifecycle-hook payload if stdin is one, else `{}`. */
|
|
673
|
+
function hookMeta(raw) {
|
|
674
|
+
const s = raw.trim();
|
|
675
|
+
if (s.slice(0, 1) !== '{') return {};
|
|
676
|
+
try { return JSON.parse(s); } catch { return {}; }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Flatten a message `content` field (string, or a list of blocks) to text. */
|
|
680
|
+
function contentText(content) {
|
|
681
|
+
if (typeof content === 'string') return content;
|
|
682
|
+
if (Array.isArray(content)) {
|
|
683
|
+
const parts = [];
|
|
684
|
+
for (const block of content) {
|
|
685
|
+
if (typeof block === 'string') parts.push(block);
|
|
686
|
+
else if (block && typeof block === 'object' && block.type === 'text') {
|
|
687
|
+
parts.push(block.text === undefined ? '' : block.text);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return parts.join('\n');
|
|
691
|
+
}
|
|
692
|
+
return '';
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
|
|
696
|
+
function flattenTranscript(p) {
|
|
697
|
+
let raw;
|
|
698
|
+
try {
|
|
699
|
+
// `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
|
|
700
|
+
raw = readText(p);
|
|
701
|
+
} catch {
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
const acc = [];
|
|
705
|
+
for (let line of pySplitlines(raw)) {
|
|
706
|
+
line = line.trim();
|
|
707
|
+
if (!line) continue;
|
|
708
|
+
let obj;
|
|
709
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
710
|
+
const msg = obj.message || {};
|
|
711
|
+
const role = msg.role || obj.role || obj.type;
|
|
712
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
713
|
+
const text = contentText(msg.content === undefined ? obj.content : msg.content).trim();
|
|
714
|
+
if (text) acc.push(`${role}: ${text}`);
|
|
715
|
+
}
|
|
716
|
+
return acc.join('\n\n');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* `_read_notes` — a lifecycle-hook payload with a `transcript_path` is read and flattened
|
|
721
|
+
* (this is what makes wiring `learn` to a Stop hook work with no redirection); anything
|
|
722
|
+
* else is used as-is.
|
|
723
|
+
*/
|
|
724
|
+
/**
|
|
725
|
+
* EXPORTED FOR THE UNIT TIER, and the rule this follows is written down once here for the three
|
|
726
|
+
* `learn` helpers below it.
|
|
727
|
+
*
|
|
728
|
+
* `docs/specs`'s P3 rule is: drive a property through the PUBLIC entry by default, and export a
|
|
729
|
+
* decision only when the property is invisible through that face. The `learn` verb's WIRING is
|
|
730
|
+
* already gated — 78 cells in the CLI matrix drive it end to end — so what a unit test would add
|
|
731
|
+
* there is nothing. What no cell can see is these three functions' behaviour on inputs a cell
|
|
732
|
+
* cannot produce: a model that answers NOTHING, a duplicate slug, a `README.md` beside the
|
|
733
|
+
* store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
|
|
734
|
+
* reaches none of them.
|
|
735
|
+
*/
|
|
736
|
+
export function readNotes(raw) {
|
|
737
|
+
const s = raw.trim();
|
|
738
|
+
if (!s) return '';
|
|
739
|
+
if (s[0] === '{') {
|
|
740
|
+
let payload;
|
|
741
|
+
try { payload = JSON.parse(s); } catch { return raw; }
|
|
742
|
+
const tp = payload && payload.transcript_path;
|
|
743
|
+
if (tp) return flattenTranscript(tp);
|
|
744
|
+
return raw;
|
|
745
|
+
}
|
|
746
|
+
return raw;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// `resolveMemoryDir` moved to `js/hosts.mjs` in P5d — see the import at the top of this
|
|
750
|
+
// file. Re-exported here so the hook entry's own import list does not change.
|
|
751
|
+
export { resolveMemoryDir };
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
|
|
755
|
+
*
|
|
756
|
+
* Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
|
|
757
|
+
* exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
|
|
758
|
+
* this port treats as a coverage event rather than a tidiness one — the fields it produces
|
|
759
|
+
* are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
|
|
760
|
+
* what gate them.
|
|
761
|
+
*/
|
|
762
|
+
export function frontmatter(md) {
|
|
763
|
+
const m = FRONTMATTER_RE.exec(md);
|
|
764
|
+
if (!m) return [new Map(), md];
|
|
765
|
+
const fm = new Map();
|
|
766
|
+
for (const line of pySplitlines(m[1])) {
|
|
767
|
+
const at = line.indexOf(':');
|
|
768
|
+
if (at >= 0) {
|
|
769
|
+
// `str.strip('"')` strips the character from BOTH ends, however many there are.
|
|
770
|
+
fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return [fm, m[2]];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
|
|
777
|
+
|
|
778
|
+
/** `Path.stem` for a filename. */
|
|
779
|
+
const stem = (name) => path.basename(name, path.extname(name));
|
|
780
|
+
|
|
781
|
+
/** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
|
|
782
|
+
function globMd(dir) {
|
|
783
|
+
return listDir(dir).filter((n) => normcase(n).endsWith('.md')
|
|
784
|
+
&& isFile(path.join(dir, n)));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
|
|
788
|
+
export function existingSlugs(memDir) {
|
|
789
|
+
const skip = new Set(['memory', 'readme']);
|
|
790
|
+
return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function buildLearnPrompt(notes, existing) {
|
|
794
|
+
const parts = [LEARN_PROMPT_HEAD, ''];
|
|
795
|
+
if (existing.size) {
|
|
796
|
+
parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
|
|
797
|
+
+ 'slugs (skip updates too; only genuinely new facts):');
|
|
798
|
+
parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
|
|
799
|
+
parts.push('');
|
|
800
|
+
}
|
|
801
|
+
parts.push('NOTES:', notes);
|
|
802
|
+
return parts.join('\n');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* `_write_memories` — split the model output into files, write each NEW one, and append a
|
|
807
|
+
* pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
|
|
808
|
+
* twice writes it once.
|
|
809
|
+
*/
|
|
810
|
+
export function writeMemories(modelOutput, memDir, existing) {
|
|
811
|
+
const written = [];
|
|
812
|
+
const indexLines = [];
|
|
813
|
+
for (let chunk of modelOutput.split(FILE_SEP_RE)) {
|
|
814
|
+
chunk = chunk.trim();
|
|
815
|
+
if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
|
|
816
|
+
const [fm] = frontmatter(chunk);
|
|
817
|
+
const name = fmGet(fm, 'name').trim();
|
|
818
|
+
if (!name || existing.has(name)) continue;
|
|
819
|
+
writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
|
|
820
|
+
existing.add(name);
|
|
821
|
+
written.push(name);
|
|
822
|
+
const desc = fmGet(fm, 'description').trim();
|
|
823
|
+
indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
824
|
+
}
|
|
825
|
+
if (indexLines.length) {
|
|
826
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
827
|
+
const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
|
|
828
|
+
: '# Memory Index\n';
|
|
829
|
+
writeText(index, `${current}${indexLines.join('\n')}\n`);
|
|
830
|
+
}
|
|
831
|
+
return written;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
|
|
836
|
+
*
|
|
837
|
+
* ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
|
|
838
|
+
* `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
|
|
839
|
+
* them, so the three rules about MEMORY.md's format live together rather than one of them
|
|
840
|
+
* living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
|
|
841
|
+
*
|
|
842
|
+
* The P6f plan asked whether P5's `learn` had already brought it across. It had not —
|
|
843
|
+
* nothing in `js/` referenced it — which is the third time a plan's "already ported" column
|
|
844
|
+
* was answered by reading rather than by trusting.
|
|
845
|
+
*
|
|
846
|
+
* SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
|
|
847
|
+
* so a hand-written index in a different shape still loses its entry. And the file is only
|
|
848
|
+
* REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
|
|
849
|
+
* touching the user's mtime.
|
|
850
|
+
*/
|
|
851
|
+
export function memoryDropIndex(memDir, name) {
|
|
852
|
+
const idx = path.join(memDir, 'MEMORY.md');
|
|
853
|
+
let lines;
|
|
854
|
+
try {
|
|
855
|
+
lines = pySplitlines(readText(idx));
|
|
856
|
+
} catch {
|
|
857
|
+
return; // the reference's `except OSError: return`
|
|
858
|
+
}
|
|
859
|
+
const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
|
|
860
|
+
if (keep.length === lines.length) return;
|
|
861
|
+
try {
|
|
862
|
+
writeText(idx, `${keep.join('\n')}\n`);
|
|
863
|
+
} catch { /* the reference swallows OSError here too */ }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
|
|
868
|
+
* orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
|
|
869
|
+
* the user's to keep).
|
|
870
|
+
*/
|
|
871
|
+
export function consolidateMemory(memDir) {
|
|
872
|
+
const skip = new Set(['memory', 'readme']);
|
|
873
|
+
const facts = new Map();
|
|
874
|
+
for (const name of sortPaths(globMd(memDir))) {
|
|
875
|
+
const s = stem(name);
|
|
876
|
+
if (skip.has(s.toLowerCase())) continue;
|
|
877
|
+
try {
|
|
878
|
+
facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
|
|
879
|
+
} catch { continue; }
|
|
880
|
+
}
|
|
881
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
882
|
+
const oldSlugs = new Set();
|
|
883
|
+
if (existsSync(index)) {
|
|
884
|
+
for (const line of pySplitlines(readText(index))) {
|
|
885
|
+
const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
|
|
886
|
+
if (m) oldSlugs.add(m[1]);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const lines = ['# Memory Index', ''];
|
|
890
|
+
for (const [slug, desc] of facts) {
|
|
891
|
+
lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
892
|
+
}
|
|
893
|
+
writeText(index, `${lines.join('\n')}\n`);
|
|
894
|
+
const seen = new Map();
|
|
895
|
+
const dups = [];
|
|
896
|
+
for (const [slug, desc] of facts) {
|
|
897
|
+
if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
|
|
898
|
+
if (!seen.has(desc)) seen.set(desc, slug);
|
|
899
|
+
}
|
|
900
|
+
const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
|
|
901
|
+
const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
|
|
902
|
+
return { added, pruned, duplicates: dups };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The model CLI, run as `$GENESEED_LLM`'s words plus the prompt.
|
|
907
|
+
*
|
|
908
|
+
* `encoding="utf-8"` on the Python side and `'utf8'` here, never the console code page: a
|
|
909
|
+
* distilled memory carrying an accent or an em dash is WRITTEN to the store, so a
|
|
910
|
+
* mis-decode outlives the process. `maxBuffer` is lifted off Node's 1 MB default, which
|
|
911
|
+
* would truncate a long reply into a silently-half-parsed set of memory files.
|
|
912
|
+
*/
|
|
913
|
+
function runLlm(llm, prompt) {
|
|
914
|
+
const argv = pyWords(llm);
|
|
915
|
+
// `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
|
|
916
|
+
// which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
|
|
917
|
+
// otherwise pops a console for as long as it takes to answer.
|
|
918
|
+
const proc = spawnSync(argv[0], [...argv.slice(1), prompt],
|
|
919
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW });
|
|
920
|
+
// Universal newlines, because Python reads the pipe in TEXT mode: a model CLI that is
|
|
921
|
+
// itself a Python script emits CRLF on Windows, `subprocess` folds it to `\n`, and
|
|
922
|
+
// `write_text` expands it again on the way into the memory file. Node hands back the raw
|
|
923
|
+
// bytes, so without this the stored fact is written with `\r\r\n` on every line.
|
|
924
|
+
const universal = (s) => (s === null || s === undefined ? ''
|
|
925
|
+
: String(s).replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
|
|
926
|
+
return {
|
|
927
|
+
stdout: universal(proc.stdout),
|
|
928
|
+
stderr: universal(proc.stderr),
|
|
929
|
+
// A spawn that never started is Python's FileNotFoundError, which propagates there;
|
|
930
|
+
// here it surfaces as a non-zero code with the reason on stderr rather than a stack.
|
|
931
|
+
returncode: proc.error ? 1 : (proc.status === null ? 1 : proc.status),
|
|
932
|
+
error: proc.error,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* `_learn_agent_lesson` — the SubagentStop path: at most one per-agent lesson into
|
|
938
|
+
* `memory/agents/<name>.md`. Skips silently when the host does not name the subagent:
|
|
939
|
+
* parity of mechanism, degrading to a no-op, never a crash.
|
|
940
|
+
*/
|
|
941
|
+
function learnAgentLesson(meta, notes, args) {
|
|
942
|
+
let agent = null;
|
|
943
|
+
for (const field of SUBAGENT_NAME_FIELDS) {
|
|
944
|
+
agent = resolveAgentName(meta[field]);
|
|
945
|
+
if (agent) break;
|
|
946
|
+
}
|
|
947
|
+
if (!agent) return 0;
|
|
948
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
949
|
+
if (!memDir) return 0;
|
|
950
|
+
const prompt = [AGENT_LESSON_PROMPT, '', 'NOTES:', notes].join('\n');
|
|
951
|
+
const llm = process.env.GENESEED_LLM;
|
|
952
|
+
if (!llm) {
|
|
953
|
+
err('[learn] $GENESEED_LLM unset \u2014 printing agent-lesson prompt.\n\n');
|
|
954
|
+
out(`${prompt}\n`);
|
|
955
|
+
return 0;
|
|
956
|
+
}
|
|
957
|
+
const proc = runLlm(llm, prompt);
|
|
958
|
+
const text = proc.stdout.trim();
|
|
959
|
+
if (text && text.toUpperCase() !== 'NOTHING') {
|
|
960
|
+
const lesson = pySplitlines(text)[0].replace(/^[-*]+/, '').trim();
|
|
961
|
+
const n = Array.from(lesson).length; // code points, as `len()` counts them
|
|
962
|
+
if (n >= 10 && n <= 300) {
|
|
963
|
+
err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
|
|
967
|
+
return proc.returncode;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function cmdLearn(args) {
|
|
971
|
+
if (args.consolidate) {
|
|
972
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
973
|
+
if (!memDir) {
|
|
974
|
+
err('[learn] no memory store found \u2014 nothing to consolidate.\n');
|
|
975
|
+
return 0;
|
|
976
|
+
}
|
|
977
|
+
const report = consolidateMemory(memDir);
|
|
978
|
+
err(`[learn] consolidated ${memDir}: +${report.added.length} indexed, `
|
|
979
|
+
+ `-${report.pruned.length} pruned, `
|
|
980
|
+
+ `${report.duplicates.length} duplicate description(s)\n`);
|
|
981
|
+
for (const [a, b] of report.duplicates) err(` duplicate: ${a} <-> ${b}\n`);
|
|
982
|
+
return 0;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
|
|
986
|
+
// folder the global install must not learn.
|
|
987
|
+
if (args.memory && sovereignBypass(path.dirname(pyPathStr(args.memory)))) return 0;
|
|
988
|
+
|
|
989
|
+
const raw = args.file ? readText(args.file) : readStdin();
|
|
990
|
+
let notes = readNotes(raw);
|
|
991
|
+
if (!notes.trim()) {
|
|
992
|
+
err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
|
|
993
|
+
return 0;
|
|
994
|
+
}
|
|
995
|
+
notes = tail(notes, MAX_NOTES_CHARS); // keep the tail: most recent, most durable
|
|
996
|
+
|
|
997
|
+
const meta = hookMeta(raw);
|
|
998
|
+
if (meta.hook_event_name === 'SubagentStop') return learnAgentLesson(meta, notes, args);
|
|
999
|
+
|
|
1000
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
1001
|
+
const existing = memDir ? existingSlugs(memDir) : new Set();
|
|
1002
|
+
const prompt = buildLearnPrompt(notes, existing);
|
|
1003
|
+
|
|
1004
|
+
const llm = process.env.GENESEED_LLM;
|
|
1005
|
+
if (!llm) {
|
|
1006
|
+
err('[learn] $GENESEED_LLM unset \u2014 printing prompt instead.\n\n');
|
|
1007
|
+
out(`${prompt}\n`);
|
|
1008
|
+
return 0;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const proc = runLlm(llm, prompt);
|
|
1012
|
+
const output = proc.stdout;
|
|
1013
|
+
if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
|
|
1014
|
+
const written = writeMemories(output, memDir, existing);
|
|
1015
|
+
if (written.length) {
|
|
1016
|
+
err(`[learn] wrote ${written.length} memory file(s) to `
|
|
1017
|
+
+ `${memDir}: ${written.join(', ')}\n`);
|
|
1018
|
+
} else {
|
|
1019
|
+
err('[learn] nothing new to store (all duplicates).\n');
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
out(output);
|
|
1023
|
+
}
|
|
1024
|
+
if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
|
|
1025
|
+
return proc.returncode;
|
|
1026
|
+
}
|