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/web/api.mjs
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web console's read endpoints — P6b's `overview`, `themes`, `doctor`, `diff`,
|
|
3
|
+
* `installs`, `excludes`, `setup` and `profile`, P6c's `catalog`, `item` and `wiki_item`,
|
|
4
|
+
* and the `WebState` all eleven read from.
|
|
5
|
+
*
|
|
6
|
+
* WHAT WAS ACTUALLY NEW HERE, MEASURED BY READING `js/` RATHER THAN BY GREPPING A NAME.
|
|
7
|
+
* The P6 handoff scored this group at "~430 LOC new against ~1,600 already ported" and
|
|
8
|
+
* the order was right, but the shape is worth stating because it is the same discovery
|
|
9
|
+
* every P5 sub-phase made: six of the eight are a JSON face over a function `js/` already
|
|
10
|
+
* owns. `apiSetup` is `statusData()` plus four fields. `apiDiff` is `diffCollect()`
|
|
11
|
+
* reshaped. `apiExcludes` is `excludesSnapshot()`, unmodified. `apiInstalls` is a row per
|
|
12
|
+
* `installTargets()` entry through five detectors P5d and P5f ported. `apiDoctor` is
|
|
13
|
+
* `doctorCollect()` — with the one parameter it did not have. Only `apiThemes` and
|
|
14
|
+
* `apiOverview` compute anything of their own, and most of what `apiOverview` computes is
|
|
15
|
+
* counting.
|
|
16
|
+
*
|
|
17
|
+
* THE ONE PARAMETER: `doctorCollect({groups})`. `js/doctor.mjs` carried a `ran(check,
|
|
18
|
+
* label, probs)` that returned its third argument and threw the first two away, with a
|
|
19
|
+
* docblock saying the label stays "because it is the one place each check is NAMED, and
|
|
20
|
+
* what P6's accumulator will key on". This is that phase. `on_progress=` — the TUI's — is
|
|
21
|
+
* still absent, still P7's: nothing here passes it and adding it would be a claim no cell
|
|
22
|
+
* can check.
|
|
23
|
+
*
|
|
24
|
+
* THE INVENTORY IS THE FULL RECORD SINCE P6c, AND THERE IS STILL ONE CLASSIFIER. P6b
|
|
25
|
+
* shipped `inventoryFor` as three integers, because `apiOverview` reads three `len()`s off
|
|
26
|
+
* `state.inventory` and the badges and taxonomy classes belonged to a phase that had not
|
|
27
|
+
* arrived. `api_catalog` consumes exactly those fields, so this is the phase that would
|
|
28
|
+
* have made the counting half and the reading half two classifiers. It did not:
|
|
29
|
+
* `tuiInventory` in `js/inventory.mjs` is the one walk, `js/status.mjs`'s `inventoryCounts`
|
|
30
|
+
* is three `length`s off it, and `specEntries` grew out of `specNames` by adding the READ
|
|
31
|
+
* rather than by being written beside it.
|
|
32
|
+
*
|
|
33
|
+
* THE TAXONOMY WAS ALREADY PORTED, which the measurement found and the handoff did not.
|
|
34
|
+
* `js/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
|
|
35
|
+
* `SKILL_CLASS`, `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g inside
|
|
36
|
+
* `js/doctor.mjs`, because doctor's authoring gates are what validate them. P6c moved the
|
|
37
|
+
* four to `js/inventory.mjs` — where Python keeps them — rather than copying them, and
|
|
38
|
+
* doctor imports them back. Only `parse_laws`, `load_registry` and `entity_status` were
|
|
39
|
+
* genuinely new.
|
|
40
|
+
*
|
|
41
|
+
* THE SNAPSHOT REPORTS NO INTERPRETER VERSION, and the absence is deliberate. The server
|
|
42
|
+
* this one replaced ran on an interpreter and published its version under a key named for
|
|
43
|
+
* it; nothing here answers that question, and answering it with this runtime's version
|
|
44
|
+
* under that name would have been a lie the UI printed. So the field is gone rather than
|
|
45
|
+
* present-and-empty. The recorded web corpus was taken from the old server and still
|
|
46
|
+
* carries the key, which is why its replay harness erases it before comparing.
|
|
47
|
+
*
|
|
48
|
+
* P6c's ROUTES ARE THE FIRST THAT PARSE A PATH, which brings three things no earlier
|
|
49
|
+
* endpoint could reach: `pyUnquote` (NOT `decodeURIComponent`, which throws where the
|
|
50
|
+
* reference leaves a stray `%` alone), the `NotFound` → 404 convention, and `flatName`'s
|
|
51
|
+
* traversal refusal — a GET carries no token, so before that check the item route was an
|
|
52
|
+
* arbitrary-file read.
|
|
53
|
+
*/
|
|
54
|
+
import { createHash } from 'node:crypto';
|
|
55
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
56
|
+
import path from 'node:path';
|
|
57
|
+
|
|
58
|
+
import { ROOT, THEMES, discoverNames } from '../checkout.mjs';
|
|
59
|
+
import { diffCollect } from '../diff.mjs';
|
|
60
|
+
import { doctorCollect } from '../doctor.mjs';
|
|
61
|
+
import { excludesSnapshot } from '../excludes.mjs';
|
|
62
|
+
import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, pyResolve } from '../hosts.mjs';
|
|
63
|
+
import {
|
|
64
|
+
footprintOfDir, installState, installTargets, installedDefaults, modeOfDir,
|
|
65
|
+
postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
|
|
66
|
+
} from '../installs.mjs';
|
|
67
|
+
import { frontmatter } from '../hooks.mjs';
|
|
68
|
+
import {
|
|
69
|
+
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
70
|
+
} from '../inventory.mjs';
|
|
71
|
+
import { firstBlockquote } from '../native.mjs';
|
|
72
|
+
import { EMIT_OPTIONS, themeOptions } from '../setup.mjs';
|
|
73
|
+
import { accentFor, statusData } from '../status.mjs';
|
|
74
|
+
import {
|
|
75
|
+
comparePaths, normcase, pyStripSpace, pyUnquote, readText, withDiscardableStderr, within,
|
|
76
|
+
} from '../lib/fs.mjs';
|
|
77
|
+
import { readJsonc } from '../settings.mjs';
|
|
78
|
+
import { apiActivity, apiActivityDetail } from './activity.mjs';
|
|
79
|
+
import { apiMcp, apiRules } from './actions.mjs';
|
|
80
|
+
import { apiGraph } from './graph.mjs';
|
|
81
|
+
|
|
82
|
+
/** `_web_core.NotFound` — a requested catalog section or item that does not exist. */
|
|
83
|
+
export class NotFound extends Error {}
|
|
84
|
+
|
|
85
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
86
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
87
|
+
|
|
88
|
+
/** Two resolved paths, compared as `PurePath.__eq__` does — case-folded on Windows. */
|
|
89
|
+
const samePath = (a, b) => normcase(pyResolve(a)) === normcase(pyResolve(b));
|
|
90
|
+
|
|
91
|
+
/** `dict.get(key, default)` — `??` would also swallow an explicit null the file declared. */
|
|
92
|
+
const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
93
|
+
|
|
94
|
+
/** `hashlib.sha256(text.encode()).hexdigest()[:16]`, and "" for an empty file. */
|
|
95
|
+
export function fingerprint(text) {
|
|
96
|
+
return text ? createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 16) : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")` — LOCAL time, as the reference's
|
|
101
|
+
* naive `fromtimestamp` is. `toISOString` would be UTC and would read as a port bug in
|
|
102
|
+
* every timezone but one.
|
|
103
|
+
*/
|
|
104
|
+
export function stampMinute(ms) {
|
|
105
|
+
const d = new Date(ms);
|
|
106
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
107
|
+
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} `
|
|
108
|
+
+ `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- WebState ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `_web_core.WebState` — the resolved view of the deployed harness a request reads from.
|
|
115
|
+
*
|
|
116
|
+
* A factory returning a plain object rather than a class, because the two cached
|
|
117
|
+
* properties are the only behaviour and a getter pair expresses them without ceremony.
|
|
118
|
+
* THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
|
|
119
|
+
* and the dashboard GETs `/api/overview` on every navigation, so an uncached `doctor`
|
|
120
|
+
* would put a full build on the request path. `refresh()` is what a mutation calls to
|
|
121
|
+
* drop both (P6f's).
|
|
122
|
+
*/
|
|
123
|
+
export function webState(theme = null, target = null) {
|
|
124
|
+
const st = {
|
|
125
|
+
// `Path(target) if target else _opencode_config_dir()` — NOT resolved. `target` is
|
|
126
|
+
// printed by three endpoints, so resolving it here would change what they answer.
|
|
127
|
+
target: target || opencodeConfigDir(),
|
|
128
|
+
root: null,
|
|
129
|
+
theme: null,
|
|
130
|
+
emit: null,
|
|
131
|
+
footprint: null,
|
|
132
|
+
posture: null,
|
|
133
|
+
mode: null,
|
|
134
|
+
_inv: null,
|
|
135
|
+
_doctor: null,
|
|
136
|
+
};
|
|
137
|
+
// The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob
|
|
138
|
+
// PROJECT install keeps its data under <repo>/.claude while the markers land at <repo>/.
|
|
139
|
+
st.root = st.target;
|
|
140
|
+
st.theme = theme || themeOfDir(st.target) || 'neutral';
|
|
141
|
+
st.emit = installedDefaults().emit || 'opencode-global';
|
|
142
|
+
st.footprint = footprintOfDir(st.target); // 'full' when no marker
|
|
143
|
+
st.posture = postureOfDir(st.target) || 'peer';
|
|
144
|
+
st.mode = modeOfDir(st.target) || 'direct';
|
|
145
|
+
|
|
146
|
+
Object.defineProperty(st, 'inventory', {
|
|
147
|
+
get() {
|
|
148
|
+
if (st._inv === null) st._inv = inventoryFor(st);
|
|
149
|
+
return st._inv;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
Object.defineProperty(st, 'doctor', {
|
|
153
|
+
get() {
|
|
154
|
+
if (st._doctor === null) {
|
|
155
|
+
const [, problems] = doctorCollect({ theme: st.theme });
|
|
156
|
+
st.stampDoctor(problems);
|
|
157
|
+
}
|
|
158
|
+
return st._doctor;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
st.stampDoctor = (problems) => {
|
|
162
|
+
st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* `WebState._detect_emit` — the CURRENT install's mode, from its `.geneseed-emit` marker:
|
|
166
|
+
* the ROOT first (where every emit writes it), then the data dir.
|
|
167
|
+
*
|
|
168
|
+
* P6b ported `refresh()` WITHOUT this, because nothing called `refresh()` yet and the
|
|
169
|
+
* constructor reads `installedDefaults()` instead. P6f is the phase with mutations, so
|
|
170
|
+
* `refresh()` finally runs — and the reference's `self.emit = self._detect_emit() or
|
|
171
|
+
* self.emit` line was missing here, which would have left `emit` stale after any write
|
|
172
|
+
* that re-themed or re-pointed the install. Restored with its caller.
|
|
173
|
+
*/
|
|
174
|
+
st.detectEmit = () => {
|
|
175
|
+
for (const d of [st.root, st.target]) {
|
|
176
|
+
try {
|
|
177
|
+
const em = path.join(d, '.geneseed-emit');
|
|
178
|
+
if (isFile(em)) {
|
|
179
|
+
const v = pyStripSpace(readText(em));
|
|
180
|
+
if (v) return v;
|
|
181
|
+
}
|
|
182
|
+
} catch { /* as the Python's `except OSError: pass` */ }
|
|
183
|
+
}
|
|
184
|
+
return existsSync(path.join(st.root, 'CLAUDE.md')) ? 'claude-global' : 'opencode-global';
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* `WebState.select_view` — re-point the console at another detected install's data dir.
|
|
188
|
+
*
|
|
189
|
+
* `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
|
|
190
|
+
* differs only for claude/bob/copilot PROJECT installs, where the data sits under
|
|
191
|
+
* `<repo>/.claude|.bob|.github` while `.geneseed-emit`/`-theme`/`-footprint` land at
|
|
192
|
+
* `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
|
|
193
|
+
* and a Diff or a Restore would then overwrite it in the wrong dialect.
|
|
194
|
+
*/
|
|
195
|
+
st.selectView = (target, root = null) => {
|
|
196
|
+
st.target = target;
|
|
197
|
+
st.root = root || target;
|
|
198
|
+
st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
|
|
199
|
+
st.emit = st.detectEmit();
|
|
200
|
+
st.footprint = footprintOfDir(st.root);
|
|
201
|
+
st.posture = postureOfDir(st.root) || 'peer';
|
|
202
|
+
st.mode = modeOfDir(st.root) || 'direct';
|
|
203
|
+
st._inv = null;
|
|
204
|
+
st._doctor = null;
|
|
205
|
+
};
|
|
206
|
+
st.refresh = () => {
|
|
207
|
+
st._inv = null;
|
|
208
|
+
st._doctor = null;
|
|
209
|
+
st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
|
|
210
|
+
st.emit = st.detectEmit() || st.emit;
|
|
211
|
+
st.footprint = footprintOfDir(st.root);
|
|
212
|
+
st.posture = postureOfDir(st.root) || 'peer';
|
|
213
|
+
st.mode = modeOfDir(st.root) || 'direct';
|
|
214
|
+
};
|
|
215
|
+
return st;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `_web_core._deployed`. */
|
|
219
|
+
export function deployed(state) {
|
|
220
|
+
return existsSync(path.join(state.target, GLOBAL_MANIFEST));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `_web_core._spec_desc` — a deployed spec's one-line purpose.
|
|
225
|
+
*
|
|
226
|
+
* The `> blockquote` convention every rendered skill and agent carries, then the
|
|
227
|
+
* frontmatter `description`, then the first prose paragraph. The fallbacks are for the
|
|
228
|
+
* VENDORED skill folders, which ride in verbatim with no blockquote and would otherwise
|
|
229
|
+
* show a blank Purpose cell.
|
|
230
|
+
*/
|
|
231
|
+
export function specDesc(fm, body) {
|
|
232
|
+
const bq = firstBlockquote(body);
|
|
233
|
+
if (bq) return bq;
|
|
234
|
+
const desc = String(fm.has('description') ? fm.get('description') : '').trim();
|
|
235
|
+
if (desc) return desc.split(/\s+/).filter(Boolean).join(' ');
|
|
236
|
+
for (const para of body.split('\n\n')) {
|
|
237
|
+
const s = para.split(/\s+/).filter(Boolean).join(' ');
|
|
238
|
+
if (s && !(s.startsWith('#') || s.startsWith('---') || s.startsWith('<!--'))) return s;
|
|
239
|
+
}
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `_web_core._spec_entries` — agent/skill specs read straight off a DEPLOYED harness dir.
|
|
245
|
+
*
|
|
246
|
+
* Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
|
|
247
|
+
* folder layout `<root>/<name>/SKILL.md`. `glob("*.md")` is case-INSENSITIVE on Windows,
|
|
248
|
+
* as pathlib's is — `normcase` rather than a bare `endsWith`. The frontmatter is stripped
|
|
249
|
+
* because it is host plumbing rather than prose, which is what makes a deployed entry the
|
|
250
|
+
* same shape as a source-rendered one and every consumer indifferent to the origin.
|
|
251
|
+
*/
|
|
252
|
+
export function specEntries(root, nested) {
|
|
253
|
+
const out = [];
|
|
254
|
+
if (!isDir(root)) return out;
|
|
255
|
+
let names;
|
|
256
|
+
try { names = readdirSync(root); } catch { return out; }
|
|
257
|
+
const files = nested
|
|
258
|
+
? names.sort(comparePaths).filter((n) => isDir(path.join(root, n)))
|
|
259
|
+
.map((n) => path.join(root, n, 'SKILL.md'))
|
|
260
|
+
: names.filter((n) => normcase(n).endsWith('.md') && !n.startsWith('_'))
|
|
261
|
+
.sort(comparePaths).map((n) => path.join(root, n));
|
|
262
|
+
for (const p of files) {
|
|
263
|
+
if (!isFile(p)) continue;
|
|
264
|
+
const [fm, body] = frontmatter(readMaybe(p) ?? '');
|
|
265
|
+
const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
|
|
266
|
+
out.push({ name, desc: specDesc(fm, body), body, source: pyResolve(p) });
|
|
267
|
+
}
|
|
268
|
+
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* `_web_core._deployed_inventory` — the agents and skills actually installed at
|
|
274
|
+
* `state.target`, not a fresh render of `src/`.
|
|
275
|
+
*
|
|
276
|
+
* Laws still come from the render: once deployed they live inside AGENT.md rather than as
|
|
277
|
+
* separate files, so the deployed arm replaces two of the three and not all three. A
|
|
278
|
+
* deployed spec file carries neither a category nor a lifecycle status, so each is tagged
|
|
279
|
+
* BY NAME from the same two sources the source render uses — and an entity the registry
|
|
280
|
+
* does not know reads "personal" for both, which is the honest answer rather than a
|
|
281
|
+
* borrowed one: filing it under the `build` fallback would claim a Geneseed taxonomy slot
|
|
282
|
+
* it was never given.
|
|
283
|
+
*/
|
|
284
|
+
export function deployedInventory(state) {
|
|
285
|
+
const render = tuiInventory(state.theme);
|
|
286
|
+
const registry = loadRegistry();
|
|
287
|
+
const skills = specEntries(path.join(state.target, 'skills'), true);
|
|
288
|
+
for (const e of skills) {
|
|
289
|
+
e.status = entityStatus(registry, `skills/${e.name}`);
|
|
290
|
+
e.klass = e.status === 'personal' ? 'personal'
|
|
291
|
+
: (Object.hasOwn(SKILL_CLASS, e.name) ? SKILL_CLASS[e.name] : 'build');
|
|
292
|
+
}
|
|
293
|
+
const agents = specEntries(path.join(state.target, 'agents'), false);
|
|
294
|
+
for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
|
|
295
|
+
return { agents, skills, laws: render.laws, theme: state.theme };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `WebState.inventory` — the deployed record when there is one, else the source render. */
|
|
299
|
+
export function inventoryFor(state) {
|
|
300
|
+
return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- the catalog stores overview counts ---------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** `_web_catalog._memory_dir` — always `<target>/memory`, never the CWD-scanning resolver. */
|
|
306
|
+
const memoryDir = (state) => path.join(state.target, 'memory');
|
|
307
|
+
const notebookDir = (state) => path.join(state.target, 'notebook');
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `sorted(pathlib.Path.glob("*.md"))` — matched, case-insensitive on Windows.
|
|
311
|
+
*
|
|
312
|
+
* `comparePaths` AND NOT A BARE `.sort()`, and this was a LIVE BUG until P6f's memory cells
|
|
313
|
+
* put an uppercase filename in the directory. `sorted()` over `Path` objects compares
|
|
314
|
+
* `_str_normcase`, so on Windows `MEMORY.md` sorts under `m` and lands after `a-fact.md`;
|
|
315
|
+
* JS's default comparator is UTF-16 code units, so `M` (0x4D) sorts before `a` (0x61) and
|
|
316
|
+
* the whole catalog came back in a different order. Every seeded memory and notebook file in
|
|
317
|
+
* P6b and P6c was lower-case, which is why nothing said so for three phases. The `normcase`
|
|
318
|
+
* in the filter beside it was already here for exactly this reason — the sort was the half
|
|
319
|
+
* that got missed.
|
|
320
|
+
*/
|
|
321
|
+
function globMd(dir) {
|
|
322
|
+
if (!isDir(dir)) return [];
|
|
323
|
+
let names;
|
|
324
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
325
|
+
return names.filter((n) => normcase(n).endsWith('.md') && isFile(path.join(dir, n)))
|
|
326
|
+
.sort(comparePaths);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
|
|
330
|
+
|
|
331
|
+
/** `_web_catalog._memory_items`. */
|
|
332
|
+
export function memoryItems(state) {
|
|
333
|
+
const d = memoryDir(state);
|
|
334
|
+
if (!isDir(d)) return [];
|
|
335
|
+
return globMd(d).map((n) => {
|
|
336
|
+
const p = path.join(d, n);
|
|
337
|
+
const [fm] = frontmatter(readMaybe(p) ?? '');
|
|
338
|
+
const stem = stemOf(n);
|
|
339
|
+
return {
|
|
340
|
+
name: stem,
|
|
341
|
+
title: fm.has('name') ? fm.get('name') : stem,
|
|
342
|
+
desc: fm.has('description') ? fm.get('description') : '',
|
|
343
|
+
source: pyResolve(p),
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** `_web_catalog._notebook_items`. */
|
|
349
|
+
export function notebookItems(state) {
|
|
350
|
+
const d = notebookDir(state);
|
|
351
|
+
if (!isDir(d)) return [];
|
|
352
|
+
return globMd(d).map((n) => ({
|
|
353
|
+
name: stemOf(n), title: stemOf(n), desc: '', source: pyResolve(path.join(d, n)),
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `_web_catalog._CONFIG_META` — the two setup manifests, in listing order. */
|
|
358
|
+
const CONFIG_META = {
|
|
359
|
+
'context.json': ['Project context', 'what the agent loads for this project'],
|
|
360
|
+
'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** `_web_catalog._config_items`. */
|
|
364
|
+
export function configItems(state) {
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const fname of ['context.json', 'wiki.jsonc']) {
|
|
367
|
+
const p = path.join(state.target, fname);
|
|
368
|
+
if (!isFile(p)) continue;
|
|
369
|
+
const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
|
|
370
|
+
out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
|
|
371
|
+
source: pyResolve(p) });
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const WIKI_FILE_CAP = 5000;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* `pyResolve` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
|
|
380
|
+
*
|
|
381
|
+
* The three wiki sites below are the only request-path callers of `expanduser`, and it now
|
|
382
|
+
* REFUSES a `~user` form by printing and throwing (`js/hosts.mjs`'s docblock). None of them
|
|
383
|
+
* may let that escape: `js/web/server.mjs` wraps the whole GET in a blanket `catch` → a JSON
|
|
384
|
+
* 500, so an unguarded refusal would turn ONE bad line in a file the user hand-edits into a
|
|
385
|
+
* dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hooks.mjs`
|
|
386
|
+
* — contain the refusal per entry and take the site's OWN degrade — and each of the three
|
|
387
|
+
* already has one: `[]`, `continue`, `continue`-into-404. The reference reaches those same
|
|
388
|
+
* three degrades for a `~user` path, from the other direction: `ntpath.expanduser` expands
|
|
389
|
+
* `~nosuchuser` to `C:\Users\nosuchuser\…`, which does not exist, so `is_file()`/`is_dir()`
|
|
390
|
+
* answer False. `_wiki_path` in `_web_catalog.py` is the guard that makes POSIX agree, where
|
|
391
|
+
* the same call raises RuntimeError instead.
|
|
392
|
+
*
|
|
393
|
+
* `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE and this
|
|
394
|
+
* caller catches — the reference prints nothing here, and `<server stderr>` is compared as
|
|
395
|
+
* bytes by `tests/web_golden.py`, so replaying it would be a divergence on every cell.
|
|
396
|
+
*
|
|
397
|
+
* DELIBERATE, ADJUDICATED RESIDUE: if `C:\Users\<other-user>\…` really exists, the reference
|
|
398
|
+
* on Windows READS ANOTHER ACCOUNT'S FILE where this refuses. Refusing is the settled product
|
|
399
|
+
* decision — the reference is the side that is wrong — and the reference is being deleted.
|
|
400
|
+
*/
|
|
401
|
+
function wikiPath(p) {
|
|
402
|
+
try {
|
|
403
|
+
return withDiscardableStderr(() => pyResolve(p));
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* `_web_catalog._wiki_manifest` — `$GENESEED_WIKI` first, else `wiki.jsonc` beside the
|
|
411
|
+
* deployed bundle, read with the harness's generic JSONC loader.
|
|
412
|
+
*
|
|
413
|
+
* `pyResolve` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
|
|
414
|
+
* consumed by `isFile` and `mcpLoad` and never reaches a response body, and both of those
|
|
415
|
+
* follow symlinks and resolve a relative path against the same cwd anyway.
|
|
416
|
+
*/
|
|
417
|
+
function wikiManifest(state) {
|
|
418
|
+
const cand = process.env.GENESEED_WIKI;
|
|
419
|
+
const p = cand ? wikiPath(cand) : path.join(state.target, 'wiki.jsonc');
|
|
420
|
+
if (!p || !isFile(p)) return [];
|
|
421
|
+
const cfg = mcpLoad(p);
|
|
422
|
+
const wikis = cfg.wikis;
|
|
423
|
+
return Array.isArray(wikis) ? wikis : [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* `harness._mcp_load(path)` with no host — the COMMENT-TOLERANT dict loader, `{}` for a
|
|
428
|
+
* file that is missing, unreadable, unparseable, or not an object.
|
|
429
|
+
*
|
|
430
|
+
* Comment-tolerant is the whole point and the first draft of this got it wrong. The two
|
|
431
|
+
* files it reads are `wiki.jsonc` and `context.json`, and the first is `.jsonc` because it
|
|
432
|
+
* is HAND-MAINTAINED: a `//` in it parses on the reference and would have made
|
|
433
|
+
* `JSON.parse` return null here, so the wiki section would have silently listed nothing
|
|
434
|
+
* and the config item's `manifest` would have come back empty. The seeded manifests in
|
|
435
|
+
* `tests/web_golden.py` are plain JSON, so no cell had one — `catalog/…-tolerates-comments`
|
|
436
|
+
* is the cell that does now.
|
|
437
|
+
*/
|
|
438
|
+
function mcpLoad(p) {
|
|
439
|
+
if (!isFile(p)) return {};
|
|
440
|
+
let text;
|
|
441
|
+
try { text = readText(p); } catch { return {}; }
|
|
442
|
+
const [data] = readJsonc(text);
|
|
443
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Every `.md` under `dir`, recursively, POSIX-relative to it and sorted — `rglob("*.md")`. */
|
|
447
|
+
function rglobMd(dir, base = dir, out = []) {
|
|
448
|
+
let entries;
|
|
449
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
450
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
451
|
+
const full = path.join(dir, e.name);
|
|
452
|
+
if (e.isDirectory()) rglobMd(full, base, out);
|
|
453
|
+
else if (normcase(e.name).endsWith('.md')) out.push(full);
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `_web_catalog._wiki_items`. */
|
|
459
|
+
export function wikiItems(state) {
|
|
460
|
+
const items = [];
|
|
461
|
+
const seen = new Set();
|
|
462
|
+
for (const w of wikiManifest(state)) {
|
|
463
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
464
|
+
const wname = String(dget(w, 'name', null) || 'wiki');
|
|
465
|
+
// PER ENTRY, and the loop continues — one unusable `path` in the manifest must not blank
|
|
466
|
+
// every OTHER vault in it. `pyResolve` where the reference only `expanduser()`s is again
|
|
467
|
+
// unobservable: `root` is only ever joined against or used as the base of a `relative()`
|
|
468
|
+
// whose other operand was built FROM it, and `source` is `.resolve()`d on both sides.
|
|
469
|
+
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
470
|
+
if (!root || !isDir(root)) continue;
|
|
471
|
+
const entries = (dget(w, 'entries', null) || [])
|
|
472
|
+
.filter((e) => e && typeof e === 'object' && !Array.isArray(e));
|
|
473
|
+
const excludes = entries.filter((e) => dget(e, 'load', null) === 'exclude')
|
|
474
|
+
.map((e) => String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
|
|
475
|
+
const excluded = (rel) => excludes.some((x) => x && (rel === x || rel.startsWith(`${x}/`)));
|
|
476
|
+
|
|
477
|
+
for (const e of entries) {
|
|
478
|
+
if (dget(e, 'load', null) === 'exclude') continue;
|
|
479
|
+
const rel = String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
480
|
+
const desc = String(dget(e, 'description', null) || '');
|
|
481
|
+
const fp = path.join(root, rel);
|
|
482
|
+
let mds;
|
|
483
|
+
if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
|
|
484
|
+
// `sorted(fp.rglob("*.md"))[:cap]` — `comparePaths`, for the reason `globMd` carries.
|
|
485
|
+
else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
|
|
486
|
+
else continue;
|
|
487
|
+
for (const md of mds) {
|
|
488
|
+
const r = path.relative(root, md).split(path.sep).join('/');
|
|
489
|
+
const key = `${wname}:${r}`;
|
|
490
|
+
if (seen.has(key) || excluded(r)) continue;
|
|
491
|
+
seen.add(key);
|
|
492
|
+
items.push({ name: key, title: path.basename(md, '.md'),
|
|
493
|
+
desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
|
|
494
|
+
source: pyResolve(md) });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return items;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `_web_catalog.api_wiki_item` — one page by `<wiki>:<relpath>`, never outside the vault. */
|
|
502
|
+
export function apiWikiItem(state, name) {
|
|
503
|
+
const at = name.indexOf(':');
|
|
504
|
+
const wname = at < 0 ? name : name.slice(0, at);
|
|
505
|
+
const rel = (at < 0 ? '' : name.slice(at + 1))
|
|
506
|
+
.replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
507
|
+
for (const w of wikiManifest(state)) {
|
|
508
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
509
|
+
if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
|
|
510
|
+
// The reference is `.expanduser().resolve()` here, so this site matches on resolve and
|
|
511
|
+
// differs only on the refusal — which falls through to the `NotFound` below, exactly
|
|
512
|
+
// where the reference lands when the expanded root turns out not to exist.
|
|
513
|
+
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
514
|
+
if (!root) continue;
|
|
515
|
+
const p = pyResolve(path.join(root, rel));
|
|
516
|
+
if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
|
|
517
|
+
const body = readMaybe(p) ?? '';
|
|
518
|
+
return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
|
|
519
|
+
body, links: resolveLinks(state, body), source: p };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
throw new NotFound(name);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* `harness._within` — re-exported from `js/lib/fs.mjs`, where P6i moved it.
|
|
527
|
+
*
|
|
528
|
+
* It lived here from P6b (the restore's containment check) until `_install_move_list` needed
|
|
529
|
+
* the same predicate from `js/uninstall.mjs`, which must not import the web tree. The
|
|
530
|
+
* re-export keeps `js/web/actions.mjs`'s import path unchanged.
|
|
531
|
+
*/
|
|
532
|
+
export { within };
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* `_web_catalog._flat_name` — catalog names are flat basenames.
|
|
536
|
+
*
|
|
537
|
+
* A separator, a `..` or a drive colon in the URL segment is someone steering the join
|
|
538
|
+
* outside the catalog dir. A GET carries no token, so before this check the endpoint was
|
|
539
|
+
* an arbitrary-file read; it raises rather than resolving.
|
|
540
|
+
*/
|
|
541
|
+
function flatName(name) {
|
|
542
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')
|
|
543
|
+
|| name.includes(':')) {
|
|
544
|
+
throw new NotFound(name);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** `_web_core.WIKILINK_RE`. Exported since P6e — `js/web/graph.mjs` is its second reader. */
|
|
549
|
+
export const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* `_web_catalog._resolve_links` — `[[name]]` matched against known agent and skill names.
|
|
553
|
+
*
|
|
554
|
+
* AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
|
|
555
|
+
* That is not a preference, it is what the reference's two loops do in that order, and
|
|
556
|
+
* `item/an-agent-carries-its-body-and-its-resolved-links` is the cell that would catch the
|
|
557
|
+
* other order.
|
|
558
|
+
*/
|
|
559
|
+
export function resolveLinks(state, body) {
|
|
560
|
+
const inv = state.inventory;
|
|
561
|
+
const known = new Map();
|
|
562
|
+
for (const e of inv.agents) known.set(e.name, 'agent');
|
|
563
|
+
for (const e of inv.skills) known.set(e.name, 'skill');
|
|
564
|
+
const links = [];
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
for (const m of body.matchAll(WIKILINK_RE)) {
|
|
567
|
+
// `pyStripSpace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
|
|
568
|
+
// gave this regex a second reader, so the two call sites answer with one rule.
|
|
569
|
+
const label = pyStripSpace(m[1]);
|
|
570
|
+
if (known.has(label) && !seen.has(label)) {
|
|
571
|
+
seen.add(label);
|
|
572
|
+
links.push({ label, type: known.get(label), name: label });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return links;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* `_web_catalog._config_manifest` — a setup file parsed into the shape the detail pane
|
|
580
|
+
* renders as cards, or `null` so the caller falls back to the raw body.
|
|
581
|
+
*/
|
|
582
|
+
function configManifest(name, p) {
|
|
583
|
+
const cfg = mcpLoad(p);
|
|
584
|
+
if (name === 'wiki.jsonc') {
|
|
585
|
+
const wikis = cfg.wikis;
|
|
586
|
+
return { kind: 'wiki', wikis: Array.isArray(wikis) ? wikis : [] };
|
|
587
|
+
}
|
|
588
|
+
if (name === 'context.json') {
|
|
589
|
+
const ctx = cfg.context;
|
|
590
|
+
return { kind: 'context', context: Array.isArray(ctx) ? ctx : [] };
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** `_web_core.SECTIONS` — a closed list; anything else is a 404. */
|
|
596
|
+
export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
|
|
597
|
+
|
|
598
|
+
/** `_web_catalog.api_catalog`. */
|
|
599
|
+
export function apiCatalog(state, section) {
|
|
600
|
+
if (!SECTIONS.includes(section)) throw new NotFound(section);
|
|
601
|
+
const inv = state.inventory;
|
|
602
|
+
let items;
|
|
603
|
+
if (section === 'agents') {
|
|
604
|
+
items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
605
|
+
source: e.source ?? null, status: e.status ?? 'unknown' }));
|
|
606
|
+
} else if (section === 'skills') {
|
|
607
|
+
items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
608
|
+
source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
|
|
609
|
+
} else if (section === 'laws') {
|
|
610
|
+
items = inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`,
|
|
611
|
+
desc: '', klass: e.klass ?? 'craft' }));
|
|
612
|
+
} else if (section === 'memory') {
|
|
613
|
+
items = memoryItems(state);
|
|
614
|
+
} else if (section === 'notebook') {
|
|
615
|
+
items = notebookItems(state);
|
|
616
|
+
} else if (section === 'wiki') {
|
|
617
|
+
items = wikiItems(state);
|
|
618
|
+
} else {
|
|
619
|
+
items = configItems(state);
|
|
620
|
+
}
|
|
621
|
+
return { section, items };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** `_web_catalog.api_item`. */
|
|
625
|
+
export function apiItem(state, type, name) {
|
|
626
|
+
const inv = state.inventory;
|
|
627
|
+
if (type === 'agent' || type === 'skill') {
|
|
628
|
+
const e = (type === 'agent' ? inv.agents : inv.skills).find((x) => x.name === name);
|
|
629
|
+
if (!e) throw new NotFound(name);
|
|
630
|
+
const out = { type, name, title: name, desc: e.desc, body: e.body,
|
|
631
|
+
links: resolveLinks(state, e.body), source: e.source ?? null };
|
|
632
|
+
if (type === 'skill') out.klass = e.klass ?? 'build';
|
|
633
|
+
out.status = e.status ?? 'unknown';
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
if (type === 'law') {
|
|
637
|
+
const e = inv.laws.find((x) => x.num === name);
|
|
638
|
+
if (!e) throw new NotFound(name);
|
|
639
|
+
return { type, name, title: `Rule ${e.num} — ${e.title}`, desc: '', body: e.body,
|
|
640
|
+
links: [], klass: e.klass ?? 'craft' };
|
|
641
|
+
}
|
|
642
|
+
if (type === 'memory' || type === 'notebook') {
|
|
643
|
+
flatName(name);
|
|
644
|
+
const d = type === 'notebook' ? notebookDir(state) : memoryDir(state);
|
|
645
|
+
const p = path.join(d, `${name}.md`);
|
|
646
|
+
if (!isFile(p)) throw new NotFound(name);
|
|
647
|
+
const body = readMaybe(p) ?? '';
|
|
648
|
+
return { type, name, title: name, desc: '', body,
|
|
649
|
+
links: resolveLinks(state, body), source: pyResolve(p) };
|
|
650
|
+
}
|
|
651
|
+
if (type === 'wiki') return apiWikiItem(state, name);
|
|
652
|
+
if (type === 'config') {
|
|
653
|
+
flatName(name);
|
|
654
|
+
const p = path.join(state.target, name);
|
|
655
|
+
if (!isFile(p)) throw new NotFound(name);
|
|
656
|
+
const raw = readMaybe(p) ?? '';
|
|
657
|
+
const [title, desc] = CONFIG_META[name] ?? [name, ''];
|
|
658
|
+
return { type, name, title, desc, manifest: configManifest(name, p),
|
|
659
|
+
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: pyResolve(p) };
|
|
660
|
+
}
|
|
661
|
+
throw new NotFound(type);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---- the eight endpoints -------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
/** `_web_actions._theme_choices`. Exported since P6g — `_build_override` is its third reader. */
|
|
667
|
+
export function themeChoices() {
|
|
668
|
+
return themeOptions().map(([name, blurb]) => {
|
|
669
|
+
const data = readJsonMaybe(path.join(THEMES, `${name}.json`));
|
|
670
|
+
const d = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
671
|
+
return { name, blurb, accent: dget(d, 'ACCENT', 'cyan'),
|
|
672
|
+
tagline: dget(d, 'TAGLINE', ''), sigil: dget(d, 'LOADED_SIGIL', '') };
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** `_web_actions._emit_choices`. */
|
|
677
|
+
export const emitChoices = () => EMIT_OPTIONS.map(([name, desc]) => ({ name, desc }));
|
|
678
|
+
|
|
679
|
+
/** `_web_actions.api_themes`. */
|
|
680
|
+
export function apiThemes(state) {
|
|
681
|
+
return { themes: themeChoices(), emits: emitChoices(),
|
|
682
|
+
current: { theme: state.theme, emit: state.emit } };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** `_web_actions.api_setup`. */
|
|
686
|
+
export function apiSetup(state) {
|
|
687
|
+
return {
|
|
688
|
+
...statusData(),
|
|
689
|
+
root: ROOT,
|
|
690
|
+
target: state.target,
|
|
691
|
+
deployed: deployed(state),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** `_web_actions.api_doctor` — the same engine as the `doctor` verb, grouped per check. */
|
|
696
|
+
export function apiDoctor(state) {
|
|
697
|
+
const groups = [];
|
|
698
|
+
const [themes, problems] = doctorCollect({ theme: state.theme, groups });
|
|
699
|
+
state.stampDoctor(problems);
|
|
700
|
+
return { themes, ok: !problems.length, problems, groups,
|
|
701
|
+
checked_at: state.doctor.checked_at };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** `_web_actions.api_diff`. */
|
|
705
|
+
export function apiDiff(state) {
|
|
706
|
+
const { target, theme, files } = diffCollect({
|
|
707
|
+
target: state.target, theme: state.theme, emit: state.emit });
|
|
708
|
+
return { deployed: files !== null, target, theme, files: files || [] };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** `_web_actions.api_excludes` — the union across every global install, verbatim. */
|
|
712
|
+
export const apiExcludes = () => excludesSnapshot();
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* `_web_actions._view_cfg` — the data dir an install's inventory/memory/diff is read from.
|
|
716
|
+
* Host-driven, so a new nested-marker host cannot silently read the bare root.
|
|
717
|
+
*/
|
|
718
|
+
export function viewCfg(host, scope, root) {
|
|
719
|
+
if (scope === 'project' && ['claude', 'bob', 'copilot'].includes(host)) {
|
|
720
|
+
return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
|
|
721
|
+
}
|
|
722
|
+
return root;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** `_web_actions.api_installs`. */
|
|
726
|
+
export function apiInstalls(state) {
|
|
727
|
+
const out = [];
|
|
728
|
+
for (const [host, scope, root] of installTargets()) {
|
|
729
|
+
out.push({
|
|
730
|
+
id: `${host}:${scope}`, host, scope, path: root,
|
|
731
|
+
state: installState(root, host, scope),
|
|
732
|
+
theme: themeOfDir(root),
|
|
733
|
+
footprint: footprintOfDir(root),
|
|
734
|
+
posture: postureOfDir(root),
|
|
735
|
+
mode: modeOfDir(root),
|
|
736
|
+
selected: samePath(viewCfg(host, scope, root), state.target),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
return { installs: out, postures: discoverNames('postures', 'peer'),
|
|
740
|
+
modes: discoverNames('modes', 'direct') };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** `_web_catalog._profile_path` — `build.PROFILE_FILE` beside the deployed AGENT.md. */
|
|
744
|
+
export const PROFILE_FILE = 'PROFILE.md';
|
|
745
|
+
export const profilePath = (state) => path.join(state.target, PROFILE_FILE);
|
|
746
|
+
|
|
747
|
+
/** `_web_catalog.api_profile`. */
|
|
748
|
+
export function apiProfile(state) {
|
|
749
|
+
const p = profilePath(state);
|
|
750
|
+
if (!isFile(p)) return { exists: false, path: p, text: '', fingerprint: '' };
|
|
751
|
+
const text = readMaybe(p) ?? '';
|
|
752
|
+
return { exists: true, path: p, text, fingerprint: fingerprint(text) };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** `_web_overview.api_overview` — the dashboard aggregate. */
|
|
756
|
+
export function apiOverview(state) {
|
|
757
|
+
const inv = state.inventory;
|
|
758
|
+
let diff = null;
|
|
759
|
+
if (deployed(state)) {
|
|
760
|
+
const { files } = diffCollect({
|
|
761
|
+
target: state.target, theme: state.theme, emit: state.emit });
|
|
762
|
+
if (files !== null) {
|
|
763
|
+
diff = {
|
|
764
|
+
edited: files.filter((f) => f.status === 'edited').length,
|
|
765
|
+
added: files.filter((f) => f.status === 'added').length,
|
|
766
|
+
missing: files.filter((f) => f.status === 'missing').length,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
let buildTime = null;
|
|
771
|
+
const agentMd = path.join(state.target, 'AGENT.md');
|
|
772
|
+
if (isFile(agentMd)) buildTime = stampMinute(statSync(agentMd).mtimeMs);
|
|
773
|
+
// Which detected install the current view points at, so the dashboard's footprint hero
|
|
774
|
+
// can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out as the reference spells
|
|
775
|
+
// it out — the two are separate code in the Python too.
|
|
776
|
+
let install = null;
|
|
777
|
+
for (const [host, scope, root] of installTargets()) {
|
|
778
|
+
const data = (scope === 'project' && ['claude', 'bob', 'copilot'].includes(host))
|
|
779
|
+
? path.join(root, HOSTS.find((h) => h.host === host).projectMarker) : root;
|
|
780
|
+
try {
|
|
781
|
+
if (samePath(data, state.target)) {
|
|
782
|
+
install = { host, scope, path: root, footprint: footprintOfDir(root) };
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
} catch { /* as the Python's `except OSError: pass` */ }
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
theme: state.theme,
|
|
789
|
+
accent: accentFor(state.theme),
|
|
790
|
+
emit: state.emit,
|
|
791
|
+
footprint: install ? install.footprint : state.footprint,
|
|
792
|
+
install,
|
|
793
|
+
target: state.target,
|
|
794
|
+
deployed: deployed(state),
|
|
795
|
+
counts: {
|
|
796
|
+
agents: inv.agents.length,
|
|
797
|
+
skills: inv.skills.length,
|
|
798
|
+
laws: inv.laws.length,
|
|
799
|
+
memory: memoryItems(state).length,
|
|
800
|
+
notebook: notebookItems(state).length,
|
|
801
|
+
wiki: wikiItems(state).length,
|
|
802
|
+
config: configItems(state).length,
|
|
803
|
+
},
|
|
804
|
+
doctor: state.doctor,
|
|
805
|
+
diff,
|
|
806
|
+
build_time: buildTime,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* `_web_server.Handler.STATE_ROUTES`, the ported half.
|
|
812
|
+
*
|
|
813
|
+
* A literal path to `api_X(state)`, exactly as the reference's table is — and the reason
|
|
814
|
+
* it is a table on this side too is `tests/test_web_server.py`'s cross-check, which reads
|
|
815
|
+
* the reference's table out of `rituals/_web_server.py` with `ast` and requires this one
|
|
816
|
+
* plus `NOT_PORTED` to equal it. On the second instance of anything, the gate becomes a
|
|
817
|
+
* table cross-checked against the source of truth.
|
|
818
|
+
*/
|
|
819
|
+
/**
|
|
820
|
+
* `_web_server.do_GET`'s prefix routes, the ported ones — path in, response out.
|
|
821
|
+
*
|
|
822
|
+
* `pyUnquote` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
|
|
823
|
+
* that is not an escape, which the shell would answer as a 500 where the reference answers
|
|
824
|
+
* a 404 naming the literal text.
|
|
825
|
+
*/
|
|
826
|
+
export const PREFIX_ROUTES = [
|
|
827
|
+
['/api/catalog/', (state, p) => apiCatalog(state, p.split('/').pop())],
|
|
828
|
+
// `path.split("/", 4)` — /api/item/<type>/<name>, and the NAME keeps its slashes so a
|
|
829
|
+
// wiki page's relpath survives. A missing name is a 404 here rather than an IndexError
|
|
830
|
+
// and a 500 two frames down.
|
|
831
|
+
['/api/item/', (state, p) => {
|
|
832
|
+
const parts = splitN(p, '/', 4);
|
|
833
|
+
if (parts.length < 5 || !parts[4]) throw new NotFound(p);
|
|
834
|
+
return apiItem(state, parts[3], pyUnquote(parts[4]));
|
|
835
|
+
}],
|
|
836
|
+
// P6e. The reference's own `path[len("/api/activity/"):]`, unquoted — the sid keeps
|
|
837
|
+
// whatever it carries and `api_activity_detail`'s safe-name scheme is what makes the
|
|
838
|
+
// lookup safe, rather than a check out here.
|
|
839
|
+
['/api/activity/', (state, p) => apiActivityDetail(
|
|
840
|
+
state, pyUnquote(p.slice('/api/activity/'.length)))],
|
|
841
|
+
];
|
|
842
|
+
|
|
843
|
+
/** `str.split(sep, maxsplit)` — at most `n` splits, the remainder kept whole. */
|
|
844
|
+
function splitN(s, sep, n) {
|
|
845
|
+
const out = [];
|
|
846
|
+
let rest = s;
|
|
847
|
+
for (let i = 0; i < n; i += 1) {
|
|
848
|
+
const at = rest.indexOf(sep);
|
|
849
|
+
if (at < 0) break;
|
|
850
|
+
out.push(rest.slice(0, at));
|
|
851
|
+
rest = rest.slice(at + sep.length);
|
|
852
|
+
}
|
|
853
|
+
out.push(rest);
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export const STATE_ROUTES = {
|
|
858
|
+
'/api/overview': apiOverview,
|
|
859
|
+
'/api/themes': apiThemes,
|
|
860
|
+
'/api/setup': apiSetup,
|
|
861
|
+
'/api/doctor': apiDoctor,
|
|
862
|
+
'/api/installs': apiInstalls,
|
|
863
|
+
'/api/excludes': apiExcludes,
|
|
864
|
+
'/api/profile': apiProfile,
|
|
865
|
+
'/api/diff': apiDiff,
|
|
866
|
+
'/api/graph': apiGraph,
|
|
867
|
+
'/api/activity': apiActivity,
|
|
868
|
+
// P6f. `/api/rules` had to cross as a PAIR: `api_rules_mutate` splices `parse_rules`' line
|
|
869
|
+
// indices, and the fingerprint every mutation must send back is what this GET answers —
|
|
870
|
+
// so a Node daemon whose POST worked and whose GET 501'd would have no way to obtain one.
|
|
871
|
+
'/api/rules': apiRules,
|
|
872
|
+
'/api/mcp': apiMcp,
|
|
873
|
+
};
|