diff --git a/.github/actions/validate-profile/action.yml b/.github/actions/validate-profile/action.yml new file mode 100644 index 0000000..8f536cc --- /dev/null +++ b/.github/actions/validate-profile/action.yml @@ -0,0 +1,19 @@ +name: Validate Hermes profile +description: Validate a Hermes profile distribution with compile and smoke checks. +inputs: + profile-path: + description: Path to the profile repository. + required: false + default: . +runs: + using: composite + steps: + - name: Install dependencies + shell: bash + run: python -m pip install -r "${{ inputs.profile-path }}/requirements.txt" + - name: Compile scripts + shell: bash + run: python -m py_compile "${{ inputs.profile-path }}"/scripts/*.py + - name: Validate profile + shell: bash + run: python "${{ inputs.profile-path }}/scripts/validate_profile.py" "${{ inputs.profile-path }}" diff --git a/BOUNTIES.md b/BOUNTIES.md new file mode 100644 index 0000000..9e7044c --- /dev/null +++ b/BOUNTIES.md @@ -0,0 +1,35 @@ +# Bounties and payout proof + +This repository uses public pull request threads and Solana transaction links to keep contributor payouts auditable. + +Scope: this page records bounty payouts that were confirmed in the PR threads during the first contributor wave. It is not a promise of future payments and it does not replace the acceptance criteria on each bounty issue. + +## Paid contributor wave + +| Contributor | PRs | Wallet | Payout proof | +| --- | --- | --- | --- | +| @JHON12091986 | [#41](https://github.com/codegraphtheory/hermes-profile-template/pull/41) | `Agb7LtU4b6SNFSGzdNZ2CAykKifBSyTm5PCFYjBmW7sh` | [Solscan tx](https://solscan.io/tx/4YBgxSMtULBVd17e71oc9BfH6zg4QWsJzMf9Q1xr1ujuRnu5JCjAdLu6ZxsijKXuUVBH2E9jLqcy5BD8CXM7ek6q) | +| @leo-guinan | [#40](https://github.com/codegraphtheory/hermes-profile-template/pull/40), [#37](https://github.com/codegraphtheory/hermes-profile-template/pull/37), [#51](https://github.com/codegraphtheory/hermes-profile-template/pull/51) | `FFAdvcr2CUPbaQSypK3c8WfQo3SuyBh5YKrtzZRSvg34` | [Solscan tx](https://solscan.io/tx/3qGbbxGr8pQLoup6wRDNHqDWkhaZFjqDMmQRSP2ZQ1XaVBYjkhCfcrmeSSFJ8uA5a4bfwVyRUsB91RGHyTe9ymE4) | +| @psukhopompos | [#34](https://github.com/codegraphtheory/hermes-profile-template/pull/34), [#29](https://github.com/codegraphtheory/hermes-profile-template/pull/29) | `9CRkSXNeWaUY1F9jd7hMkTGEb1iTAuhsPz8JoDn43Wjk` | [Solscan tx](https://solscan.io/tx/3bVQDcjfkZK1PnYNimRPWg4P8M5oMUi8D2Vfjd1CiW2yb7dDwvWWZ3SbRi1N5DrUtREMUYc1iwLb4g9FGLAyras6) | +| @therealsaitama0 | [#28](https://github.com/codegraphtheory/hermes-profile-template/pull/28), [#38](https://github.com/codegraphtheory/hermes-profile-template/pull/38), [#36](https://github.com/codegraphtheory/hermes-profile-template/pull/36), [#35](https://github.com/codegraphtheory/hermes-profile-template/pull/35), [#33](https://github.com/codegraphtheory/hermes-profile-template/pull/33), [#31](https://github.com/codegraphtheory/hermes-profile-template/pull/31), [#24](https://github.com/codegraphtheory/hermes-profile-template/pull/24) | `C64LcyGryk9CWtXGNMTz1FnbYsVe6vHHSaX9EdjVBMPT` | [Solscan tx](https://solscan.io/tx/ef28eJxaZ5QFVxwKNMetU66oMXoqrg2ranV7vDbzs2bnqe17N5GoWXUPhUPsCFmJSSZmuwXsirUc7jNgZCn6bL1) | +| @xNicky2k | [#47](https://github.com/codegraphtheory/hermes-profile-template/pull/47) | `HvUzii2Dt2WAErMQ1ade6d5qNDthu7nPF8BUZzh8AhgR` | [Solscan tx](https://solscan.io/tx/3KzDAKcMu4XbDgUNtKXLpwPAVwqcKMuBibBiYAxUT4xVVQZWVwJw6ie8PgveqKqgSgPEZFnW7beXX7g8asfKRQn1) | +| @iyeanur6-cyber | [#22](https://github.com/codegraphtheory/hermes-profile-template/pull/22) | `297hZrAVMi3rujS1vxSEbrD43ezfms7snkPHaTGaLeka` | [Solscan tx](https://solscan.io/tx/2njide2JKtwqGwhoiuUfS8wmn3aLqUG7Bpsisrfjo8ZZBVg9ynfYLhjevgfPf4i3BVfTUmw2eQbcreZMRySGWaiA) | + +## Contributor credit + +The contributor wave was consolidated into a maintainer-reviewed integration so the repo could keep code quality high while still preserving first-time contributor credit. + +Merged integration: [#53](https://github.com/codegraphtheory/hermes-profile-template/pull/53) + +Main commits: + +- [`f9e7d88`](https://github.com/codegraphtheory/hermes-profile-template/commit/f9e7d88d1853cbb92d992a6b358e3bcb849c9b51): integrated the first-time contributor toolkit. +- [`a4f445b`](https://github.com/codegraphtheory/hermes-profile-template/commit/a4f445b19b9e4f336ed833ee9b2cf243d310157c): normalized co-author credit. + +## How future bounties should be handled + +1. Link the bounty issue in the PR body. +2. Include a Solana wallet address in the PR body before payout review. +3. Keep one PR scoped to one issue unless a maintainer asks for consolidation. +4. After payment, a maintainer should comment with the wallet and Solscan transaction link. +5. Update this file only after the transaction link is available. diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f4438..96a2ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ All notable changes to this Hermes profile distribution are documented here. +## 0.6.5 + +- Integrated community contributor toolkit: release readiness, scorecard, discovery, wizard, demo, catalog, examples, and reusable validation action. +- Credited first-time contributor PR authors through co-author trailers while consolidating overlapping implementations into one verified maintainer integration. + +## 0.6.4 + +- Fixed live Hermes profile evaluation so generated agents run from the generated profile directory instead of inheriting the template author's repo context. +- Added isolated live-profile installation, configurable provider/model/timeout/max-turn controls, and cleanup for noisy Hermes CLI warning output. +- Stopped generated customer profiles from bundling template authoring skills, keeping generated agents focused on their domain workflow. +- Reworked the local web demo into a finite-state interface with discrete prompt, progress, and fullscreen results panels plus smooth transitions and back/start-over navigation. + +## 0.6.3 + +- Added real Hermes LLM calls to the local web demo pipeline: one call expands the sentence into a mature profile prompt, another reviews generated profile quality. +- Split backend work into explicit status stages so the UI receives snappier progress updates instead of jumping from start to finish. +- Added inline generated-file preview endpoints and clickable file rows in the web UI, so users can inspect files without downloading the zip. +- Added `--profile-prompt-file` support to `scripts/generate_from_sentence.py` so Hermes-refined prompts can drive deterministic repo generation. + +## 0.6.2 + +- Fixed local web demo layout collisions by separating the prompt pane, status rail, and detail workbench columns. +- Added an embedded playable demo iframe directly in the result window. +- Added a clearer generated-output summary with quality checks, important files, install command, and artifact links. +- Exposed generated file metadata and validation-quality signals from the local backend result payload. + +## 0.6.1 + +- Redesigned the local web demo into a fixed fullscreen windowpane experience. +- Replaced the marketing-style landing page with a single sentence text box as the initial state. +- Added progressive workbench panels that unveil prompt, params, repo, demo, diagram, validation, and package details as generation runs. +- Added richer job status metadata for the frontend while keeping the backend local and standard-library based. + +## 0.6.0 + +- Added `scripts/generate_from_sentence.py`, a deterministic local backend entrypoint that turns one sentence into an installable Hermes profile repo. +- Added generated demo artifacts: `demo/index.html`, `docs/playable-demo.md`, `docs/output-diagram.svg`, and `docs/validation-report.md`. +- Added safe zip packaging for generated profile downloads. +- Added `web-demo/server.py` and a static local webpage that submits generation jobs and displays download, demo, diagram, prompt, and validation links. +- Added `make sentence-smoke` and `make web-demo` shortcuts. +- Documented the local web demo and the one-sentence generation path. + +## 0.5.0 + +- Added a bundled `prompt-engineering` skill that expands a simple profile idea into a mature Hermes profile prompt and generation brief. +- Added `templates/prompts/prompt-to-profile.md` as a reusable prompt expansion template. +- Updated `profile-architect` and `profile-craft` workflows so short user ideas are expanded before params generation. +- Added `profile_prompt` support to generated params and `docs/profile-prompt.md` output so generated repos preserve the mature prompt that shaped them. +- Updated generated profile READMEs to explain the preserved design prompt and regeneration workflow. +- Updated support-file copying so generated repos include the template's authoring skills without copying unrelated runtime skills. + ## 0.4.0 - Rebuilt the README around the literal prompt-to-installable-profile-repo workflow. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d01634..a0f0d44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,12 @@ Thanks for improving this Hermes profile distribution. +## Etiquette + +1. In order to accomodate the maximum number of first-time contributors, please limit yourself to ONE `help-wanted` or `bounty` issue at a time. +2. Agent PRs are welcomed and accepted as long as they are high quality. +3. Please fork and star the repo prior to raising a pull request. + ## Development loop 1. Inspect the current repository state. diff --git a/Makefile b/Makefile index 0458e30..2493149 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps validate compile generate-smoke smoke release-check clean +.PHONY: deps validate test compile generate-smoke sentence-smoke smoke web-demo release-check scorecard discovery-check demo-smoke clean PYTHON ?= python3 BASE ?= origin/main @@ -9,6 +9,10 @@ deps: validate: $(PYTHON) scripts/validate_profile.py . + $(PYTHON) scripts/discovery_optimizer.py . + +test: + $(PYTHON) -m unittest discover -s tests compile: PYTHONDONTWRITEBYTECODE=1 $(PYTHON) -m py_compile scripts/*.py @@ -18,11 +22,28 @@ generate-smoke: $(PYTHON) scripts/generate_profile.py --params templates/profile.params.yaml --output $(GEN_ROOT)/generated $(PYTHON) $(GEN_ROOT)/generated/scripts/validate_profile.py $(GEN_ROOT)/generated +sentence-smoke: + rm -rf $(GEN_ROOT) + $(PYTHON) scripts/generate_from_sentence.py --sentence "a database migration reviewer" --output $(GEN_ROOT)/sentence-generated --force + $(PYTHON) $(GEN_ROOT)/sentence-generated/scripts/validate_profile.py $(GEN_ROOT)/sentence-generated + +web-demo: + $(PYTHON) web-demo/server.py + smoke: scripts/smoke_install.sh release-check: - $(PYTHON) scripts/check_release_version.py --base $(BASE) + $(PYTHON) scripts/release_readiness.py --base $(BASE) + +scorecard: + $(PYTHON) scripts/profile_scorecard.py . --threshold 80 + +discovery-check: + $(PYTHON) scripts/discovery_optimizer.py . + +demo-smoke: + $(PYTHON) scripts/demo_fixture.py . --demo generate clean: rm -rf $(GEN_ROOT) .pytest_cache .mypy_cache .ruff_cache htmlcov dist build diff --git a/README.md b/README.md index 930fec1..7e19941 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,25 @@ Hermes Agent core provides profile isolation and distribution install/update com For the boundary between Hermes core and this template, see [`docs/profile-distribution-contract.md`](docs/profile-distribution-contract.md). +## Built with this template + +These public Hermes profiles have verified `distribution.yaml` template lineage pointing back to this repository. + +| Profile | What it is | Install | Star | +| --- | --- | --- | --- | +| [context-forge-rag](https://github.com/codegraphtheory/context-forge-rag) | Production RAG architecture, evaluation, observability, Pinecone workflows, and implementation-ready delivery specs. | `hermes profile install github.com/codegraphtheory/context-forge-rag --alias` | [![Star context-forge-rag](https://img.shields.io/github/stars/codegraphtheory/context-forge-rag?style=social)](https://github.com/codegraphtheory/context-forge-rag/stargazers) | +| [chainforge](https://github.com/codegraphtheory/chainforge) | Security-first blockchain architect for smart contracts, Solana, Solidity, DeFi, audits, governance, and tokenomics. | `hermes profile install github.com/codegraphtheory/chainforge --alias` | [![Star chainforge](https://img.shields.io/github/stars/codegraphtheory/chainforge?style=social)](https://github.com/codegraphtheory/chainforge/stargazers) | +| heavy-coder | Private multi-agent coding-team profile with issue-to-PR automation, critique, synthesis, and fail-closed merge policy. Coming soon! | Coming soon! | Coming soon! | + +If you publish a profile built from this template, keep `template_source` in `distribution.yaml` so it can be added here. + +## Bounties and payout proof + +We pay contributors transparently when bounty work is accepted. See [BOUNTIES.md](BOUNTIES.md) for recorded Solana payout proof and contributor PR links. + ## The literal workflow -One prompt should become a real repository directory that can be installed with `hermes profile install`. +One simple sentence should become a mature agent prompt, then a real repository directory that can be installed with `hermes profile install`. ```bash export DEMO_ROOT="/tmp/hermes-profile-builder-demo" @@ -27,7 +43,15 @@ hermes profile install github.com/codegraphtheory/hermes-profile-template \ profile-architect chat ``` -Paste a product-style prompt: +Paste a one-sentence idea or a product-style prompt. The installed profile will first expand the idea into a mature profile prompt, preserve that prompt in the generated repo, then generate and validate the profile. + +Minimal prompt: + +```text +Turn "a database migration reviewer" into a fantastic installable Hermes profile repo under /tmp/hermes-profile-builder-demo/database-migration-reviewer. Expand the idea into a mature agent prompt first, then generate the repo and run validation. +``` + +More detailed prompt: ```text Create a Hermes profile distribution for a database migration reviewer. @@ -79,18 +103,84 @@ scripts/generate_profile.py Deterministic generator scripts/smoke_install.sh Local install smoke test .github/workflows/ Validation and release guard CI skills/ Bundled profile-specific skills -templates/ Params and catalog templates +templates/ Params, prompt-engineering, and catalog templates +docs/profile-prompt.md Mature prompt preserved from the user's simple idea ``` The generated repo is not just a text draft. It should validate locally and install through Hermes. +## Local web demo + +Run a local webpage that wraps `scripts/generate_from_sentence.py`: + +```bash +make web-demo +``` + +Then open: + +```text +http://127.0.0.1:8765 +``` + +The page starts as a single sentence text box. During generation it transforms into a fullscreen workbench that progressively reveals the Hermes prompt-engineering pass, params, repository files, demo, diagram, validation, and Hermes quality review. The result view includes quality checks, a generated-file summary, clickable inline file previews, and an embedded playable demo so viewers can inspect the profile without downloading the zip. + +The page lets someone type a sentence, then the backend creates: + +- downloadable profile repo zip +- `docs/profile-prompt.md` +- `docs/output-diagram.svg` +- `demo/index.html` +- `docs/playable-demo.md` +- `docs/validation-report.md` + +The local API is intentionally simple and demo-focused. It runs jobs under `/tmp/hermes-profile-web-demo-jobs` and uses only local files. Do not expose it directly to the public internet without adding authentication, quotas, sandboxing, and abuse controls. + +## Community contributor toolkit + +This release consolidates first-time contributor PR work into a maintainer-reviewed toolkit while preserving author credit. + +```bash +make scorecard +make discovery-check +make demo-smoke +make release-check +``` + +Included tools: + +- `scripts/profile_wizard.py`: guided `profile.params.yaml` creation for first-time authors. +- `scripts/profile_scorecard.py`: JSON, Markdown, and terminal quality scoring for profile repos. +- `scripts/discovery_optimizer.py`: GitHub metadata and README discovery checks. +- `scripts/render_catalog_entry.py`: catalog-ready Markdown, YAML, and PR-body snippets. +- `scripts/demo_fixture.py`: safe temporary demo workspaces with runtime-state checks. +- `scripts/release_readiness.py`: changelog, version, validation, compile, and secret hygiene release report. +- `.github/actions/validate-profile/action.yml`: reusable validation action for generated profile repos. +- `examples/gallery.json`: lightweight generated-profile gallery metadata. + +### Guided profile wizard + +```bash +python3 scripts/profile_wizard.py --class engineer --bundle open-source --output /tmp/profile.params.yaml --force +python3 scripts/generate_profile.py --params /tmp/profile.params.yaml --output /tmp/engineering-reviewer +python3 /tmp/engineering-reviewer/scripts/validate_profile.py /tmp/engineering-reviewer +``` + +### Scorecard and discovery checks + +```bash +python3 scripts/profile_scorecard.py . --format markdown --threshold 80 +python3 scripts/discovery_optimizer.py . +python3 scripts/render_catalog_entry.py . --source-url https://github.com/codegraphtheory/hermes-profile-template --format all +``` + ## Usage paths Every path below ends in the same contract: a directory that passes validation and can be installed with `hermes profile install`. -### Path 1: Prompt to repo with the installed profile +### Path 1: Simple sentence to repo with the installed profile -Use this when you want the claim literally: describe a profile in natural language and let the `profile-architect` profile create the repo. +Use this when you want the claim literally: give a short natural-language idea, let `profile-architect` expand it into a mature profile prompt, then generate the repo. ```bash hermes profile install github.com/codegraphtheory/hermes-profile-template \ @@ -103,6 +193,12 @@ profile-architect chat Prompt pattern: +```text +Turn "[simple profile idea]" into a fantastic installable Hermes profile repo under [absolute output path]. Expand the idea into a mature agent prompt first, preserve it in docs/profile-prompt.md, then generate the repo and run validation. +``` + +Detailed prompt pattern: + ```text Create a Hermes profile distribution for [target user or workflow]. @@ -120,11 +216,12 @@ Repository requirements: Expected behavior from the installed profile: -1. Ask only for missing essentials. -2. Write a params YAML file. -3. Run `python3 scripts/generate_profile.py --params --output `. -4. Run `python3 /scripts/validate_profile.py `. -5. Report the generated repo path and exact validation output. +1. Expand short input into a mature profile prompt. +2. Ask only for missing essentials. +3. Write a params YAML file with `profile_prompt` preserved. +4. Run `python3 scripts/generate_profile.py --params --output `. +5. Run `python3 /scripts/validate_profile.py `. +6. Report the generated repo path and exact validation output. ### Path 2: Prompt to repo in one non-interactive command @@ -141,9 +238,9 @@ hermes profile install github.com/codegraphtheory/hermes-profile-template \ --yes hermes -p profile-architect chat -q ' -Create a Hermes profile distribution for a database migration reviewer. -Write it under /tmp/hermes-profile-builder-demo/database-migration-reviewer. -It should review SQL migration diffs, flag destructive operations, produce rollback checklists, include one bundled migration-review skill, and run validation before finishing. +Turn "a database migration reviewer" into a fantastic installable Hermes profile repo under /tmp/hermes-profile-builder-demo/database-migration-reviewer. +Expand the idea into a mature agent prompt first, preserve it in docs/profile-prompt.md, then generate the repo and run validation. +The profile should review SQL migration diffs, flag destructive operations, produce rollback checklists, and include one bundled migration-review skill. Do not use real credentials. ' ``` @@ -156,7 +253,25 @@ python3 scripts/validate_profile.py . hermes profile install . --name migration-reviewer-demo --yes --force ``` -### Path 3: Direct generation from command-line flags +### Path 3: One-sentence deterministic generation with demo assets + +Use this when you want the web-demo backend behavior from the terminal without opening the browser. + +```bash +python3 scripts/generate_from_sentence.py \ + --sentence "a database migration reviewer" \ + --output /tmp/database-migration-reviewer \ + --force + +cd /tmp/database-migration-reviewer +python3 scripts/validate_profile.py . +open demo/index.html +open docs/output-diagram.svg +``` + +This path creates the profile repo, mature prompt, playable demo, SVG diagram, validation report, and a downloadable zip artifact under the output parent `artifacts/` directory. + +### Path 4: Direct generation from command-line flags Use this when you already know the profile name and one-sentence purpose. @@ -178,7 +293,7 @@ hermes profile install . --name security-reviewer-local --yes This path is deterministic and quick, but less expressive than the installed `profile-architect` prompt workflow. -### Path 4: Deterministic generation from a params file +### Path 5: Deterministic generation from a params file Use this when you want reproducible profile generation, code review, or repeatable builds. @@ -201,7 +316,7 @@ hermes profile install . --name migration-reviewer-local --yes This is the best path after the first prompt-generated draft. Commit the params file so the repo can be regenerated later. -### Path 5: Use GitHub's template button +### Path 6: Use GitHub's template button Use this when you want a new repository that GitHub marks as generated from this template. diff --git a/SOUL.md b/SOUL.md index 0c4c709..21079a6 100644 --- a/SOUL.md +++ b/SOUL.md @@ -32,28 +32,29 @@ When a user asks you to create a new Hermes profile, do not stop at a plan. Prod Default workflow: -1. Ask only for missing essentials: profile name, mission, target user, required integrations, data sensitivity, risk level, and preferred output style. -2. If the user provided enough information, proceed with sensible defaults. -3. Create a params YAML file using `templates/profile.params.yaml` as the schema reference. -4. Run: +1. If the user gives a simple sentence, expand it into a mature profile prompt first. Use `skills/prompt-engineering/SKILL.md` and `templates/prompts/prompt-to-profile.md` as the procedure. +2. Ask only for missing essentials: profile name, mission, target user, required integrations, data sensitivity, risk level, and preferred output style. +3. If the user provided enough information, proceed with sensible defaults and state assumptions. +4. Create a params YAML file using `templates/profile.params.yaml` as the schema reference. Include `profile_prompt` with the mature prompt so the generated repo preserves it in `docs/profile-prompt.md`. +5. Run: ```bash python3 scripts/generate_profile.py --params --output ``` -5. Run: +6. Run: ```bash python3 /scripts/validate_profile.py ``` -6. If Hermes is available, smoke install the generated repo: +7. If Hermes is available, smoke install the generated repo: ```bash hermes profile install --name --yes --force ``` -7. Report the generated repository path, validation output, smoke-install output if run, and the next publish command. +8. Report the mature prompt path, generated repository path, validation output, smoke-install output if run, and the next publish command. ## Minimum generated repository diff --git a/distribution.yaml b/distribution.yaml index 291c95e..292496f 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,6 +1,6 @@ name: hermes-profile-template -version: 0.4.0 -description: "Prompt-to-repo authoring system for building installable Hermes Agent profile distributions." +version: 0.6.5 +description: "Prompt-to-repo authoring system with one-sentence generation, demo assets, diagrams, and installable Hermes Agent profile distributions." hermes_requires: ">=0.12.0" author: "CodeGraphTheory" license: "MIT" @@ -28,4 +28,6 @@ distribution_owned: - requirements.txt - Makefile - docs/ + - examples/ + - .github/actions/ - .env.EXAMPLE diff --git a/docs/demos/README.md b/docs/demos/README.md new file mode 100644 index 0000000..2a637fa --- /dev/null +++ b/docs/demos/README.md @@ -0,0 +1,19 @@ +# Safe demo kit + +Use this kit to record scaffold, validation, and install walkthroughs without exposing local secrets or private Hermes state. + +## Smoke checks + +```bash +python3 scripts/demo_fixture.py . --demo generate +python3 scripts/demo_fixture.py . --demo all +``` + +The install demo uses a temporary `HERMES_HOME` and skips itself when the Hermes CLI is unavailable unless `--require-hermes` is set. + +## Redaction checklist + +- Record only temporary workspaces created by `scripts/demo_fixture.py`. +- Never show `.env`, `auth.json`, real API keys, memories, sessions, logs, or private repositories. +- Keep credentials as placeholders in `.env.EXAMPLE`. +- Show validation output, generated file names, and install commands. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..dcb60bb --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +# Hermes profile examples + +These lightweight examples show publishable profile shapes without including real credentials or runtime state. + +| Example | Use case | Install command | Keywords | +| --- | --- | --- | --- | +| `security-reviewer` | Reviews code and architecture for application security risk. | `hermes profile install github.com/YOUR_ORG/security-reviewer --alias` | security, code review | +| `database-migration-reviewer` | Reviews SQL migrations for rollout and rollback risk. | `hermes profile install github.com/YOUR_ORG/database-migration-reviewer --alias` | database, migrations, rollback | +| `release-manager` | Coordinates changelog, smoke validation, and rollout notes. | `hermes profile install github.com/YOUR_ORG/release-manager --alias` | release, CI, smoke testing | +| `research-assistant` | Builds source-grounded briefs with uncertainty labels. | `hermes profile install github.com/YOUR_ORG/research-assistant --alias` | research, documentation | + +Machine-readable metadata lives in `gallery.json`. Replace `YOUR_ORG` before publishing any install command. diff --git a/examples/gallery.json b/examples/gallery.json new file mode 100644 index 0000000..dd2fe1e --- /dev/null +++ b/examples/gallery.json @@ -0,0 +1,10 @@ +{ + "schema_version": "hermes-profile-examples/v0.1", + "schema_docs": "examples/README.md", + "examples": [ + {"name": "security-reviewer", "use_case": "Reviews code and architecture for application security risk.", "install_command": "hermes profile install github.com/YOUR_ORG/security-reviewer --alias", "publish_hint": "Replace YOUR_ORG before publishing.", "keywords": ["security", "code review"]}, + {"name": "database-migration-reviewer", "use_case": "Reviews SQL migrations for rollout and rollback risk.", "install_command": "hermes profile install github.com/YOUR_ORG/database-migration-reviewer --alias", "publish_hint": "Replace YOUR_ORG before publishing.", "keywords": ["database", "migrations", "rollback"]}, + {"name": "release-manager", "use_case": "Coordinates changelog, smoke validation, and rollout notes.", "install_command": "hermes profile install github.com/YOUR_ORG/release-manager --alias", "publish_hint": "Replace YOUR_ORG before publishing.", "keywords": ["release", "ci", "smoke testing"]}, + {"name": "research-assistant", "use_case": "Builds source-grounded briefs with uncertainty labels.", "install_command": "hermes profile install github.com/YOUR_ORG/research-assistant --alias", "publish_hint": "Replace YOUR_ORG before publishing.", "keywords": ["research", "documentation"]} + ] +} diff --git a/github-repo-metadata.yaml b/github-repo-metadata.yaml index f8a7f4e..b56a1ed 100644 --- a/github-repo-metadata.yaml +++ b/github-repo-metadata.yaml @@ -1,4 +1,4 @@ -description: AI-friendly GitHub template for building installable Hermes Agent profile distributions with profile manifests, validation scripts, CI, docs, and reproducible release checks. +description: Prompt-to-repo authoring system with one-sentence generation, demo assets, diagrams, and installable Hermes Agent profile distributions. homepage: https://github.com/codegraphtheory/hermes-profile-template topics: - agent-profile diff --git a/scripts/demo_fixture.py b/scripts/demo_fixture.py new file mode 100644 index 0000000..4f33351 --- /dev/null +++ b/scripts/demo_fixture.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Run safe temporary demo fixtures without exposing local Hermes state.""" +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +FORBIDDEN_NAMES = {".env", "auth.json", "state.db"} +FORBIDDEN_PARTS = {"memories", "sessions", "logs"} + + +@dataclass(frozen=True) +class DemoResult: + name: str + status: str + workspace: Path + note: str + + +def redacted(path: Path, workspace: Path) -> str: + text = str(path) + return text.replace(str(workspace), "$DEMO_WORKSPACE") + + +def run(args: list[str], cwd: Path, workspace: Path, env: dict[str, str] | None = None) -> None: + printable = [redacted(Path(arg), workspace) if str(arg).startswith(str(workspace)) else str(arg) for arg in args] + print("$ " + " ".join(printable)) + subprocess.run(args, cwd=cwd, env=env, check=True) + + +def assert_no_runtime_state(root: Path) -> None: + hits = [] + for path in root.rglob("*"): + if path.name in FORBIDDEN_NAMES or set(path.relative_to(root).parts) & FORBIDDEN_PARTS: + hits.append(str(path.relative_to(root))) + if hits: + raise RuntimeError("Runtime state leaked into demo output: " + ", ".join(hits)) + + +def generate_demo(root: Path, workspace: Path) -> DemoResult: + output = workspace / "generated-profile" + run([sys.executable, "scripts/generate_profile.py", "--params", "templates/profile.params.yaml", "--output", str(output)], root, workspace) + run([sys.executable, str(output / "scripts" / "validate_profile.py"), str(output)], root, workspace) + assert_no_runtime_state(output) + return DemoResult("generate-and-validate", "passed", workspace, "Generated profile validated in a temporary workspace.") + + +def install_demo(root: Path, workspace: Path, require_hermes: bool) -> DemoResult: + hermes = shutil.which("hermes") + if not hermes: + if require_hermes: + raise RuntimeError("Hermes CLI was not found on PATH.") + return DemoResult("install-profile", "skipped", workspace, "Hermes CLI not found.") + hermes_home = workspace / "hermes-home" + env = {**os.environ, "HERMES_HOME": str(hermes_home)} + run([hermes, "profile", "install", ".", "--name", "profile-architect-demo", "--yes", "--force"], root, workspace, env=env) + expected = hermes_home / "profiles" / "profile-architect-demo" / "SOUL.md" + if not expected.exists(): + raise RuntimeError(f"Expected installed file missing: {expected}") + return DemoResult("install-profile", "passed", workspace, "Installed into temporary HERMES_HOME.") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run safe Hermes profile demo fixtures.") + parser.add_argument("path", nargs="?", default=".") + parser.add_argument("--demo", choices=["generate", "install", "all"], default="generate") + parser.add_argument("--keep", action="store_true") + parser.add_argument("--require-hermes", action="store_true") + args = parser.parse_args() + root = Path(args.path).resolve() + workspace = Path(tempfile.mkdtemp(prefix="hermes-demo-")) + results: list[DemoResult] = [] + try: + if args.demo in {"generate", "all"}: + results.append(generate_demo(root, workspace)) + if args.demo in {"install", "all"}: + results.append(install_demo(root, workspace, args.require_hermes)) + print("\n| Demo | Status | Note |") + print("| --- | --- | --- |") + for result in results: + print(f"| {result.name} | {result.status} | {result.note} |") + print("\nRedaction reminder: record only the temporary workspace, never secrets, auth files, memories, sessions, or logs.") + return 0 + finally: + if args.keep: + print(f"Kept workspace: {workspace}") + else: + shutil.rmtree(workspace, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/discovery_optimizer.py b/scripts/discovery_optimizer.py new file mode 100644 index 0000000..bbf15cb --- /dev/null +++ b/scripts/discovery_optimizer.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import json +import re +from pathlib import Path +import yaml + +RECOMMENDED_TOPICS = ["hermes-agent", "ai-agents", "agent-profile", "profile-distribution"] + + +def load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def infer_origin(root: Path) -> str | None: + proc = subprocess.run(["git", "remote", "get-url", "origin"], cwd=root, text=True, capture_output=True) + if proc.returncode != 0: + return None + url = proc.stdout.strip() + if url.endswith(".git"): + url = url[:-4] + return url + + +def run_checks(root: Path, fix: bool) -> tuple[dict[str, dict[str, str]], list[str]]: + report = { + "one_sentence_desc": {"status": "FAIL", "hint": "Add a clear description (<= 180 chars) in distribution.yaml."}, + "install_command": {"status": "FAIL", "hint": "Include a visible `hermes profile install` command near the top of README.md."}, + "github_topics": {"status": "FAIL", "hint": "Add all recommended topics to github-repo-metadata.yaml."}, + "domain_keywords": {"status": "FAIL", "hint": "Enhance README.md headings with context keywords like Security or Use Case."}, + "template_lineage": {"status": "FAIL", "hint": "Mention source template lineage (hermes-profile-template) in README.md."}, + "validation_commands": {"status": "FAIL", "hint": "Document explicit verification runbooks like `make validate` in README.md."}, + "license_security": {"status": "FAIL", "hint": "Verify both LICENSE and SECURITY.md exist in the repository root."}, + "social_preview": {"status": "FAIL", "hint": "Provide configurations or guides for social previews / sharing snippets."} + } + + fixes: list[str] = [] + dist = load_yaml(root / "distribution.yaml") + meta_path = root / "github-repo-metadata.yaml" + meta = load_yaml(meta_path) + + desc = str(dist.get("description") or "").strip() + if desc and len(desc) <= 180: + report["one_sentence_desc"]["status"] = "PASS" + report["one_sentence_desc"]["hint"] = "Valid core distribution description verified." + + readme_path = root / "README.md" + readme_content = "" + readme_lines = [] + if readme_path.exists(): + readme_content = readme_path.read_text(encoding="utf-8", errors="ignore") + readme_lines = readme_content.splitlines() + + if "hermes profile install" in readme_content: + report["install_command"]["status"] = "PASS" + report["install_command"]["hint"] = "Installation command block located dynamically." + + if not meta and fix and desc: + meta = {"description": desc, "homepage": infer_origin(root) or "", "topics": list(RECOMMENDED_TOPICS)} + meta_path.write_text(yaml.safe_dump(meta, sort_keys=False), encoding="utf-8") + fixes.append("Created github-repo-metadata.yaml configuration.") + meta = load_yaml(meta_path) + + if meta: + topics = [str(t).lower() for t in meta.get("topics") or []] + missing = [t for t in RECOMMENDED_TOPICS if t not in topics] + if not missing: + report["github_topics"]["status"] = "PASS" + report["github_topics"]["hint"] = "All recommended discovery topics are active." + elif fix: + meta["topics"] = topics + missing + meta_path.write_text(yaml.safe_dump(meta, sort_keys=False), encoding="utf-8") + fixes.append("Injected missing infrastructure topics.") + report["github_topics"]["status"] = "PASS" + report["github_topics"]["hint"] = "All recommended discovery topics are active." + + matched_headings = 0 + keyword_patterns = [r"security", r"discovery", r"use\s*case", r"profile", r"optimization", r"deployment"] + for line in readme_lines: + if line.strip().startswith("#"): + if any(re.search(pat, line, re.IGNORECASE) for pat in keyword_patterns): + matched_headings += 1 + + if matched_headings >= 2: + report["domain_keywords"]["status"] = "PASS" + report["domain_keywords"]["hint"] = f"Validated {matched_headings} enriched domain search anchors." + + if "hermes-profile-template" in readme_content: + report["template_lineage"]["status"] = "PASS" + report["template_lineage"]["hint"] = "Upstream tracking lineage confirmed." + + val_patterns = [r"make validate", r"validate_profile\.py", r"make smoke"] + if any(re.search(pat, readme_content, re.IGNORECASE) for pat in val_patterns): + report["validation_commands"]["status"] = "PASS" + report["validation_commands"]["hint"] = "Testing matrix runbooks mapped out." + + if (root / "SECURITY.md").exists() and (root / "LICENSE").exists(): + report["license_security"]["status"] = "PASS" + report["license_security"]["hint"] = "Compliance gates (LICENSE + SECURITY.md) satisfied." + + if any(re.search(pat, readme_content, re.IGNORECASE) for pat in [r"social", r"preview", r"share", r"og:image", r"meta"]): + report["social_preview"]["status"] = "PASS" + report["social_preview"]["hint"] = "Social link index configuration guidelines covered." + + if fix and report["template_lineage"]["status"] == "FAIL" and readme_path.exists(): + lineage_node = "\n\n---\n*Generated via [hermes-profile-template](https://github.com/codegraphtheory/hermes-profile-template).*\n" + readme_path.write_text(readme_content + lineage_node, encoding="utf-8") + fixes.append("Appended lineage tracking data to README.md.") + report["template_lineage"]["status"] = "PASS" + report["template_lineage"]["hint"] = "Upstream tracking lineage confirmed." + + return report, fixes + + +def generate_markdown(report: dict) -> str: + lines = ["# README Discovery Optimization Report\n"] + lines.append("| Metric Domain | Pipeline Status | Recommendation Notes |") + lines.append("| :--- | :---: | :--- |") + for k, v in report.items(): + sym = "✅ PASS" if v["status"] == "PASS" else "❌ FAIL" + lines.append(f"| **{k.replace('_', ' ').title()}** | {sym} | {v['hint']} |") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check and optimize profile repository discovery.") + parser.add_argument("path", nargs="?", default=".") + parser.add_argument("--fix", action="store_true") + parser.add_argument("--json", action="store_true") + parser.add_argument("--markdown", action="store_true") + args = parser.parse_args() + + root = Path(args.path) + report, fixes = run_checks(root, args.fix) + + if args.json: + print(json.dumps(report, indent=2)) + return 0 + elif args.markdown: + print(generate_markdown(report)) + return 0 + + for item in fixes: + print("FIX: " + item) + + print("Discovery Diagnostics Summary:") + for k, v in report.items(): + print(f"[{v['status']}] {k.replace('_', ' ').title()}: {v['hint']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/scripts/generate_from_sentence.py b/scripts/generate_from_sentence.py new file mode 100755 index 0000000..ed49b9f --- /dev/null +++ b/scripts/generate_from_sentence.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +"""Generate an installable Hermes profile repo from one sentence. + +This is the deterministic backend entrypoint for local and web demos. It expands +one short idea into a mature profile prompt, writes params YAML, invokes the +profile generator, adds a focused starter skill, renders a playable static demo, +renders an SVG contents diagram, validates the profile, and optionally packages +safe downloadable artifacts. +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import shutil +import subprocess +import sys +import textwrap +import zipfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None + +MAX_SENTENCE_CHARS = 1000 +SECRET_HINTS = re.compile( + r"(sk-[A-Za-z0-9]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-|AKIA[0-9A-Z]{12,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)", + re.IGNORECASE, +) + +STOPWORDS = { + "a", "an", "the", "for", "with", "that", "this", "into", "agent", "profile", "hermes", + "make", "build", "create", "turn", "to", "of", "and", "or", "my", "our", "me", "it", +} + +@dataclass +class BuildResult: + slug: str + display_name: str + profile_dir: Path + params_path: Path + prompt_path: Path + diagram_path: Path + demo_md_path: Path + demo_html_path: Path + validation_report_path: Path + zip_path: Path | None + validation_stdout: str + + +def slugify(value: str) -> str: + value = value.strip().lower() + value = re.sub(r"[^a-z0-9]+", "-", value) + value = re.sub(r"-+", "-", value).strip("-") + if not value: + return "generated-profile" + parts = [part for part in value.split("-") if part not in STOPWORDS] + slug = "-".join(parts[:5]) or value + return slug[:63].strip("-") or "generated-profile" + + +def titleize(slug: str) -> str: + return " ".join(part.capitalize() for part in slug.split("-")) + + +def sanitize_sentence(sentence: str) -> str: + cleaned = " ".join(sentence.strip().split()) + if not cleaned: + raise ValueError("sentence is required") + if len(cleaned) > MAX_SENTENCE_CHARS: + raise ValueError(f"sentence must be {MAX_SENTENCE_CHARS} characters or less") + if SECRET_HINTS.search(cleaned): + raise ValueError("sentence appears to contain a secret or credential. Remove it and try again") + return cleaned + + +def infer_domain(sentence: str) -> dict[str, Any]: + low = sentence.lower() + if any(term in low for term in ["ticket", "support", "helpdesk", "customer"]): + return { + "noun": "support ticket", + "skill": "ticket-triage-workflow", + "skill_title": "Ticket Triage Workflow", + "description": "Triage customer support tickets into priority, ownership, response guidance, and safe next actions while protecting customer data.", + "topics": ["support", "helpdesk", "ticket-triage", "customer-success"], + "scope": [ + "Classify support tickets by priority, category, sentiment, customer impact, and likely owning team.", + "Extract concise summaries, missing information, evidence, and recommended next actions.", + "Draft safe customer replies and internal escalation notes.", + "Flag security, privacy, billing, legal, outage, SLA, VIP, and customer-health risks for human review.", + ], + "outputs": [ + "Triage result with priority, category, owner, status recommendation, and confidence.", + "Evidence from the ticket and inspected sources, with assumptions labeled.", + "Missing information and recommended next actions.", + "Customer-facing draft reply and internal escalation note when useful.", + ], + "refusals": [ + "Exfiltrating customer data, credentials, payment information, or internal-only records.", + "Fabricating ticket history, SLA commitments, outage status, refunds, or root causes.", + "Closing, deleting, suppressing, or downgrading tickets to hide incidents.", + ], + "env": [ + {"name": "SUPPORT_HELPDESK_BASE_URL", "description": "Optional helpdesk base URL.", "required": False}, + {"name": "SUPPORT_KB_BASE_URL", "description": "Optional support knowledge-base URL.", "required": False}, + ], + } + if any(term in low for term in ["migration", "sql", "database", "postgres", "mysql"]): + return { + "noun": "database migration", + "skill": "migration-review-workflow", + "skill_title": "Migration Review Workflow", + "description": "Review database migration changes for destructive operations, lock risk, data integrity risk, and rollback readiness.", + "topics": ["database", "sql", "migrations", "devops"], + "scope": [ + "Review SQL, ORM, and schema migration diffs before deployment.", + "Flag destructive changes, table rewrites, unsafe locks, missing rollback plans, and irreversible data changes.", + "Produce deploy risk summaries and rollback checklists.", + "Ask for database engine and version only when semantics depend on it.", + ], + "outputs": [ + "Risk verdict with one-sentence rationale.", + "Findings grouped by destructive change, lock risk, rollback gap, and data integrity risk.", + "Required fixes before deploy.", + "Rollback checklist and evidence reviewed.", + ], + "refusals": [ + "Running destructive SQL against production without explicit approval and a named target.", + "Claiming a migration is safe without reviewing the actual diff or migration content.", + "Storing database credentials in repository files.", + ], + "env": [], + } + if any(term in low for term in ["security", "audit", "vulnerability", "threat", "risk"]): + return { + "noun": "security review", + "skill": "security-review-workflow", + "skill_title": "Security Review Workflow", + "description": "Review code, architecture, and operational changes for security risk with evidence-backed findings and mitigations.", + "topics": ["security", "code-review", "threat-modeling", "appsec"], + "scope": [ + "Inspect code, configs, diffs, and architecture notes for security weaknesses.", + "Prioritize issues by exploitability, blast radius, and likelihood.", + "Recommend concrete mitigations and verification steps.", + "Separate confirmed vulnerabilities from assumptions and follow-up questions.", + ], + "outputs": [ + "Severity-ranked findings with evidence.", + "Threat model summary.", + "Mitigation checklist.", + "Verification commands or review steps.", + ], + "refusals": [ + "Weaponizing vulnerabilities against third-party systems.", + "Stealing credentials, secrets, sessions, or private data.", + "Concealing backdoors or bypasses in code.", + ], + "env": [], + } + return { + "noun": "workflow", + "skill": "focused-workflow", + "skill_title": "Focused Workflow", + "description": f"Help users with {sentence.rstrip('.')} through a focused, safe, evidence-backed Hermes profile.", + "topics": ["workflow-automation", "ai-agent", "productivity"], + "scope": [ + f"Handle user requests related to {sentence.rstrip('.')}.", + "Clarify missing inputs only when they materially affect the result.", + "Produce durable, actionable outputs with evidence when relevant.", + "Run validation or checks after creating or modifying files.", + ], + "outputs": [ + "Concise result.", + "Evidence, files inspected, or commands run when relevant.", + "Risks, assumptions, and blockers.", + "Recommended next action.", + ], + "refusals": [ + "Credential theft or secret exposure.", + "Hidden persistence, backdoors, or deceptive automation.", + "Fabricated facts, links, audits, or affiliations.", + ], + "env": [], + } + + +def mature_prompt(sentence: str, slug: str, display_name: str, domain: dict[str, Any], output: Path) -> str: + workflows = "\n".join(f"{i}. {item}" for i, item in enumerate(domain["scope"], 1)) + outputs = "\n".join(f"- {item}" for item in domain["outputs"]) + refusals = "\n".join(f"- {item}" for item in domain["refusals"]) + env_lines = domain.get("env") or [] + env_text = "\n".join(f"- {item['name']}: {item['description']}" for item in env_lines) or "- None required for the default local workflow." + return f"""## Simple sentence + +{sentence} + +## Mature Profile Prompt + +### Profile name and slug + +- Display name: {display_name} +- Slug: {slug} + +### Mission + +{domain['description']} + +### Target users + +- Builders, operators, reviewers, and teams who need repeatable assistance for {domain['noun']} work. + +### Primary workflows + +{workflows} + +### Trigger patterns + +Use this profile when the user asks to analyze, review, triage, generate, summarize, validate, or prepare outputs related to {domain['noun']} work. + +### Inputs expected + +- Pasted text, local files, repository paths, diffs, exported data, links, or user-provided context. +- The user's taxonomy, policy, rubric, or success criteria when available. +- Constraints such as risk tolerance, audience, deadline, or required output format. + +### Outputs required + +{outputs} + +### Tool-use policy + +- Inspect real files, repositories, or docs before making factual claims about them. +- Use validation, smoke tests, or syntax checks after changing files. +- Report exact command output for verification-critical work. +- State assumptions and blockers clearly instead of inventing results. + +### Safety boundaries and refusals + +{refusals} + +### Required bundled skills + +- {domain['skill']}: a focused procedure for the profile's core workflow. + +### Environment variables + +{env_text} + +### Verification and smoke-test expectations + +- `python3 scripts/validate_profile.py .` +- `hermes profile install . --name {slug}-local --yes --force` + +### Repository output requirements + +- Write the generated profile under `{output}`. +- Include `SOUL.md`, `distribution.yaml`, `README.md`, `config.yaml`, `.env.EXAMPLE`, `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, scripts, docs, and required bundled skills. +- Preserve this mature prompt in `docs/profile-prompt.md`. +- Include a playable demo and an SVG diagram explaining the output contents. +""" + + +def params_for(sentence: str, output: Path, profile_prompt_override: str | None = None) -> dict[str, Any]: + slug = slugify(sentence) + display = titleize(slug) + domain = infer_domain(sentence) + prompt = profile_prompt_override or mature_prompt(sentence, slug, display, domain, output) + topics = ["hermes-agent", "ai-agents", "agent-profile", "profile-distribution"] + domain["topics"] + return { + "name": slug, + "display_name": display, + "description": domain["description"], + "author": "Hermes profile author", + "version": "0.1.0", + "license": "MIT", + "model_provider": os.getenv("HERMES_PROFILE_MODEL_PROVIDER", "openrouter"), + "model_default": os.getenv("HERMES_PROFILE_MODEL_DEFAULT", "anthropic/claude-sonnet-4"), + "template_source": { + "name": "codegraphtheory/hermes-profile-template", + "url": "https://github.com/codegraphtheory/hermes-profile-template", + "relationship": "generated-from-template", + }, + "toolsets": ["file", "terminal", "skills", "web", "session_search", "clarify"], + "env_requires": domain.get("env") or [], + "principles": [ + "Be evidence-backed before being confident.", + "Protect user data and never expose secrets.", + "Ask only for missing information that materially changes the result.", + "Produce concise outputs that a human can act on immediately.", + "Run validation or checks when creating or changing artifacts.", + ], + "scope": domain["scope"], + "refusals": domain["refusals"], + "output_contract": domain["outputs"], + "profile_prompt": prompt, + "github_topics": topics[:20], + } + + +def write_yaml(path: Path, data: dict[str, Any]) -> None: + if yaml is None: + raise RuntimeError("PyYAML is required. Install with: python3 -m pip install pyyaml") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False), encoding="utf-8") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def render_skill(path: Path, slug: str, title: str, domain: dict[str, Any]) -> None: + scope = "\n".join(f"{i}. {item}" for i, item in enumerate(domain["scope"], 1)) + outputs = "\n".join(f"- {item}" for item in domain["outputs"]) + text = f"""--- +name: {slug} +description: "Reusable workflow for {domain['noun']} tasks." +version: 0.1.0 +author: Hermes profile author +license: MIT +--- + +# {title} + +Use this skill when the user asks for help with {domain['noun']} work. + +## Workflow + +{scope} + +## Output contract + +{outputs} + +## Verification + +- Cite files, inputs, or commands used as evidence. +- Label assumptions and missing information. +- Run validators, tests, or smoke checks when files are created or changed. +- Never invent command output or external facts. +""" + write_text(path, text) + + +def render_playable_demo(profile_dir: Path, params: dict[str, Any]) -> tuple[Path, Path]: + slug = params["name"] + display = params["display_name"] + demo_prompt = f"Help me with {params['description'].rstrip('.').lower()}." + assistant_reply = "\n".join([ + f"I am {display}. I will handle this with a focused workflow.", + "", + "1. I will inspect the provided context before making factual claims.", + "2. I will apply the bundled workflow skill for this profile.", + "3. I will return the required output contract with evidence, risks, and next steps.", + "4. If files change, I will run validation before calling the task complete.", + ]) + md = f"""# Playable Demo + +This is a static demo transcript for the generated `{slug}` profile. It is safe to publish and does not require credentials. + +## Demo conversation + +**User** + +```text +{demo_prompt} +``` + +**{display}** + +```text +{assistant_reply} +``` + +## Try it locally + +```bash +hermes profile install . --name {slug}-local --yes --force +hermes -p {slug}-local chat +``` +""" + md_path = profile_dir / "docs" / "playable-demo.md" + write_text(md_path, md) + html_doc = f""" + + + + + {html.escape(display)} demo + + + +
+

{html.escape(slug)}

+

{html.escape(display)} playable demo

+
+
{html.escape(demo_prompt)}
+
{html.escape(assistant_reply)}
+
+
+ + +""" + html_path = profile_dir / "demo" / "index.html" + write_text(html_path, html_doc) + return md_path, html_path + + +def render_diagram(profile_dir: Path, params: dict[str, Any]) -> Path: + display = html.escape(params["display_name"]) + slug = html.escape(params["name"]) + boxes = [ + ("User sentence", "One short idea typed into the web form"), + ("Mature profile prompt", "docs/profile-prompt.md preserves the expanded design"), + ("Params YAML", "templates/profile.params.yaml captures generation inputs"), + ("Installable repo", "SOUL, manifest, config, docs, scripts, skills"), + ("Playable demo", "demo/index.html and docs/playable-demo.md"), + ("Validation", "scripts/validate_profile.py proves the repo shape"), + ] + width, height = 980, 720 + parts = [f""] + parts.append("") + parts.append("") + parts.append(f"{display}") + parts.append(f"{slug} profile generation map") + y = 130 + for i, (title, body) in enumerate(boxes): + fill = "url(#g)" if i == 0 else "#121a33" + stroke = "#60a5fa" if i == 0 else "#334155" + parts.append(f"") + parts.append(f"{html.escape(title)}") + parts.append(f"{html.escape(body)}") + if i < len(boxes) - 1: + parts.append(f"") + y += 94 + parts.insert(2, "") + parts.append("") + path = profile_dir / "docs" / "output-diagram.svg" + write_text(path, "\n".join(parts)) + return path + + +def safe_zip(profile_dir: Path, artifact_dir: Path, slug: str) -> Path: + artifact_dir.mkdir(parents=True, exist_ok=True) + zip_path = artifact_dir / f"{slug}.zip" + forbidden_parts = {".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "sessions", "memories", "logs", "cache", "local"} + forbidden_names = {".env", "auth.json", "state.db", "state.db-shm", "state.db-wal"} + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(profile_dir.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(profile_dir) + if any(part in forbidden_parts for part in rel.parts) or path.name in forbidden_names: + continue + zf.write(path, Path(slug) / rel) + return zip_path + + +def run_validation(profile_dir: Path) -> str: + cmd = [sys.executable, str(profile_dir / "scripts" / "validate_profile.py"), str(profile_dir)] + proc = subprocess.run(cmd, text=True, capture_output=True) + output = proc.stdout + proc.stderr + if proc.returncode != 0: + raise RuntimeError(output) + return output + + +def build(sentence: str, output: Path, force: bool, artifact_dir: Path | None, package: bool, profile_prompt_file: Path | None = None) -> BuildResult: + sentence = sanitize_sentence(sentence) + output = output.resolve() + template_root = Path(__file__).resolve().parents[1] + profile_prompt_override = profile_prompt_file.read_text(encoding="utf-8") if profile_prompt_file else None + params = params_for(sentence, output, profile_prompt_override) + slug = params["name"] + display = params["display_name"] + if output.exists() and force: + shutil.rmtree(output) + elif output.exists(): + raise FileExistsError(f"output exists. Pass --force to overwrite: {output}") + params_path = output.parent / f"{slug}.params.yaml" + write_yaml(params_path, params) + generator = template_root / "scripts" / "generate_profile.py" + subprocess.run([sys.executable, str(generator), "--params", str(params_path), "--output", str(output)], check=True) + domain = infer_domain(sentence) + render_skill(output / "skills" / domain["skill"] / "SKILL.md", domain["skill"], domain["skill_title"], domain) + demo_md_path, demo_html_path = render_playable_demo(output, params) + diagram_path = render_diagram(output, params) + validation_stdout = run_validation(output) + validation_report_path = output / "docs" / "validation-report.md" + write_text(validation_report_path, f"# Validation Report\n\nGenerated at: {datetime.now(timezone.utc).isoformat()}\n\n```text\n{validation_stdout}\n```\n") + validation_stdout = run_validation(output) + zip_path = safe_zip(output, artifact_dir or output.parent / "artifacts", slug) if package else None + manifest = { + "slug": slug, + "display_name": display, + "sentence": sentence, + "profile_dir": str(output), + "params_path": str(params_path), + "prompt_path": str(output / "docs" / "profile-prompt.md"), + "diagram_path": str(diagram_path), + "demo_html_path": str(demo_html_path), + "demo_md_path": str(demo_md_path), + "validation_report_path": str(validation_report_path), + "zip_path": str(zip_path) if zip_path else None, + } + write_text((artifact_dir or output.parent / "artifacts") / f"{slug}.manifest.json", json.dumps(manifest, indent=2) + "\n") + return BuildResult(slug, display, output, params_path, output / "docs" / "profile-prompt.md", diagram_path, demo_md_path, demo_html_path, validation_report_path, zip_path, validation_stdout) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Turn one sentence into an installable Hermes profile repo") + parser.add_argument("--sentence", required=True, help="Simple profile idea") + parser.add_argument("--output", required=True, help="Output profile directory") + parser.add_argument("--force", action="store_true", help="Overwrite output if it exists") + parser.add_argument("--artifact-dir", help="Directory for zip and manifest artifacts") + parser.add_argument("--no-package", action="store_true", help="Skip zip packaging") + parser.add_argument("--profile-prompt-file", help="Optional mature prompt markdown produced by Hermes/LLM refinement") + parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") + args = parser.parse_args() + try: + result = build( + args.sentence, + Path(args.output), + args.force, + Path(args.artifact_dir).resolve() if args.artifact_dir else None, + not args.no_package, + Path(args.profile_prompt_file).resolve() if args.profile_prompt_file else None, + ) + except Exception as exc: + if args.json: + print(json.dumps({"ok": False, "error": str(exc)})) + else: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + payload = { + "ok": True, + "slug": result.slug, + "display_name": result.display_name, + "profile_dir": str(result.profile_dir), + "params_path": str(result.params_path), + "prompt_path": str(result.prompt_path), + "diagram_path": str(result.diagram_path), + "demo_md_path": str(result.demo_md_path), + "demo_html_path": str(result.demo_html_path), + "validation_report_path": str(result.validation_report_path), + "zip_path": str(result.zip_path) if result.zip_path else None, + "validation_stdout": result.validation_stdout, + } + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(f"Created installable Hermes profile repo: {result.profile_dir}") + print(f"Mature prompt: {result.prompt_path}") + print(f"Playable demo: {result.demo_html_path}") + print(f"Diagram: {result.diagram_path}") + if result.zip_path: + print(f"Download zip: {result.zip_path}") + print(result.validation_stdout, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_profile.py b/scripts/generate_profile.py index 1817b5a..092bf30 100755 --- a/scripts/generate_profile.py +++ b/scripts/generate_profile.py @@ -286,17 +286,15 @@ def render_readme(params: dict[str, Any], slug: str, display_name: str, descript hermes -p {slug}-local chat ``` -## Generate another profile from this one +## Design prompt -This distribution includes a deterministic generator: +The mature prompt used to generate or refine this profile is preserved in: -```bash -python3 scripts/generate_profile.py \ - --params templates/profile.params.yaml \ - --output ../my-new-profile +```text +docs/profile-prompt.md ``` -Edit `templates/profile.params.yaml` first to customize name, mission, principles, env vars, and toolsets. +This document is included for transparency so reviewers can understand the profile's design intent. ## Quality gates @@ -356,6 +354,33 @@ def render_template_source_file(params: dict[str, Any]) -> str | None: return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) +def render_profile_prompt(params: dict[str, Any], display_name: str, description: str) -> str: + profile_prompt = str(params.get("profile_prompt") or "").strip() + if profile_prompt: + return f"""# Mature Profile Prompt + +This document preserves the expanded prompt used to generate this Hermes profile distribution. + +{profile_prompt} +""" + return f"""# Mature Profile Prompt + +This profile was generated from structured params. Use this document to record the mature profile prompt when regenerating or substantially redesigning the profile. + +## Profile + +{display_name} + +## Mission + +{description} + +## Maintenance note + +Keep `docs/profile-prompt.md` aligned with any substantive behavior changes so reviewers can trace why this profile behaves the way it does. +""" + + def render_params_example(slug: str, display_name: str, description: str, author: str) -> str: data = { "name": slug, @@ -386,6 +411,12 @@ def render_params_example(slug: str, display_name: str, description: str, author "Fabricated facts, links, audits, or affiliations.", ], "output_contract": ["Result.", "Evidence or command output when relevant.", "Next step."], + "profile_prompt": ( + "Create a mature Hermes profile prompt before generation. " + "Capture mission, target users, workflows, trigger patterns, inputs, " + "outputs, tool-use policy, safety boundaries, required skills, " + "verification, and repository output requirements." + ), "github_topics": [ "hermes-agent", "ai-agents", @@ -403,18 +434,9 @@ def copy_support_files(template_root: Path, output: Path) -> None: src = template_root / rel if src.exists(): shutil.copytree(src, output / rel, dirs_exist_ok=True, ignore=ignore) - # When this repository is installed as a Hermes profile, Hermes may seed the - # profile with many bundled user skills. Do not copy that entire runtime - # skills directory into generated repos. Only copy this template's own - # authoring skill as a starter example. - profile_craft = template_root / "skills" / "profile-craft" - if profile_craft.exists(): - shutil.copytree( - profile_craft, - output / "skills" / "profile-craft", - dirs_exist_ok=True, - ignore=ignore, - ) + # Generated customer profiles should contain only their domain workflow + # skills. The template's authoring skills are useful for this repo, but + # they make generated agents look like profile-construction agents. for rel in ["LICENSE", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md", "requirements.txt", "Makefile"]: src_file = template_root / rel if src_file.exists(): @@ -459,7 +481,9 @@ def generate(params: dict[str, Any], output: Path, force: bool, template_root: P write(output / "mcp.json", "{\n \"mcpServers\": {}\n}") write(output / "github-repo-metadata.yaml", render_github_metadata(slug, description, params)) write(output / "templates" / "profile.params.yaml", render_params_example(slug, display_name, description, author)) + (output / "skills").mkdir(parents=True, exist_ok=True) copy_support_files(template_root, output) + write(output / "docs" / "profile-prompt.md", render_profile_prompt(params, display_name, description)) template_source_file = render_template_source_file(params) if template_source_file: write(output / ".github" / "template-source.yml", template_source_file) diff --git a/scripts/profile_scorecard.py b/scripts/profile_scorecard.py new file mode 100644 index 0000000..c2bff86 --- /dev/null +++ b/scripts/profile_scorecard.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Compute a quality scorecard for a Hermes profile repository.""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path + +import yaml + +SECRET_PATTERNS = [ + re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), + re.compile(r"sk-[A-Za-z0-9]{20,}"), + re.compile(r"AKIA[0-9A-Z]{16}"), +] +RUNTIME_NAMES = {".env", "auth.json", "state.db", "state.db-shm", "state.db-wal"} +RUNTIME_PARTS = {"memories", "sessions", "logs", "workspace", "plans"} + + +@dataclass(frozen=True) +class Item: + name: str + points: int + max_points: int + detail: str + + +class Scorecard: + def __init__(self, root: Path): + self.root = root.resolve() + self.items: list[Item] = [] + + def add(self, name: str, points: int, max_points: int, detail: str) -> None: + self.items.append(Item(name, points, max_points, detail)) + + def run(self) -> None: + self.check_manifest() + self.check_docs() + self.check_security() + self.check_quality_gates() + self.check_discovery() + + def manifest(self) -> dict: + path = self.root / "distribution.yaml" + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return data if isinstance(data, dict) else {} + + def check_manifest(self) -> None: + data = self.manifest() + self.add("distribution.yaml exists", 10 if data else 0, 10, "Installable profiles need a manifest.") + name = str(data.get("name") or "") + version = str(data.get("version") or "") + desc = str(data.get("description") or "") + self.add("manifest name is kebab-case", 8 if re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) else 0, 8, name or "missing") + self.add("manifest version is semver", 8 if re.fullmatch(r"\d+\.\d+\.\d+", version) else 0, 8, version or "missing") + self.add("manifest description is useful", 8 if 20 <= len(desc) <= 180 else 0, 8, desc or "missing") + env = data.get("env_requires") or [] + documented = all(isinstance(item, dict) and item.get("name") and item.get("description") for item in env) + self.add("env vars are documented", 6 if documented else 0, 6, f"{len(env)} env var entries") + + def check_docs(self) -> None: + for filename, points in {"README.md": 8, "SOUL.md": 6, "AGENTS.md": 4, "SECURITY.md": 4, "CHANGELOG.md": 4}.items(): + self.add(f"{filename} exists", points if (self.root / filename).exists() else 0, points, filename) + readme = (self.root / "README.md").read_text(encoding="utf-8") if (self.root / "README.md").exists() else "" + self.add("README includes install command", 8 if "hermes profile install" in readme else 0, 8, "install command visible") + self.add("README includes validation command", 4 if "validate_profile.py" in readme or "make validate" in readme else 0, 4, "validation command visible") + + def check_security(self) -> None: + hits: list[str] = [] + for path in self.root.rglob("*"): + if ".git" in path.parts or not path.is_file(): + continue + rel = path.relative_to(self.root) + if path.name in RUNTIME_NAMES or set(rel.parts) & RUNTIME_PARTS: + hits.append(str(rel)) + continue + if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".zip"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if any(pattern.search(text) for pattern in SECRET_PATTERNS): + hits.append(str(rel)) + self.add("no runtime state or token-like secrets", 14 if not hits else 0, 14, ", ".join(hits[:8]) if hits else "clean") + + def check_quality_gates(self) -> None: + self.add("validator script exists", 5 if (self.root / "scripts" / "validate_profile.py").exists() else 0, 5, "scripts/validate_profile.py") + makefile = (self.root / "Makefile").read_text(encoding="utf-8") if (self.root / "Makefile").exists() else "" + self.add("Makefile exposes validate", 4 if "validate:" in makefile else 0, 4, "make validate") + self.add("Makefile exposes smoke", 4 if "smoke:" in makefile else 0, 4, "make smoke") + + def check_discovery(self) -> None: + meta_path = self.root / "github-repo-metadata.yaml" + if not meta_path.exists(): + self.add("GitHub metadata exists", 0, 5, "github-repo-metadata.yaml missing") + self.add("GitHub topics are useful", 0, 4, "metadata missing") + return + data = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {} + topics = data.get("topics") or [] + self.add("GitHub metadata exists", 5, 5, "github-repo-metadata.yaml") + useful = isinstance(topics, list) and {"hermes-agent", "profile-distribution"}.issubset(set(map(str, topics))) + self.add("GitHub topics are useful", 4 if useful else 0, 4, ", ".join(map(str, topics)) if isinstance(topics, list) else "invalid") + + @property + def total(self) -> int: + return sum(item.points for item in self.items) + + @property + def maximum(self) -> int: + return sum(item.max_points for item in self.items) + + @property + def percent(self) -> float: + return round((self.total / self.maximum) * 100, 1) if self.maximum else 0.0 + + def as_dict(self) -> dict: + return {"score": self.percent, "points": self.total, "max_points": self.maximum, "items": [asdict(item) for item in self.items]} + + +def render_markdown(card: Scorecard) -> str: + lines = ["# Hermes profile scorecard", "", f"Score: {card.percent}% ({card.total}/{card.maximum})", "", "| Check | Points | Detail |", "| --- | --- | --- |"] + for item in card.items: + lines.append(f"| {item.name} | {item.points}/{item.max_points} | {item.detail.replace('|', '/')} |") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Score a Hermes profile distribution repository.") + parser.add_argument("path", nargs="?", default=".") + parser.add_argument("--format", choices=["text", "json", "markdown"], default="text") + parser.add_argument("--threshold", type=int) + args = parser.parse_args() + if args.threshold is not None and not 0 <= args.threshold <= 100: + parser.error("--threshold must be between 0 and 100") + card = Scorecard(Path(args.path)) + card.run() + if args.format == "json": + print(json.dumps(card.as_dict(), indent=2, sort_keys=True)) + elif args.format == "markdown": + print(render_markdown(card), end="") + else: + print(f"Hermes profile scorecard: {card.percent}% ({card.total}/{card.maximum})") + for item in card.items: + status = "PASS" if item.points == item.max_points else "WARN" if item.points else "FAIL" + print(f"[{status}] {item.name}: {item.points}/{item.max_points} - {item.detail}") + return 1 if args.threshold is not None and card.percent < args.threshold else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/profile_wizard.py b/scripts/profile_wizard.py new file mode 100644 index 0000000..922710a --- /dev/null +++ b/scripts/profile_wizard.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Interactive wizard that writes profile.params.yaml for first-time authors.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + +DEFAULT_OUTPUT = Path("profile.params.yaml") +CLASSES = { + "engineer": { + "name": "engineering-reviewer", + "display_name": "Engineering Reviewer", + "description": "Reviews code, architecture, and release plans for production readiness.", + "toolsets": ["terminal", "file", "github"], + "principles": ["Prefer verified evidence over claims.", "Keep changes small and reversible."], + "github_topics": ["hermes-agent", "code-review", "software-quality"], + }, + "researcher": { + "name": "research-assistant", + "display_name": "Research Assistant", + "description": "Builds source-grounded briefs with uncertainty labels and reusable handoff notes.", + "toolsets": ["web", "file"], + "principles": ["Cite sources before conclusions.", "Separate facts, estimates, and assumptions."], + "github_topics": ["hermes-agent", "research", "knowledge-management"], + }, + "operator": { + "name": "release-operator", + "display_name": "Release Operator", + "description": "Coordinates release readiness, smoke validation, changelogs, and rollout notes.", + "toolsets": ["terminal", "file", "github"], + "principles": ["Never ship without a rollback path.", "Automate checks before relying on memory."], + "github_topics": ["hermes-agent", "release-management", "devops"], + }, +} +BUNDLES = { + "open-source": {"toolsets": ["github"], "scope": ["Review contribution workflow and public docs."], "topics": ["open-source"]}, + "safe-demo": {"toolsets": ["terminal"], "scope": ["Record demos only in temporary workspaces."], "topics": ["demo"]}, + "security": {"toolsets": ["terminal", "file"], "scope": ["Scan for secrets and runtime state."], "topics": ["security"]}, +} + + +def merge_unique(base: list[str], extra: list[str]) -> list[str]: + result = list(base) + for item in extra: + if item not in result: + result.append(item) + return result + + +def build_params(kind: str, bundles: list[str]) -> dict: + if kind not in CLASSES: + raise ValueError(f"Unknown profile class: {kind}") + data = dict(CLASSES[kind]) + scope: list[str] = ["Stay within the profile mission.", "Ask for missing deployment details only when required."] + topics = list(data["github_topics"]) + toolsets = list(data["toolsets"]) + for bundle_name in bundles: + bundle = BUNDLES[bundle_name] + scope = merge_unique(scope, bundle.get("scope", [])) + topics = merge_unique(topics, bundle.get("topics", [])) + toolsets = merge_unique(toolsets, bundle.get("toolsets", [])) + return { + "name": data["name"], + "display_name": data["display_name"], + "description": data["description"], + "version": "0.1.0", + "author": "Profile Author", + "license": "MIT", + "hermes_requires": ">=0.12.0", + "model_provider": "openrouter", + "model_default": "anthropic/claude-sonnet-4", + "toolsets": toolsets, + "env_requires": [], + "principles": data["principles"], + "scope": scope, + "refusals": ["Do not expose secrets, private state, or unsupported claims."], + "output_contract": ["Summarize evidence.", "List risks and next actions."], + "github_topics": topics, + "template_source": {"url": "https://github.com/codegraphtheory/hermes-profile-template", "relationship": "generated-from-template"}, + } + + +def choose(prompt: str, choices: list[str]) -> str: + print(prompt) + for idx, choice in enumerate(choices, 1): + print(f" {idx}) {choice}") + while True: + raw = input("Select: ").strip() + if raw.isdigit() and 1 <= int(raw) <= len(choices): + return choices[int(raw) - 1] + if raw in choices: + return raw + print(f"Enter 1-{len(choices)} or one of: {', '.join(choices)}") + + +def write_params(path: Path, params: dict, *, force: bool) -> None: + if path.exists() and not force: + raise SystemExit(f"Refusing to overwrite {path}. Re-run with --force or choose --output.") + path.write_text(yaml.safe_dump(params, sort_keys=False), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Write a profile.params.yaml from guided choices.") + parser.add_argument("--class", dest="profile_class", choices=sorted(CLASSES)) + parser.add_argument("--bundle", action="append", choices=sorted(BUNDLES), default=[]) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + profile_class = args.profile_class or choose("Choose a profile class", sorted(CLASSES)) + params = build_params(profile_class, args.bundle) + write_params(args.output, params, force=args.force) + print(f"Wrote {args.output}") + print("Next: python3 scripts/generate_profile.py --params {0} --output ../{1}".format(args.output, params["name"])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_readiness.py b/scripts/release_readiness.py new file mode 100644 index 0000000..5c83678 --- /dev/null +++ b/scripts/release_readiness.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Release readiness checks for Hermes profile distributions.""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + +RELEASE_RELEVANT_PREFIXES = ( + ".github/", + "docs/", + "skills/", + "templates/", + "scripts/", + "web-demo/", + "examples/", + "SOUL.md", + "AGENTS.md", + "README.md", + "Makefile", + "requirements.txt", + "distribution.yaml", + "config.yaml", + "mcp.json", +) +IGNORED_PATHS = {"CHANGELOG.md"} +FORBIDDEN_NAMES = {".env", "auth.json", "state.db", "state.db-shm", "state.db-wal"} +FORBIDDEN_PARTS = {"memories", "sessions", "logs", "workspace", "plans", ".pytest_cache", "__pycache__"} +SECRET_PATTERNS = [ + re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), + re.compile(r"sk-[A-Za-z0-9]{20,}"), + re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}"), + re.compile(r"AKIA[0-9A-Z]{16}"), +] + + +@dataclass(frozen=True) +class CheckResult: + name: str + status: str + detail: str + hint: str = "" + + +def run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True) + + +def changed_files(root: Path, base: str) -> list[str] | None: + proc = run_git(root, ["diff", "--name-only", f"{base}...HEAD"]) + if proc.returncode != 0: + proc = run_git(root, ["diff", "--name-only", base, "HEAD"]) + if proc.returncode != 0: + return None + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + + +def read_manifest_version_text(text: str) -> str | None: + data = yaml.safe_load(text) or {} + version = str(data.get("version") or "").strip() + return version or None + + +def current_version(root: Path) -> str | None: + manifest = root / "distribution.yaml" + if not manifest.exists(): + return None + return read_manifest_version_text(manifest.read_text(encoding="utf-8")) + + +def base_version(root: Path, base: str) -> str | None: + proc = run_git(root, ["show", f"{base}:distribution.yaml"]) + if proc.returncode != 0: + return None + return read_manifest_version_text(proc.stdout) + + +def is_release_relevant(path: str) -> bool: + if path in IGNORED_PATHS: + return False + return any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in RELEASE_RELEVANT_PREFIXES) + + +def iter_validation_paths(root: Path) -> list[Path]: + proc = run_git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) + if proc.returncode == 0: + return [root / item for item in proc.stdout.split("\0") if item] + return [p for p in root.rglob("*") if p.is_file() and ".git" not in p.parts] + + +def check_version(root: Path, base: str, strict: bool) -> CheckResult: + files = changed_files(root, base) + if files is None: + status = "FAIL" if strict else "SKIP" + return CheckResult("version", status, f"Could not compare against {base}", "Fetch the base ref before running release readiness.") + relevant = [path for path in files if is_release_relevant(path)] + if not relevant: + return CheckResult("version", "PASS", "No release-relevant changes detected.") + now = current_version(root) + before = base_version(root, base) + if not now: + return CheckResult("version", "FAIL", "distribution.yaml is missing a version.") + if before and before == now: + return CheckResult("version", "FAIL", f"Release-relevant files changed but version stayed {now}.", "Bump distribution.yaml and add a matching changelog heading.") + return CheckResult("version", "PASS", f"Version is release-ready: {before or 'unknown'} -> {now}.") + + +def check_changelog(root: Path) -> CheckResult: + version = current_version(root) + changelog = root / "CHANGELOG.md" + if not version: + return CheckResult("changelog", "FAIL", "Cannot read current distribution version.") + if not changelog.exists(): + return CheckResult("changelog", "FAIL", "CHANGELOG.md is missing.") + text = changelog.read_text(encoding="utf-8") + if re.search(rf"^##\s+{re.escape(version)}\b", text, re.MULTILINE): + return CheckResult("changelog", "PASS", f"Found CHANGELOG.md heading for {version}.") + return CheckResult("changelog", "FAIL", f"Missing CHANGELOG.md heading for {version}.") + + +def check_command(root: Path, name: str, cmd: list[str]) -> CheckResult: + proc = subprocess.run(cmd, cwd=root, text=True, capture_output=True) + detail = (proc.stdout + proc.stderr).strip().splitlines()[-12:] + joined = "\n".join(detail) if detail else "command produced no output" + return CheckResult(name, "PASS" if proc.returncode == 0 else "FAIL", joined) + + +def check_runtime_and_secrets(root: Path) -> CheckResult: + hits: list[str] = [] + for path in iter_validation_paths(root): + rel = path.relative_to(root) + if path.name in FORBIDDEN_NAMES or set(rel.parts) & FORBIDDEN_PARTS: + hits.append(str(rel)) + continue + if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".zip", ".pack", ".idx"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for pattern in SECRET_PATTERNS: + if pattern.search(text): + hits.append(f"{rel}: matches {pattern.pattern}") + break + if hits: + return CheckResult("runtime-and-secrets", "FAIL", "\n".join(hits[:20])) + return CheckResult("runtime-and-secrets", "PASS", "No forbidden runtime paths or token-like secrets found.") + + +def markdown_report(results: list[CheckResult]) -> str: + lines = ["# Release readiness report", "", "| Check | Status | Detail |", "| --- | --- | --- |"] + for result in results: + detail = result.detail.replace("|", "\\|").replace("\n", "
") + if result.hint: + detail += "
Hint: " + result.hint.replace("|", "\\|") + lines.append(f"| {result.name} | {result.status} | {detail} |") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check release readiness for this Hermes profile distribution.") + parser.add_argument("--base", default="origin/main", help="Base ref for version discipline checks.") + parser.add_argument("--strict", action="store_true", help="Fail instead of skipping when the base ref is unavailable.") + args = parser.parse_args() + root = Path.cwd() + results = [ + check_version(root, args.base, args.strict), + check_changelog(root), + check_command(root, "profile-validation", [sys.executable, "scripts/validate_profile.py", "."]), + check_command(root, "python-compile", [sys.executable, "-m", "py_compile", *[str(p) for p in sorted((root / "scripts").glob("*.py"))]]), + check_runtime_and_secrets(root), + ] + print(markdown_report(results)) + return 0 if all(result.status in {"PASS", "SKIP"} for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/render_catalog_entry.py b/scripts/render_catalog_entry.py new file mode 100644 index 0000000..d98ad72 --- /dev/null +++ b/scripts/render_catalog_entry.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Render catalog-ready snippets from distribution.yaml.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + + +def load_manifest(root: Path) -> dict: + data = yaml.safe_load((root / "distribution.yaml").read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise SystemExit("distribution.yaml must be a YAML mapping") + return data + + +def render_markdown(data: dict, source_url: str) -> str: + name = data.get("name", "unknown-profile") + desc = data.get("description", "Hermes profile distribution.") + return f"- [{name}]({source_url}) - {desc} Install: `hermes profile install {source_url} --alias`" + + +def render_yaml(data: dict, source_url: str) -> str: + payload = { + "name": data.get("name"), + "description": data.get("description"), + "source_url": source_url, + "install": f"hermes profile install {source_url} --alias", + "topics": data.get("github_topics", []), + } + return yaml.safe_dump(payload, sort_keys=False).strip() + + +def render_pr_body(data: dict, source_url: str) -> str: + return "\n".join([ + "## Profile catalog submission", + "", + f"Profile: `{data.get('name')}`", + f"Source: {source_url}", + f"Install: `hermes profile install {source_url} --alias`", + "", + "I verified this is a Hermes profile distribution with documented install and validation steps.", + ]) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render profile catalog submission snippets.") + parser.add_argument("path", nargs="?", default=".") + parser.add_argument("--source-url", required=True) + parser.add_argument("--format", choices=["markdown", "yaml", "pr-body", "all"], default="markdown") + args = parser.parse_args() + data = load_manifest(Path(args.path)) + outputs = { + "markdown": render_markdown(data, args.source_url), + "yaml": render_yaml(data, args.source_url), + "pr-body": render_pr_body(data, args.source_url), + } + if args.format == "all": + for name, text in outputs.items(): + print(f"## {name}\n{text}\n") + else: + print(outputs[args.format]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/profile-craft/SKILL.md b/skills/profile-craft/SKILL.md index 5d8c1be..095e012 100644 --- a/skills/profile-craft/SKILL.md +++ b/skills/profile-craft/SKILL.md @@ -27,13 +27,14 @@ Use this skill when creating or improving a Hermes Agent profile distribution. T When a user asks for a new profile from a prompt: -1. Ask only for missing essentials: profile name, mission, target user, required integrations, data sensitivity, risk level, and output style. -2. If enough information is present, proceed with sensible defaults. -3. Write a params YAML file using `templates/profile.params.yaml` as the schema reference. -4. Run `python3 scripts/generate_profile.py --params --output `. -5. Run `python3 /scripts/validate_profile.py `. -6. If Hermes is available, run `hermes profile install --name --yes --force`. -7. Report generated repo path, validation output, smoke-install output if run, and next publish command. +1. If the prompt is only a simple sentence, expand it into a mature profile prompt with `skills/prompt-engineering/SKILL.md` before generating files. +2. Ask only for missing essentials: profile name, mission, target user, required integrations, data sensitivity, risk level, and output style. +3. If enough information is present, proceed with sensible defaults and state assumptions. +4. Write a params YAML file using `templates/profile.params.yaml` as the schema reference. Include the mature prompt in `profile_prompt` so the generated repo can preserve it in `docs/profile-prompt.md`. +5. Run `python3 scripts/generate_profile.py --params --output `. +6. Run `python3 /scripts/validate_profile.py `. +7. If Hermes is available, run `hermes profile install --name --yes --force`. +8. Report mature prompt path, generated repo path, validation output, smoke-install output if run, and next publish command. ### Profile design workflow diff --git a/skills/prompt-engineering/SKILL.md b/skills/prompt-engineering/SKILL.md new file mode 100644 index 0000000..3374e0b --- /dev/null +++ b/skills/prompt-engineering/SKILL.md @@ -0,0 +1,132 @@ +--- +name: prompt-engineering +description: "Turn a simple profile idea into a mature, production-quality Hermes profile prompt and generation brief." +version: 0.1.0 +author: Hermes profile template +license: MIT +metadata: + hermes: + tags: [prompt-engineering, hermes, profiles, agent-design, profile-generation] +--- + +# Prompt Engineering for Hermes Profiles + +Use this skill when a user gives a short idea such as "make me a database migration reviewer" and wants a high-quality Hermes profile distribution. + +The goal is not to make a longer prompt for its own sake. The goal is to turn a simple sentence into a mature profile specification that can generate an installable, safe, useful Hermes profile repo. + +## Inputs + +Minimum input: + +- A one-sentence profile idea. + +Useful optional input: + +- Target user or team. +- Domain and workflow. +- Expected artifacts. +- Tools or integrations. +- Risk level and data sensitivity. +- Desired tone and output format. +- Repository output path. + +## Workflow + +### 1. Extract intent + +From the user's simple sentence, infer: + +- job to be done +- target user +- trigger situations +- expected outputs +- likely tools +- likely risks +- what must be refused + +Ask at most three clarifying questions only if missing information would materially change the generated profile. Otherwise proceed with explicit assumptions. + +### 2. Write a mature profile prompt + +Create a `Mature Profile Prompt` with these sections: + +1. Profile name and slug. +2. Mission. +3. Target users. +4. Primary workflows. +5. Trigger patterns. +6. Inputs the profile expects. +7. Outputs the profile must produce. +8. Tool-use policy. +9. Safety boundaries and refusals. +10. Required skills. +11. Environment variables, if any. +12. Verification and smoke-test expectations. +13. Repository output requirements. + +Keep it specific enough that another agent could generate the profile repo without more context. + +### 3. Convert mature prompt to params YAML + +Map the mature prompt into `templates/profile.params.yaml` fields: + +- `name` +- `display_name` +- `description` +- `toolsets` +- `env_requires` +- `principles` +- `scope` +- `refusals` +- `output_contract` +- `github_topics` + +Add a `profile_prompt` field containing the mature prompt so the generated repo can preserve the design rationale in `docs/profile-prompt.md` when supported by the generator. + +### 4. Generate the repository + +Run: + +```bash +python3 scripts/generate_profile.py --params --output +``` + +Then add any profile-specific bundled skill that materially improves the profile. Keep skills narrow, procedural, and domain-specific. + +### 5. Verify installability + +Run: + +```bash +python3 /scripts/validate_profile.py +``` + +When Hermes is available, also run: + +```bash +hermes profile install --name --yes --force +``` + +## Quality checklist + +- The mature prompt is concrete, not generic. +- The generated profile has one clear job. +- The generated profile includes safety boundaries. +- The profile does not claim fake integrations, credentials, audits, or community links. +- Required env vars are documented in `.env.EXAMPLE` only. +- At least one domain-specific bundled skill is added when the workflow benefits from reusable procedure. +- Validation passes. +- Smoke install passes when Hermes is available. + +## Output format + +When finishing, report: + +- Simple sentence received. +- Mature profile prompt path, if written. +- Params YAML path. +- Generated repository path. +- Validation command and exact result. +- Smoke-install command and exact result, or why skipped. +- Next publish command. diff --git a/templates/profile.params.yaml b/templates/profile.params.yaml index 9f0aa36..7dbacfe 100644 --- a/templates/profile.params.yaml +++ b/templates/profile.params.yaml @@ -52,6 +52,15 @@ output_contract: - Verification command and exact outcome. - Remaining risks or manual steps. +# Preserve the mature prompt that was expanded from the user's simple sentence. +# The generator writes this to docs/profile-prompt.md. +profile_prompt: | + Create a mature Hermes profile prompt before generation. + + Include mission, target users, primary workflows, trigger patterns, expected inputs, + required outputs, tool-use policy, safety boundaries, required bundled skills, + environment variables, verification expectations, and repository output requirements. + github_topics: - hermes-agent - ai-agents diff --git a/templates/prompts/prompt-to-profile.md b/templates/prompts/prompt-to-profile.md new file mode 100644 index 0000000..86604a1 --- /dev/null +++ b/templates/prompts/prompt-to-profile.md @@ -0,0 +1,74 @@ +# Prompt-to-profile brief + +Use this template when turning a simple sentence into a mature Hermes profile prompt. + +## Simple sentence + +[one sentence from the user] + +## Mature Profile Prompt + +### Profile name and slug + +- Display name: [display name] +- Slug: [slug] + +### Mission + +[one precise mission sentence] + +### Target users + +- [user type] + +### Primary workflows + +1. [workflow] +2. [workflow] +3. [workflow] + +### Trigger patterns + +Use this profile when the user asks to: + +- [trigger] +- [trigger] + +### Inputs expected + +- [input] +- [input] + +### Outputs required + +- [output] +- [output] + +### Tool-use policy + +- Inspect real files, repositories, systems, or docs before making factual claims. +- Run validators or smoke tests after generating or changing profile files. +- Do not fabricate command output. + +### Safety boundaries and refusals + +- [refusal] +- [refusal] + +### Required bundled skills + +- [skill name]: [procedure it should encode] + +### Environment variables + +- [NAME]: [why required, or state none] + +### Verification and smoke-test expectations + +- `python3 scripts/validate_profile.py .` +- `hermes profile install . --name [smoke-name] --yes --force` + +### Repository output requirements + +- Include `SOUL.md`, `distribution.yaml`, `README.md`, `config.yaml`, `.env.EXAMPLE`, `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, scripts, docs, and any required bundled skills. +- Preserve this mature prompt in `docs/profile-prompt.md`. diff --git a/tests/test_community_tools.py b/tests/test_community_tools.py new file mode 100644 index 0000000..e0b5efa --- /dev/null +++ b/tests/test_community_tools.py @@ -0,0 +1,53 @@ +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +class CommunityToolsTest(unittest.TestCase): + def test_profile_wizard_noninteractive_output(self): + with tempfile.TemporaryDirectory() as tmp: + params = Path(tmp) / "profile.params.yaml" + proc = subprocess.run( + [sys.executable, "scripts/profile_wizard.py", "--class", "engineer", "--bundle", "open-source", "--output", str(params)], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) + data = yaml.safe_load(params.read_text()) + self.assertEqual(data["name"], "engineering-reviewer") + self.assertIn("github", data["toolsets"]) + + def test_scorecard_json(self): + proc = subprocess.run( + [sys.executable, "scripts/profile_scorecard.py", ".", "--format", "json"], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) + data = json.loads(proc.stdout) + self.assertGreaterEqual(data["score"], 80) + + def test_catalog_renderer(self): + proc = subprocess.run( + [sys.executable, "scripts/render_catalog_entry.py", ".", "--source-url", "https://github.com/codegraphtheory/hermes-profile-template", "--format", "yaml"], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) + data = yaml.safe_load(proc.stdout) + self.assertEqual(data["name"], "hermes-profile-template") + self.assertIn("hermes profile install", data["install"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web-demo/server.py b/web-demo/server.py new file mode 100755 index 0000000..505b902 --- /dev/null +++ b/web-demo/server.py @@ -0,0 +1,759 @@ +#!/usr/bin/env python3 +"""Local web demo for prompt-to-profile generation. + +Run: + python3 web-demo/server.py +Then open http://127.0.0.1:8765 +""" +from __future__ import annotations + +import json +import mimetypes +import os +import shutil +import subprocess +import sys +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + +HOST = "127.0.0.1" +PORT = 8765 +MAX_BODY = 12_000 +MAX_FILE_PREVIEW = 80_000 +HERMES_LLM_ENABLED = os.getenv("HERMES_WEB_DEMO_USE_LLM", "1").lower() not in {"0", "false", "no"} +DEMO_STEP_DELAY = float(os.getenv("HERMES_WEB_DEMO_STEP_DELAY", "0.75")) +HERMES_DEMO_PROVIDER = os.getenv("HERMES_WEB_DEMO_PROVIDER", "openai-codex") +HERMES_DEMO_MODEL = os.getenv("HERMES_WEB_DEMO_MODEL", "gpt-5.5") +HERMES_LIVE_TIMEOUT = int(os.getenv("HERMES_WEB_DEMO_LIVE_TIMEOUT", "300")) +HERMES_LIVE_MAX_TURNS = int(os.getenv("HERMES_WEB_DEMO_LIVE_MAX_TURNS", "12")) +ROOT = Path(__file__).resolve().parents[1] +STATIC = Path(__file__).resolve().parent / "static" +JOBS_ROOT = Path("/tmp/hermes-profile-web-demo-jobs") +JOBS_ROOT.mkdir(parents=True, exist_ok=True) + +JOBS: dict[str, dict] = {} +LOCK = threading.Lock() + + +def public_job(job_id: str) -> dict | None: + job = safe_job(job_id) + if not job: + return None + public = {k: v for k, v in job.items() if k not in {"paths"}} + public["job_id"] = job_id + return public + + +def safe_job(job_id: str) -> dict | None: + with LOCK: + job = JOBS.get(job_id) + return dict(job) if job else None + + +def set_job(job_id: str, **updates) -> None: + with LOCK: + job = JOBS.setdefault(job_id, {}) + job.update(updates) + job["updated_at"] = time.time() + + + +def pace() -> None: + if DEMO_STEP_DELAY > 0: + time.sleep(DEMO_STEP_DELAY) + + +def append_progress(job_id: str, stage_index: int, message: str) -> None: + with LOCK: + job = JOBS.setdefault(job_id, {}) + progress = list(job.get("progress") or []) + progress.append(message) + job.update(status="running", stage_index=stage_index, progress=progress, updated_at=time.time()) + + +def run_process_with_progress( + job_id: str, + stage_index: int, + cmd: list[str], + *, + cwd: Path, + start_message: str, + heartbeat_message: str, + timeout: int, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + append_progress(job_id, stage_index, start_message) + started = time.monotonic() + last_heartbeat = started + proc = subprocess.Popen(cmd, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + while proc.poll() is None: + now = time.monotonic() + elapsed = int(now - started) + if elapsed >= timeout: + proc.kill() + stdout, stderr = proc.communicate() + raise subprocess.TimeoutExpired(cmd, timeout, output=stdout, stderr=stderr) + if now - last_heartbeat >= 3: + append_progress(job_id, stage_index, f"{heartbeat_message} ({elapsed}s elapsed)") + last_heartbeat = now + time.sleep(0.25) + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr) + + +def append_live_progress(job_id: str, run_id: str, message: str, **updates) -> None: + with LOCK: + job = JOBS.setdefault(job_id, {}) + live_runs = dict(job.get("live_runs") or {}) + run = dict(live_runs.get(run_id) or {}) + progress = list(run.get("progress") or []) + progress.append(message) + run.update(updates) + run.update(progress=progress, updated_at=time.time()) + live_runs[run_id] = run + job.update(live_runs=live_runs, updated_at=time.time()) + + +def get_live_run(job_id: str, run_id: str) -> dict | None: + with LOCK: + job = JOBS.get(job_id) or {} + run = (job.get("live_runs") or {}).get(run_id) + return dict(run) if run else None + + +def run_live_process_with_progress( + job_id: str, + run_id: str, + cmd: list[str], + *, + cwd: Path, + start_message: str, + heartbeat_message: str, + timeout: int, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + append_live_progress(job_id, run_id, start_message, status="running") + started = time.monotonic() + last_heartbeat = started + proc = subprocess.Popen(cmd, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + while proc.poll() is None: + now = time.monotonic() + elapsed = int(now - started) + if elapsed >= timeout: + proc.kill() + stdout, stderr = proc.communicate() + raise subprocess.TimeoutExpired(cmd, timeout, output=stdout, stderr=stderr) + if now - last_heartbeat >= 3: + append_live_progress(job_id, run_id, f"{heartbeat_message} ({elapsed}s elapsed)", status="running") + last_heartbeat = now + time.sleep(0.25) + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr) + + +def load_env_file(path: Path, env: dict[str, str]) -> None: + if not path.exists() or not path.is_file(): + return + for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in env: + env[key] = value + + +def hermes_chat_command(prompt: str) -> list[str]: + return ["hermes", "chat", "--provider", HERMES_DEMO_PROVIDER, "-m", HERMES_DEMO_MODEL, "-Q", "-q", prompt] + + +def hermes_profile_chat_command(profile_name: str, message: str) -> list[str]: + return [ + "hermes", + "-p", + profile_name, + "chat", + "--provider", + HERMES_DEMO_PROVIDER, + "-m", + HERMES_DEMO_MODEL, + "-Q", + "--max-turns", + str(HERMES_LIVE_MAX_TURNS), + "-q", + message, + ] + + +def clean_hermes_live_response(text: str) -> str: + """Remove CLI/runtime noise that is not the generated agent response.""" + noisy_prefixes = ( + "⚠ tirith security scanner enabled but not available", + "tirith security scanner enabled but not available", + ) + greeting_lines = { + "Heyy :) What would you like to work on in the Hermes profile template?", + } + lines: list[str] = [] + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + if any(line.startswith(prefix) for prefix in noisy_prefixes): + continue + if line in greeting_lines: + continue + lines.append(raw_line) + return "\n".join(lines).strip() + + +def run_hermes_llm(job_id: str, stage_index: int, title: str, prompt: str, timeout: int = 120) -> tuple[bool, str]: + """Run a real Hermes one-shot LLM call for web-demo generation steps.""" + if not HERMES_LLM_ENABLED: + append_progress(job_id, stage_index, f"Skipped Hermes LLM call: {title}") + return False, "Hermes LLM calls disabled by HERMES_WEB_DEMO_USE_LLM=0." + cmd = hermes_chat_command(prompt) + try: + append_progress(job_id, stage_index, f"Using Hermes provider {HERMES_DEMO_PROVIDER} with model {HERMES_DEMO_MODEL}") + proc = run_process_with_progress( + job_id, + stage_index, + cmd, + cwd=ROOT, + start_message=f"Hermes LLM call started: {title}", + heartbeat_message=f"Waiting for Hermes LLM response: {title}", + timeout=timeout, + ) + except FileNotFoundError: + return False, "Hermes CLI was not found on PATH." + except subprocess.TimeoutExpired as exc: + return False, f"Hermes LLM call timed out after {timeout}s. Partial output: {exc.stdout or ''}" + output = (proc.stdout or "").strip() + if proc.returncode != 0 or not output: + return False, (proc.stderr or proc.stdout or "Hermes LLM call failed").strip() + return True, output + + +def hermes_expand_prompt(job_id: str, sentence: str, job_dir: Path) -> tuple[Path | None, str]: + prompt = f"""You are improving a Hermes Agent profile generation request. + +User sentence: +{sentence} + +Return only a mature Hermes profile prompt in Markdown. Do not include preamble. +Make it specific, operational, and demoable. Include these sections: + +## Simple sentence +## Mature Profile Prompt +### Mission +### Target users +### Primary workflows +### Required outputs +### Safety boundaries +### Tool and data policy +### Skills to include +### Demo behavior +### Validation expectations + +The output should be suitable for docs/profile-prompt.md in an installable Hermes profile repository. +""" + ok, output = run_hermes_llm(job_id, 0, "expand one sentence into mature profile prompt", prompt, timeout=150) + path = job_dir / "enhanced-profile-prompt.md" + if ok: + path.write_text(output + "\n", encoding="utf-8") + return path, "Hermes expanded the sentence into a mature profile prompt." + path.write_text(f"# Hermes prompt expansion unavailable\n\n```text\n{output}\n```\n", encoding="utf-8") + return None, f"Hermes prompt expansion unavailable. Used deterministic fallback. Reason: {output[:240]}" + + +def hermes_review_generated_profile(job_id: str, sentence: str, output: Path) -> tuple[Path | None, str]: + snippets: list[str] = [] + for rel in ["docs/profile-prompt.md", "SOUL.md", "skills/focused-workflow/SKILL.md", "docs/playable-demo.md", "docs/validation-report.md"]: + path = output / rel + if path.exists() and path.is_file(): + text = path.read_text(encoding="utf-8", errors="replace")[:5000] + snippets.append(f"## {rel}\n\n{text}") + prompt = f"""Review this generated Hermes profile for demo quality. + +Original user sentence: +{sentence} + +Generated profile snippets: +{chr(10).join(snippets)} + +Return only Markdown with: +## Verdict +A one-paragraph quality verdict. + +## What is good +- concise bullets + +## What still feels half baked +- concise bullets + +## How to demo it +- concrete talk track bullets + +Do not invent external claims. Be honest and useful. +""" + ok, output_text = run_hermes_llm(job_id, 5, "review generated profile quality", prompt, timeout=150) + path = output / "docs" / "llm-quality-review.md" + if ok: + path.write_text(output_text + "\n", encoding="utf-8") + return path, "Hermes reviewed generated profile quality." + path.write_text(f"# LLM Quality Review\n\nHermes review unavailable.\n\n```text\n{output_text}\n```\n", encoding="utf-8") + return path, f"Hermes quality review unavailable. Reason: {output_text[:240]}" + + +def safe_profile_file(profile_dir: Path, rel: str) -> Path: + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + raise ValueError("invalid file path") + path = (profile_dir / rel_path).resolve() + root = profile_dir.resolve() + if not str(path).startswith(str(root)) or not path.is_file(): + raise ValueError("file not found") + if path.name in {".env", "auth.json", "state.db"}: + raise ValueError("file is not previewable") + return path + +def generated_file_summary(profile_dir: Path) -> list[dict[str, str]]: + """Return the most important generated files for the web UI.""" + candidates = [ + ("SOUL.md", "agent identity"), + ("distribution.yaml", "install manifest"), + ("README.md", "usage guide"), + ("config.yaml", "runtime defaults"), + ("templates/profile.params.yaml", "generation params"), + ("docs/profile-prompt.md", "mature prompt"), + ("skills", "bundled skills"), + ("scripts/validate_profile.py", "validator"), + ("demo/index.html", "playable demo"), + ("docs/validation-report.md", "quality report"), + ("docs/llm-quality-review.md", "Hermes review"), + ] + summary: list[dict[str, str]] = [] + for rel, role in candidates: + path = profile_dir / rel + if rel == "skills" and path.exists(): + hidden_skills = {"profile-craft", "prompt-engineering"} + for skill_file in sorted(path.glob("*/SKILL.md")): + if skill_file.parent.name in hidden_skills: + continue + summary.append({"path": str(skill_file.relative_to(profile_dir)), "role": "bundled skill"}) + if sum(1 for item in summary if item.get("role") == "bundled skill") >= 4: + break + continue + if path.exists() and path.is_file(): + summary.append({"path": rel, "role": role}) + return summary + + +def ensure_live_profile_installed(job_id: str, run_id: str | None = None) -> tuple[bool, str, Path | None, str]: + job = safe_job(job_id) + if not job: + return False, "job not found", None, "" + result = job.get("result") or {} + paths = job.get("paths") or {} + profile_dir = paths.get("profile") + slug = result.get("slug") + if not profile_dir or not slug: + return False, "generated profile is not available yet", None, "" + hermes_home = (JOBS_ROOT / job_id / "hermes-home").resolve() + profile_name = f"{slug}-live" + marker = hermes_home / "profiles" / profile_name / "distribution.yaml" + if marker.exists(): + return True, profile_name, hermes_home, "Profile is already installed for live demo." + hermes_home.mkdir(parents=True, exist_ok=True) + default_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes") + for name in (".env", "auth.json"): + source = default_home / name + target = hermes_home / name + if source.exists() and source.is_file() and not target.exists(): + shutil.copy2(source, target) + os.chmod(target, 0o600) + env = os.environ.copy() + load_env_file(hermes_home / ".env", env) + env["HERMES_HOME"] = str(hermes_home) + cmd = ["hermes", "profile", "install", str(profile_dir), "--name", profile_name, "--yes", "--force"] + try: + if run_id: + proc = run_live_process_with_progress( + job_id, + run_id, + cmd, + cwd=ROOT, + start_message="Installing generated profile into isolated Hermes home", + heartbeat_message="Still installing generated profile", + timeout=120, + env=env, + ) + else: + proc = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True, timeout=120, env=env) + except FileNotFoundError: + return False, "Hermes CLI was not found on PATH.", None, "" + except subprocess.TimeoutExpired: + return False, "Hermes profile install timed out after 120s.", None, "" + output = (proc.stdout + proc.stderr).strip() + if proc.returncode != 0: + return False, output or "Hermes profile install failed.", None, output + return True, profile_name, hermes_home, output or "Profile installed for live demo." + + +def run_live_demo(job_id: str, run_id: str, message: str) -> None: + append_live_progress(job_id, run_id, "Preparing generated profile for a real Hermes call", status="running") + ok, profile_name, hermes_home, install_output = ensure_live_profile_installed(job_id, run_id) + if not ok or not hermes_home: + append_live_progress(job_id, run_id, profile_name, status="failed", error=profile_name) + return + job = safe_job(job_id) or {} + profile_dir_raw = (job.get("paths") or {}).get("profile") + profile_dir = Path(profile_dir_raw).resolve() if profile_dir_raw else ROOT + append_live_progress(job_id, run_id, install_output.splitlines()[-1] if install_output else "Generated profile installed", status="running") + append_live_progress( + job_id, + run_id, + f"Calling Hermes with profile {profile_name} using {HERMES_DEMO_PROVIDER}/{HERMES_DEMO_MODEL}", + status="running", + ) + env = os.environ.copy() + load_env_file(hermes_home / ".env", env) + env["HERMES_HOME"] = str(hermes_home) + env["HERMES_ACCEPT_HOOKS"] = "1" + env["HERMES_INTERACTIVE"] = "0" + cmd = hermes_profile_chat_command(profile_name, message) + try: + proc = run_live_process_with_progress( + job_id, + run_id, + cmd, + cwd=profile_dir, + start_message="Hermes chat process started for generated profile", + heartbeat_message="Waiting for generated profile to answer", + timeout=HERMES_LIVE_TIMEOUT, + env=env, + ) + except FileNotFoundError: + append_live_progress(job_id, run_id, "Hermes CLI was not found on PATH", status="failed", error="Hermes CLI was not found on PATH.") + return + except subprocess.TimeoutExpired as exc: + partial_parts = [] + for part in (exc.stdout, exc.stderr): + if isinstance(part, bytes): + partial_parts.append(part.decode("utf-8", errors="replace")) + elif part: + partial_parts.append(str(part)) + partial = clean_hermes_live_response("\n".join(partial_parts)) + error = f"Hermes live call timed out after {HERMES_LIVE_TIMEOUT}s before the generated profile returned a response." + if partial: + error += f" Partial output: {partial}" + append_live_progress(job_id, run_id, error, status="failed", error=error, response=partial) + return + response = clean_hermes_live_response(proc.stdout or "") + if proc.returncode != 0 or not response: + error = clean_hermes_live_response(proc.stderr or proc.stdout or "Hermes live call failed.") + append_live_progress(job_id, run_id, error, status="failed", error=error, stderr=proc.stderr, stdout=proc.stdout) + return + append_live_progress( + job_id, + run_id, + "Hermes returned a live response from the generated profile", + status="complete", + response=response, + stdout=proc.stdout, + stderr=proc.stderr, + ) + + +def run_job(job_id: str, sentence: str) -> None: + job_dir = JOBS_ROOT / job_id + output = job_dir / "profile" + artifacts = job_dir / "artifacts" + job_dir.mkdir(parents=True, exist_ok=True) + set_job( + job_id, + status="running", + stage_index=0, + progress=["Starting Hermes prompt engineering pass"], + ) + try: + append_progress(job_id, 0, "Reading profile idea and preparing prompt expansion") + enhanced_prompt_path, prompt_status = hermes_expand_prompt(job_id, sentence, job_dir) + append_progress(job_id, 1, prompt_status) + append_progress(job_id, 1, "Prompt pass complete. Preparing deterministic generator input") + pace() + cmd = [ + sys.executable, + str(ROOT / "scripts" / "generate_from_sentence.py"), + "--sentence", + sentence, + "--output", + str(output), + "--artifact-dir", + str(artifacts), + "--force", + "--json", + ] + if enhanced_prompt_path: + cmd.extend(["--profile-prompt-file", str(enhanced_prompt_path)]) + append_progress(job_id, 2, f"Launching profile repository generator with {HERMES_DEMO_PROVIDER}/{HERMES_DEMO_MODEL} defaults") + pace() + generator_env = os.environ.copy() + generator_env["HERMES_PROFILE_MODEL_PROVIDER"] = HERMES_DEMO_PROVIDER + generator_env["HERMES_PROFILE_MODEL_DEFAULT"] = HERMES_DEMO_MODEL + proc = run_process_with_progress( + job_id, + 2, + cmd, + cwd=ROOT, + start_message="Generator process started: writing params, SOUL, manifest, skills, docs, and artifacts", + heartbeat_message="Generator still running: creating profile files and validation assets", + timeout=180, + env=generator_env, + ) + raw = proc.stdout.strip() + payload = json.loads(raw[raw.find("{"):]) if "{" in raw else {} + if proc.returncode != 0 or not payload.get("ok"): + error = payload.get("error") or proc.stderr or proc.stdout or "generation failed" + set_job(job_id, status="failed", error=error, stdout=proc.stdout, stderr=proc.stderr) + return + append_progress(job_id, 3, f"Profile repository created at {payload.get('profile_dir', output)}") + append_progress(job_id, 3, f"Mature prompt written: {Path(payload['prompt_path']).name}") + append_progress(job_id, 3, f"Playable demo written: {Path(payload['demo_html_path']).name}") + append_progress(job_id, 3, "Generated demo artifacts written") + pace() + append_progress(job_id, 4, "Generator validation passed. Preparing artifact metadata") + pace() + zip_path = Path(payload["zip_path"]) if payload.get("zip_path") else None + demo_path = Path(payload["demo_html_path"]) + diagram_path = Path(payload["diagram_path"]) + prompt_path = Path(payload["prompt_path"]) + validation_report_path = Path(payload["validation_report_path"]) + review_path, review_status = hermes_review_generated_profile(job_id, sentence, output) + append_progress(job_id, 5, review_status) + generated_files = generated_file_summary(output) + append_progress(job_id, 5, f"Indexed {len(generated_files)} important generated files for inline inspection") + for item in generated_files: + item["url"] = f"/api/jobs/{job_id}/file/{item['path']}" + append_progress(job_id, 5, f"Preview ready: {item['path']} ({item['role']})") + if zip_path: + append_progress(job_id, 5, f"Download package ready: {zip_path.name}") + result = { + "slug": payload["slug"], + "display_name": payload["display_name"], + "profile_dir": payload["profile_dir"], + "quality_summary": "Validated profile repo", + "quality_checks": [ + prompt_status, + "Hermes profile validation passed.", + "Mature prompt was preserved for review.", + "Playable demo was generated.", + review_status, + "Download zip was packaged from allowlisted profile files.", + ], + "generated_files": generated_files, + "zip_url": f"/api/jobs/{job_id}/artifact/zip" if zip_path else None, + "demo_url": f"/api/jobs/{job_id}/artifact/demo", + "diagram_url": f"/api/jobs/{job_id}/artifact/diagram", + "prompt_url": f"/api/jobs/{job_id}/artifact/prompt", + "validation_url": f"/api/jobs/{job_id}/artifact/validation", + "install_command": f"unzip {payload['slug']}.zip && cd {payload['slug']} && hermes profile install . --name {payload['slug']}-local --yes", + } + final_progress = list((safe_job(job_id) or {}).get("progress") or []) + ["Complete"] + set_job( + job_id, + status="complete", + progress=final_progress, + result=result, + paths={ + "profile": str(output), + "zip": str(zip_path) if zip_path else None, + "demo": str(demo_path), + "diagram": str(diagram_path), + "prompt": str(prompt_path), + "validation": str(validation_report_path), + }, + stdout=proc.stdout, + stderr=proc.stderr, + ) + except Exception as exc: + set_job(job_id, status="failed", error=str(exc)) + + +class Handler(BaseHTTPRequestHandler): + server_version = "HermesProfileDemo/0.1" + + def log_message(self, fmt: str, *args) -> None: + print(f"{self.address_string()} - {fmt % args}") + + def send_json(self, data: dict, status: int = 200) -> None: + body = json.dumps(data, indent=2).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_file(self, path: Path, download_name: str | None = None) -> None: + if not path.exists() or not path.is_file(): + self.send_json({"error": "artifact not found"}, 404) + return + data = path.read_bytes() + content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + if download_name: + self.send_header("Content-Disposition", f"attachment; filename=\"{download_name}\"") + self.end_headers() + self.wfile.write(data) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY: + self.send_json({"error": "request too large"}, 413) + return + if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/live-demo"): + parts = parsed.path.strip("/").split("/") + job_id = parts[2] if len(parts) == 4 else "" + job = safe_job(job_id) + if not job: + self.send_json({"error": "job not found"}, 404) + return + if job.get("status") != "complete": + self.send_json({"error": "generate a profile before running the live demo"}, 409) + return + try: + data = json.loads(self.rfile.read(length).decode("utf-8")) + user_message = str(data.get("message") or "").strip() + if not user_message: + raise ValueError("message is required") + except Exception as exc: + self.send_json({"error": str(exc)}, 400) + return + run_id = uuid.uuid4().hex[:12] + append_live_progress(job_id, run_id, "Queued live Hermes demo call", status="queued", user_message=user_message, created_at=time.time()) + thread = threading.Thread(target=run_live_demo, args=(job_id, run_id, user_message), daemon=True) + thread.start() + self.send_json({"run_id": run_id, "status_url": f"/api/jobs/{job_id}/live-demo/{run_id}"}, 202) + return + if parsed.path != "/api/jobs": + self.send_json({"error": "not found"}, 404) + return + try: + data = json.loads(self.rfile.read(length).decode("utf-8")) + sentence = str(data.get("sentence") or "").strip() + if not sentence: + raise ValueError("sentence is required") + except Exception as exc: + self.send_json({"error": str(exc)}, 400) + return + job_id = uuid.uuid4().hex[:12] + set_job(job_id, status="queued", progress=["Queued"], created_at=time.time(), sentence=sentence) + thread = threading.Thread(target=run_job, args=(job_id, sentence), daemon=True) + thread.start() + self.send_json({"job_id": job_id, "status_url": f"/api/jobs/{job_id}"}, 202) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = unquote(parsed.path) + if path.startswith("/api/jobs/"): + parts = path.strip("/").split("/") + job_id = parts[2] if len(parts) >= 3 else "" + job = safe_job(job_id) + if not job: + self.send_json({"error": "job not found"}, 404) + return + if len(parts) == 3: + self.send_json(public_job(job_id) or {}) + return + if len(parts) == 4 and parts[3] == "events": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + last_payload = None + deadline = time.time() + 300 + while time.time() < deadline: + public = public_job(job_id) + if not public: + break + payload = json.dumps(public) + if payload != last_payload: + self.wfile.write(f"data: {payload}\n\n".encode("utf-8")) + self.wfile.flush() + last_payload = payload + if public.get("status") in {"complete", "failed"}: + break + time.sleep(0.4) + return + if len(parts) == 5 and parts[3] == "live-demo": + run = get_live_run(job_id, parts[4]) + if not run: + self.send_json({"error": "live demo run not found"}, 404) + return + public = dict(run) + public["run_id"] = parts[4] + self.send_json(public) + return + if len(parts) >= 5 and parts[3] == "file": + paths = job.get("paths") or {} + profile = paths.get("profile") + if not profile: + self.send_json({"error": "profile files are not available yet"}, 404) + return + rel = "/".join(parts[4:]) + try: + file_path = safe_profile_file(Path(profile), rel) + data = file_path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + self.send_json({"error": str(exc)}, 404) + return + truncated = len(data) > MAX_FILE_PREVIEW + if truncated: + data = data[:MAX_FILE_PREVIEW] + "\n\n[truncated]" + self.send_json({"path": rel, "content": data, "truncated": truncated}) + return + if len(parts) == 5 and parts[3] == "artifact": + kind = parts[4] + paths = job.get("paths") or {} + artifact = paths.get(kind) + if not artifact: + self.send_json({"error": "artifact not available"}, 404) + return + download = None + if kind == "zip": + slug = (job.get("result") or {}).get("slug", "generated-profile") + download = f"{slug}.zip" + self.send_file(Path(artifact), download) + return + self.send_json({"error": "not found"}, 404) + return + if path in {"/", ""}: + path = "/index.html" + candidate = (STATIC / path.lstrip("/")).resolve() + if not str(candidate).startswith(str(STATIC.resolve())): + self.send_json({"error": "not found"}, 404) + return + self.send_file(candidate) + + +def main() -> int: + print(f"Hermes profile web demo: http://{HOST}:{PORT}") + print(f"Jobs directory: {JOBS_ROOT}") + httpd = ThreadingHTTPServer((HOST, PORT), Handler) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopping") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-demo/static/app.js b/web-demo/static/app.js new file mode 100644 index 0000000..24449cd --- /dev/null +++ b/web-demo/static/app.js @@ -0,0 +1,539 @@ +const appWindow = document.querySelector('#window'); +const promptPanel = document.querySelector('#promptPanel'); +const progressPanel = document.querySelector('#progressPanel'); +const sentence = document.querySelector('#sentence'); +const generate = document.querySelector('#generate'); +const stageList = document.querySelector('#stageList'); +const activityLog = document.querySelector('#activityLog'); +const fullActivityLog = document.querySelector('#fullActivityLog'); +const statusDot = document.querySelector('#statusDot'); +const statusLabel = document.querySelector('#statusLabel'); +const currentActivity = document.querySelector('#currentActivity'); +const headline = document.querySelector('#headline'); +const inputEcho = document.querySelector('#inputEcho'); +const newRun = document.querySelector('#newRun'); +const progressBack = document.querySelector('#progressBack'); +const resultsBack = document.querySelector('#resultsBack'); +const errorBox = document.querySelector('#error'); +const results = document.querySelector('#results'); +const profileTitle = document.querySelector('#profileTitle'); +const profilePath = document.querySelector('#profilePath'); +const installCommand = document.querySelector('#installCommand'); +const qualityPill = document.querySelector('#qualityPill'); +const qualityChecks = document.querySelector('#qualityChecks'); +const generatedFiles = document.querySelector('#generatedFiles'); +const fileEmpty = document.querySelector('#fileEmpty'); +const fileModal = document.querySelector('#fileModal'); +const fileModalBackdrop = document.querySelector('#fileModalBackdrop'); +const fileModalClose = document.querySelector('#fileModalClose'); +const fileModalTitle = document.querySelector('#fileModalTitle'); +const fileModalContent = document.querySelector('#fileModalContent'); +const downloadZip = document.querySelector('#downloadZip'); +const playDemo = document.querySelector('#playDemo'); +const openLiveSession = document.querySelector('#openLiveSession'); +const viewPrompt = document.querySelector('#viewPrompt'); +const viewValidation = document.querySelector('#viewValidation'); +const liveModal = document.querySelector('#liveModal'); +const liveModalBackdrop = document.querySelector('#liveModalBackdrop'); +const liveModalClose = document.querySelector('#liveModalClose'); +const liveComposer = document.querySelector('#liveComposer'); +const livePromptModal = document.querySelector('#livePromptModal'); +const runLiveDemoModal = document.querySelector('#runLiveDemoModal'); +const liveStatusModal = document.querySelector('#liveStatusModal'); +const liveLogModal = document.querySelector('#liveLogModal'); +const liveTranscript = document.querySelector('#liveTranscript'); +const toggleLiveLog = document.querySelector('#toggleLiveLog'); + +const STAGES = [ + { + id: 'prompt', + title: 'Hermes prompt pass', + detail: 'Calling Hermes to expand the sentence into a mature profile prompt.', + panelTitle: 'Prompt engineering', + panelBody: 'The simple sentence becomes a complete agent design brief that is preserved in docs/profile-prompt.md.', + artifact: 'docs/profile-prompt.md' + }, + { + id: 'params', + title: 'Generation params', + detail: 'Writing profile.params.yaml with scope, refusals, toolsets, topics, and env placeholders.', + panelTitle: 'Structured params', + panelBody: 'The design brief is translated into deterministic YAML so the profile can be regenerated and reviewed.', + artifact: 'templates/profile.params.yaml' + }, + { + id: 'repo', + title: 'Profile repository', + detail: 'Creating SOUL.md, distribution.yaml, README, config, scripts, docs, and bundled skills.', + panelTitle: 'Installable repo', + panelBody: 'The backend is assembling a real Hermes profile distribution, not a text mockup.', + artifact: 'SOUL.md, distribution.yaml, skills/' + }, + { + id: 'demo', + title: 'Demo', + detail: 'Rendering a local demo page for this exact profile.', + panelTitle: 'Demo surface', + panelBody: 'A safe, publishable demo is created so viewers can understand how the generated profile behaves.', + artifact: 'demo/index.html' + }, + { + id: 'validation', + title: 'Hermes quality review', + detail: 'Calling Hermes again to review the generated profile and produce demo talking points.', + panelTitle: 'LLM review', + panelBody: 'Hermes inspects the generated files and writes an honest quality review for the demo.', + artifact: 'docs/llm-quality-review.md' + } +]; + +let activeStage = 0; +let stageTimer = null; +let currentStatusUrl = null; +let currentJobId = null; +let jobEvents = null; +let currentState = 'prompt'; + +renderScaffold(); +transitionTo('prompt', { immediate: true }); +sentence.focus(); + +function transitionTo(nextState, options = {}) { + if (!['prompt', 'progress', 'results'].includes(nextState)) return; + const previousState = currentState; + currentState = nextState; + appWindow.dataset.state = nextState; + for (const panel of [promptPanel, progressPanel, results]) { + const isActive = panel.dataset.panel === nextState; + panel.hidden = false; + panel.classList.toggle('is-active', isActive); + panel.classList.toggle('is-exiting', panel.dataset.panel === previousState && !isActive && !options.immediate); + panel.setAttribute('aria-hidden', String(!isActive)); + if (!isActive) { + window.setTimeout(() => { + if (!panel.classList.contains('is-active')) panel.hidden = true; + panel.classList.remove('is-exiting'); + }, options.immediate ? 0 : 360); + } + } + if (nextState === 'prompt') window.setTimeout(() => sentence.focus(), options.immediate ? 0 : 220); +} + +generate.addEventListener('click', startGeneration); +sentence.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + startGeneration(); + } +}); +newRun.addEventListener('click', resetExperience); +progressBack.addEventListener('click', resetExperience); +resultsBack.addEventListener('click', () => transitionTo('progress')); +liveComposer.addEventListener('submit', startLiveDemo); +livePromptModal.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + liveComposer.requestSubmit(); + } +}); +openLiveSession.addEventListener('click', openLiveModal); +toggleLiveLog.addEventListener('click', toggleLiveSessionLog); +liveModalClose.addEventListener('click', closeLiveModal); +liveModalBackdrop.addEventListener('click', closeLiveModal); +fileModalClose.addEventListener('click', closeFileModal); +fileModalBackdrop.addEventListener('click', closeFileModal); +document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !fileModal.hidden) closeFileModal(); + if (event.key === 'Escape' && !liveModal.hidden) closeLiveModal(); +}); + +async function startGeneration() { + const value = normalizeSentence(sentence.value); + if (!value) return; + sentence.value = value; + transitionTo('progress'); + closeFileModal(); + closeLiveModal(); + errorBox.hidden = true; + generate.disabled = true; + inputEcho.textContent = value; + headline.textContent = 'Building your Hermes profile'; + statusLabel.textContent = 'Starting'; + statusDot.className = 'status-dot'; + activeStage = 0; + renderScaffold(); + renderActivity(['Submitting job']); + + try { + const response = await fetch('/api/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sentence: value }) + }); + const created = await response.json(); + if (!response.ok) throw new Error(created.error || 'failed to create job'); + currentStatusUrl = created.status_url; + currentJobId = created.job_id; + await streamJob(created.status_url, created.job_id); + } catch (err) { + showError(err.message || String(err)); + } finally { + generate.disabled = false; + } +} + +async function poll(url) { + for (;;) { + const response = await fetch(url); + const job = await response.json(); + renderJob(job); + if (job.status === 'complete') return; + if (job.status === 'failed') throw new Error(job.error || 'generation failed'); + await new Promise(resolve => setTimeout(resolve, 850)); + } +} + +async function streamJob(statusUrl, jobId) { + if (!window.EventSource) return poll(statusUrl); + return new Promise((resolve, reject) => { + let settled = false; + jobEvents = new EventSource(`/api/jobs/${jobId}/events`); + jobEvents.onmessage = event => { + const job = JSON.parse(event.data); + renderJob(job); + if (job.status === 'complete') { + settled = true; + jobEvents.close(); + resolve(); + } else if (job.status === 'failed') { + settled = true; + jobEvents.close(); + reject(new Error(job.error || 'generation failed')); + } + }; + jobEvents.onerror = () => { + jobEvents.close(); + if (!settled) poll(statusUrl).then(resolve).catch(reject); + }; + }); +} + +function renderJob(job) { + renderActivity(job.progress || []); + if (job.status === 'queued') { + statusLabel.textContent = 'Queued'; + return; + } + if (job.status === 'running') { + statusLabel.textContent = 'Generating'; + const serverStep = typeof job.stage_index === 'number' ? job.stage_index : null; + if (serverStep !== null) activeStage = Math.max(activeStage, Math.min(serverStep, STAGES.length - 1)); + renderScaffold(); + return; + } + if (job.status === 'complete') { + clearInterval(stageTimer); + activeStage = STAGES.length; + statusLabel.textContent = 'Complete'; + statusDot.className = 'status-dot complete'; + headline.textContent = 'Your profile is ready'; + currentJobId = job.job_id || currentJobId; + renderScaffold(); + renderResults(job.result); + return; + } +} + +function tickStages() { + if (activeStage < STAGES.length - 1) { + activeStage += 1; + renderScaffold(); + } +} + +function renderActivity(items) { + const latest = items[items.length - 1] || 'Waiting for work to start.'; + currentActivity.textContent = summarizeProgress(latest, true); + currentActivity.title = latest; + const visibleItems = compactProgress(items).slice(-6); + activityLog.innerHTML = visibleItems.map((item, index, visible) => { + const className = index === visible.length - 1 ? ' class="latest"' : ''; + return `${escapeHtml(item.label)}`; + }).join(''); + fullActivityLog.innerHTML = items.slice(-12).map((item, index, visible) => { + const className = index === visible.length - 1 ? ' class="latest"' : ''; + return `${escapeHtml(item)}`; + }).join(''); + fullActivityLog.scrollTop = fullActivityLog.scrollHeight; +} + +function compactProgress(items) { + const compacted = []; + let lastLabel = ''; + for (const raw of items) { + const label = summarizeProgress(raw, false); + if (label === lastLabel && !/\d+s/.test(label)) continue; + compacted.push({ raw, label }); + lastLabel = label; + } + return compacted; +} + +function summarizeProgress(message, detailed) { + const text = String(message || '').trim(); + const elapsed = text.match(/\((\d+s elapsed)\)/); + if (text.startsWith('Waiting for Hermes LLM response')) { + return elapsed ? `Waiting for Hermes: ${elapsed[1].replace(' elapsed', '')}` : 'Waiting for Hermes'; + } + if (text.startsWith('Hermes LLM call started')) return 'Hermes call started'; + if (text.startsWith('Using Hermes provider')) return text.replace('Using Hermes provider ', 'Using '); + if (text.startsWith('Reading profile idea')) return 'Preparing prompt'; + if (text.startsWith('Starting Hermes prompt')) return 'Starting prompt pass'; + if (text.startsWith('Hermes expanded')) return 'Prompt expanded'; + if (text.startsWith('Prompt pass complete')) return 'Preparing generator'; + if (text.startsWith('Launching profile repository generator')) return 'Launching generator'; + if (text.startsWith('Generator process started')) return 'Writing profile repo'; + if (text.startsWith('Generator still running')) return elapsed ? `Writing files: ${elapsed[1].replace(' elapsed', '')}` : 'Writing files'; + if (text.startsWith('Profile repository created')) return 'Repo created'; + if (text.startsWith('Mature prompt written')) return 'Prompt saved'; + if (text.startsWith('Playable demo written')) return 'Demo saved'; + if (text.startsWith('Generator validation passed')) return 'Validation passed'; + if (text.startsWith('Indexed ')) return text.replace(' important generated files for inline inspection', ' files indexed'); + if (text.startsWith('Preview ready:')) return detailed ? text : 'File previews ready'; + if (text.startsWith('Download package ready')) return 'Zip ready'; + return detailed || text.length <= 42 ? text : `${text.slice(0, 39)}...`; +} + +function renderScaffold() { + stageList.innerHTML = STAGES.map((stage, index) => { + const state = index < activeStage ? 'done' : index === activeStage ? 'active' : 'pending'; + const marker = index < activeStage ? '✓' : String(index + 1).padStart(2, '0'); + return ` +
  • + ${marker} + + ${escapeHtml(stage.title)} + +
  • `; + }).join(''); +} + +function renderResults(result) { + profileTitle.textContent = result.display_name; + profilePath.textContent = result.profile_dir; + installCommand.textContent = result.install_command; + qualityPill.textContent = result.quality_summary || 'Validated'; + qualityChecks.innerHTML = (result.quality_checks || []).map(check => ` +
    + + ${escapeHtml(check)} +
    `).join(''); + const files = result.generated_files || []; + fileEmpty.hidden = files.length > 0; + generatedFiles.innerHTML = files.map(file => ` + `).join(''); + generatedFiles.querySelectorAll('.file-row').forEach(button => { + button.addEventListener('click', () => loadFilePreview(button.dataset.url, button.dataset.path)); + }); + downloadZip.href = result.zip_url; + playDemo.href = result.demo_url; + viewPrompt.href = result.prompt_url; + viewValidation.href = result.validation_url; + openLiveSession.disabled = false; + runLiveDemoModal.disabled = false; + setLiveStatus('Ready to call Hermes with the generated profile.'); + liveLogModal.innerHTML = ''; + transitionTo('results'); +} + +async function startLiveDemo(event) { + if (event) event.preventDefault(); + if (!currentJobId) return; + const message = livePromptModal.value.trim(); + if (!message) return; + appendChatMessage('user', message); + livePromptModal.value = ''; + const pending = appendChatMessage('assistant', 'Hermes is thinking...'); + setLiveRunning(true); + setLiveStatus('Starting live Hermes call'); + renderLiveLog(['Submitting live Hermes request']); + try { + const response = await fetch(`/api/jobs/${currentJobId}/live-demo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }); + const created = await response.json(); + if (!response.ok) throw new Error(created.error || 'failed to start live demo'); + await pollLiveDemo(created.status_url, pending); + } catch (err) { + setLiveStatus('Live demo failed'); + pending.querySelector('.message-body').textContent = err.message || String(err); + pending.classList.add('failed-message'); + } finally { + setLiveRunning(false); + livePromptModal.focus(); + } +} + +async function pollLiveDemo(url, pendingMessage) { + for (;;) { + const response = await fetch(url); + const run = await response.json(); + if (!response.ok) throw new Error(run.error || 'live demo failed'); + renderLiveRun(run, pendingMessage); + if (run.status === 'complete') return; + if (run.status === 'failed') throw new Error(run.error || 'live demo failed'); + await new Promise(resolve => setTimeout(resolve, 650)); + } +} + +function renderLiveRun(run, pendingMessage) { + setLiveStatus(run.status === 'complete' ? 'Hermes response received' : 'Hermes is working'); + renderLiveLog(run.progress || []); + if (run.response) { + pendingMessage.querySelector('.message-body').textContent = run.response; + pendingMessage.classList.remove('pending-message'); + scrollTranscriptToBottom(); + } +} + +function renderLiveLog(items) { + liveLogModal.innerHTML = items.slice(-24).map((item, index, visible) => { + const className = index === visible.length - 1 ? ' class="latest"' : ''; + return `${escapeHtml(item)}`; + }).join(''); + liveLogModal.scrollTop = liveLogModal.scrollHeight; +} + +function setLiveStatus(message) { + liveStatusModal.textContent = message; +} + +function setLiveRunning(isRunning) { + runLiveDemoModal.disabled = isRunning; +} + +function appendChatMessage(role, text) { + const message = document.createElement('div'); + message.className = `message ${role === 'user' ? 'user-message' : 'assistant-message'}${role === 'assistant' && text.endsWith('...') ? ' pending-message' : ''}`; + message.innerHTML = ` +
    ${role === 'user' ? 'You' : 'Hermes'}
    +
    `; + message.querySelector('.message-body').textContent = text; + liveTranscript.appendChild(message); + scrollTranscriptToBottom(); + return message; +} + +function scrollTranscriptToBottom() { + liveTranscript.scrollTop = liveTranscript.scrollHeight; +} + +function toggleLiveSessionLog() { + liveLogModal.hidden = !liveLogModal.hidden; + toggleLiveLog.textContent = liveLogModal.hidden ? 'Show log' : 'Hide log'; +} + +function resetLiveTranscript() { + liveTranscript.innerHTML = ` +
    +
    Hermes
    +
    Generate a profile, then ask the agent anything here. This fullscreen session calls the generated Hermes profile, not the static preview.
    +
    `; +} + +async function loadFilePreview(url, path) { + if (!url) return; + fileModal.hidden = false; + document.body.classList.add('modal-open'); + fileModalTitle.textContent = `Loading ${path}`; + fileModalContent.textContent = ''; + try { + const response = await fetch(url); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || 'failed to load file'); + fileModalTitle.textContent = payload.path + (payload.truncated ? ' (truncated)' : ''); + fileModalContent.textContent = payload.content; + fileModalClose.focus(); + } catch (err) { + fileModalTitle.textContent = path || 'File preview'; + fileModalContent.textContent = err.message || String(err); + } +} + +function closeFileModal() { + fileModal.hidden = true; + document.body.classList.remove('modal-open'); +} + +function openLiveModal() { + liveModal.hidden = false; + document.body.classList.add('modal-open'); + livePromptModal.focus(); +} + +function closeLiveModal() { + liveModal.hidden = true; + document.body.classList.remove('modal-open'); +} + +function resetExperience() { + clearInterval(stageTimer); + if (jobEvents) jobEvents.close(); + jobEvents = null; + currentStatusUrl = null; + currentJobId = null; + activeStage = 0; + transitionTo('prompt'); + closeFileModal(); + closeLiveModal(); + fileModalContent.textContent = ''; + liveLogModal.innerHTML = ''; + resetLiveTranscript(); + setLiveStatus('Ready after generation completes.'); + setLiveRunning(false); + errorBox.hidden = true; + generate.disabled = false; + statusDot.className = 'status-dot'; + statusLabel.textContent = 'Waiting'; + currentActivity.textContent = 'No job running.'; + fullActivityLog.innerHTML = ''; + headline.textContent = 'Building your Hermes profile'; + renderScaffold(); + renderActivity([]); + setTimeout(() => { + sentence.focus(); + sentence.select(); + }, 120); +} + +function showError(message) { + clearInterval(stageTimer); + statusLabel.textContent = 'Failed'; + statusDot.className = 'status-dot failed'; + headline.textContent = 'Generation failed'; + errorBox.textContent = message; + errorBox.hidden = false; +} + +function normalizeSentence(value) { + return value.trim(); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`'); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>'\"]/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + })[char]); +} diff --git a/web-demo/static/index.html b/web-demo/static/index.html new file mode 100644 index 0000000..e210251 --- /dev/null +++ b/web-demo/static/index.html @@ -0,0 +1,137 @@ + + + + + + Hermes Profile Generator Demo + + + +
    +
    +
    + + +
    +
    + +
    +
    +
    +

    Building

    +

    Building your Hermes profile

    +
    +
    + +
    +
    + +
    +
    +
    + + Waiting +
    +
    No job running.
    +
    +
      +
      +
      +
      +
      Live log
      +
      +
      +
      +
      + +
      + Input +

      +
      + +
      + + + + + + +
      + + + diff --git a/web-demo/static/style.css b/web-demo/static/style.css new file mode 100644 index 0000000..bbe6c7b --- /dev/null +++ b/web-demo/static/style.css @@ -0,0 +1,662 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-feature-settings: "cv01", "ss03"; + --bg: #08090a; + --panel: rgba(255, 255, 255, .032); + --border: rgba(255, 255, 255, .08); + --border-soft: rgba(255, 255, 255, .05); + --text: #f7f8f8; + --muted: #8a8f98; + --soft: #d0d6e0; + --accent: #7170ff; + --accent-strong: #5e6ad2; + --green: #27a644; + --amber: #f59e0b; + --red: #ef4444; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; overflow: hidden; } +body { + margin: 0; + background: + radial-gradient(circle at 50% -10%, rgba(113,112,255,.16), transparent 42rem), + linear-gradient(180deg, #0b0c10 0%, var(--bg) 100%); + color: var(--text); +} +button, textarea, a { font: inherit; } +button { cursor: pointer; } +button:disabled { opacity: .6; cursor: wait; } + +.window { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + overflow: hidden; + background: var(--bg); +} + +.app-panel { + position: absolute; + inset: 0; + min-width: 0; + min-height: 0; + width: 100vw; + height: 100vh; + overflow: hidden; + opacity: 0; + transform: translateY(18px) scale(.985); + pointer-events: none; + transition: opacity .34s cubic-bezier(.2,.8,.2,1), transform .34s cubic-bezier(.2,.8,.2,1); +} +.app-panel.is-active { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + z-index: 2; +} +.app-panel.is-exiting { + opacity: 0; + transform: translateY(-16px) scale(.99); + z-index: 1; +} +.app-panel[hidden] { display: none; } + +.prompt-panel { + display: grid; + place-items: center; + padding: clamp(18px, 4vw, 48px); +} +.composer-shell { + width: min(880px, calc(100vw - 36px)); + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255,255,255,.025); + box-shadow: 0 0 0 1px rgba(0,0,0,.25), 0 30px 100px rgba(0,0,0,.38); + overflow: hidden; + backdrop-filter: blur(18px); +} +textarea { + width: 100%; + resize: none; + border: 0; + outline: 0; + background: transparent; + color: var(--text); +} +#sentence { + min-height: 210px; + padding: 28px 30px 18px; + font-size: clamp(22px, 3vw, 34px); + font-weight: 400; + line-height: 1.22; + letter-spacing: -.45px; +} +.composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + color: var(--muted); + font-size: 12px; +} +#generate, .ghost { + border: 1px solid rgba(255,255,255,.1); + border-radius: 999px; + padding: 9px 13px; + background: rgba(255,255,255,.04); + color: var(--text); + font-weight: 510; +} +#generate { background: var(--accent-strong); border-color: rgba(255,255,255,.14); } +.ghost { background: transparent; color: var(--soft); } + +.panel { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--panel); + box-shadow: inset 0 1px 0 rgba(255,255,255,.03), 0 24px 80px rgba(0,0,0,.22); +} +.panel-header { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} +.panel-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.overline, .sentence-card > span, .file-reader > span, .results-header span { + margin: 0 0 6px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .12em; + font-size: 10px; + font-weight: 590; +} +h1, h2, p { margin: 0; } +h1 { font-size: clamp(24px, 4vw, 44px); line-height: 1; letter-spacing: -1.2px; font-weight: 520; } +h2 { font-size: 18px; letter-spacing: -.25px; font-weight: 510; } + +.progress-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + gap: 14px; + padding: clamp(16px, 3vw, 32px); +} +.progress-hub { + min-width: 0; + min-height: 0; + overflow: hidden; + padding: 16px; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; +} +.progress-topline { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + align-items: center; + gap: 14px; +} +.brand-row { + display: flex; + align-items: center; + gap: 9px; + color: var(--soft); + font-size: 13px; + font-weight: 510; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 24px rgba(245,158,11,.7); +} +.status-dot.complete { background: var(--green); box-shadow: 0 0 24px rgba(39,166,68,.7); } +.status-dot.failed { background: var(--red); box-shadow: 0 0 24px rgba(239,68,68,.7); } +.current-activity { + min-width: 0; + min-height: 38px; + padding: 9px 11px; + border: 1px solid rgba(113,112,255,.24); + border-radius: 12px; + background: rgba(113,112,255,.07); + color: var(--text); + display: flex; + align-items: center; + font-size: 13px; + line-height: 1.35; + overflow: hidden; +} +.stage-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + overflow: hidden; +} +.stage { + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 7px; + border: 1px solid transparent; + border-radius: 12px; + color: var(--muted); + transition: all .3s ease; +} +.stage.active, .stage.done { + color: var(--text); + background: rgba(255,255,255,.03); + border-color: var(--border-soft); +} +.stage-index { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 21px; + height: 21px; + border-radius: 50%; + background: rgba(255,255,255,.05); + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.stage.done .stage-index { background: rgba(39,166,68,.2); color: #86efac; } +.stage.active .stage-index { background: rgba(113,112,255,.22); color: #c4b5fd; } +.stage-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 510; +} +.progress-feedback { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, .9fr) minmax(320px, 1.1fr); + gap: 10px; + overflow: hidden; +} +.activity-log { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + color: var(--muted); + font-size: 11px; + line-height: 1.4; + overflow: hidden; +} +.activity-log div { + min-width: 0; + min-height: 42px; + padding: 7px 8px; + border-radius: 9px; + background: rgba(255,255,255,.018); + display: flex; + align-items: center; + overflow: hidden; + overflow-wrap: anywhere; +} +.activity-log div.latest { + color: var(--text); + background: rgba(113,112,255,.08); + border: 1px solid rgba(113,112,255,.18); +} +.full-activity-log { + min-width: 0; + min-height: 0; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: rgba(0,0,0,.16); + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} +.log-title { + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + color: var(--muted); + text-transform: uppercase; + letter-spacing: .12em; + font-size: 9px; + font-weight: 590; +} +.log-lines { + overflow: auto; + padding: 8px 10px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.45; +} +.log-lines div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 1px 0; +} +.log-lines div.latest { color: var(--soft); } +.sentence-card { + padding: 12px 14px; + color: var(--soft); + min-height: 0; +} +#error { + margin: 0; + white-space: pre-wrap; + color: #fecaca; + background: rgba(127,29,29,.25); + border: 1px solid rgba(239,68,68,.28); + padding: 12px; + border-radius: 14px; + max-height: 150px; + overflow: auto; +} + +.results-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + padding: clamp(16px, 3vw, 32px); +} +.results-topbar { + align-items: center; +} +#profilePath { color: var(--muted); margin-top: 7px; font-size: 12px; word-break: break-all; } +.results-content { + min-width: 0; + min-height: 0; + overflow: hidden; + display: grid; +} +.file-reader { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 16px; +} +.results-summary-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + margin-bottom: 12px; +} +.quality-pill { + border: 1px solid rgba(39,166,68,.3); + border-radius: 999px; + padding: 7px 10px; + color: #bbf7d0; + background: rgba(39,166,68,.12); + font-size: 12px; + font-weight: 510; + white-space: nowrap; +} +#installCommand { + display: block; + white-space: pre-wrap; + padding: 10px; + border-radius: 12px; + background: #030712; + border: 1px solid var(--border-soft); + color: #c4b5fd; + font-size: 11px; + line-height: 1.35; +} +.quality-list { + display: grid; + gap: 6px; + margin: 8px 0 10px; +} +.quality-check { + display: grid; + grid-template-columns: 20px 1fr; + gap: 8px; + align-items: start; + padding: 7px 8px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: rgba(255,255,255,.02); + color: var(--soft); + font-size: 12px; + line-height: 1.35; +} +.quality-check b { color: #86efac; font-weight: 510; } +.file-help { + margin: 0 0 8px; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} +.file-empty { + padding: 18px 14px; + border: 1px dashed rgba(255,255,255,.1); + border-radius: 12px; + background: rgba(255,255,255,.018); + color: var(--muted); + font-size: 12px; + line-height: 1.4; + text-align: center; +} +.file-empty[hidden] { display: none; } +.file-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 7px; + margin: 0 0 12px; +} +.file-row { + width: 100%; + border: 0; + cursor: pointer; + display: flex; + justify-content: space-between; + gap: 12px; + padding: 9px 10px; + border-radius: 10px; + background: rgba(255,255,255,.022); + color: var(--soft); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.file-row:hover { background: rgba(113,112,255,.09); } +.file-row small { color: var(--muted); font-family: inherit; } +.artifact-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.artifact, .ghost-link { + min-height: 40px; + display: grid; + place-items: center; + text-align: center; + padding: 8px 10px; + text-decoration: none; + color: var(--text); + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255,255,255,.035); + font-size: 12px; + font-weight: 510; +} +.artifact.primary { background: var(--accent-strong); border-color: rgba(255,255,255,.14); } +.artifact:hover, .ghost-link:hover { border-color: rgba(113,112,255,.55); background: rgba(113,112,255,.08); } + +.file-modal, .live-modal { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: clamp(18px, 3vw, 42px); +} +.file-modal[hidden], .live-modal[hidden] { display: none; } +.file-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(3,7,18,.74); + backdrop-filter: blur(18px); +} +.file-modal-shell { + position: relative; + z-index: 1; + width: min(1280px, 96vw); + height: min(860px, 92vh); + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + background: rgba(8,9,10,.94); + box-shadow: 0 40px 160px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.04); +} +.file-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid var(--border-soft); +} +.file-modal-header span { + display: block; + margin: 0 0 6px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .12em; + font-size: 10px; + font-weight: 590; +} +#fileModalTitle { font-size: 18px; line-height: 1.2; word-break: break-all; } +#fileModalContent { + margin: 0; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 18px; + background: #030712; + color: #dbeafe; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} +.live-modal { padding: 0; } +.live-modal-shell { + position: relative; + z-index: 1; + width: 100vw; + height: 100vh; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border-radius: 0; + border: 0; + background: #08090a; +} +.live-session-body { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + background: + radial-gradient(circle at 50% -12%, rgba(113,112,255,.12), transparent 36rem), + #08090a; +} +.live-session-status { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 18px; + border-bottom: 1px solid var(--border-soft); + color: var(--muted); + font-size: 12px; +} +.live-session-status button { + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 10px; + background: rgba(255,255,255,.035); + color: var(--soft); + font-size: 12px; +} +.live-session-log { + max-height: 120px; + overflow: auto; + padding: 8px 18px; + border-bottom: 1px solid var(--border-soft); + background: rgba(0,0,0,.22); + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.45; +} +.live-session-log[hidden] { display: none; } +.live-session-log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.live-session-log div.latest { color: var(--soft); } +.live-transcript { + min-width: 0; + min-height: 0; + width: min(980px, calc(100vw - 36px)); + justify-self: center; + overflow: auto; + padding: 28px 0 22px; + display: flex; + flex-direction: column; + gap: 16px; +} +.message { max-width: min(760px, 86%); display: grid; gap: 6px; } +.user-message { align-self: flex-end; } +.assistant-message { align-self: flex-start; } +.message-role { + color: var(--muted); + font-size: 11px; + font-weight: 590; + letter-spacing: .08em; + text-transform: uppercase; +} +.user-message .message-role { text-align: right; } +.message-body { + padding: 14px 16px; + border: 1px solid var(--border-soft); + border-radius: 18px; + background: rgba(255,255,255,.035); + color: var(--soft); + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.user-message .message-body { background: rgba(113,112,255,.14); border-color: rgba(113,112,255,.28); color: var(--text); } +.pending-message .message-body { color: var(--muted); animation: pulse 1.2s ease-in-out infinite; } +.failed-message .message-body { color: #fecaca; border-color: rgba(239,68,68,.28); background: rgba(127,29,29,.22); } +.live-composer { + width: min(980px, calc(100vw - 36px)); + justify-self: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 14px 0 18px; + border-top: 1px solid var(--border-soft); +} +#livePromptModal { + min-height: 58px; + max-height: 160px; + resize: vertical; + border: 1px solid var(--border); + border-radius: 16px; + background: #030712; + color: var(--text); + padding: 13px 14px; + font-size: 14px; + line-height: 1.45; +} +#runLiveDemoModal { + align-self: end; + min-width: 92px; + border: 1px solid rgba(255,255,255,.14); + border-radius: 16px; + padding: 14px 18px; + background: var(--accent-strong); + color: var(--text); + font-weight: 590; +} + +@keyframes pulse { 0%, 100% { opacity: .58; } 50% { opacity: 1; } } + +@media (max-width: 980px) { + .panel-header, .results-topbar { align-items: stretch; flex-direction: column; } + .panel-actions { justify-content: flex-start; } + .progress-feedback { grid-template-columns: 1fr; } + .stage-list { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .stage-title { display: none; } + .artifact-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .results-summary-row { grid-template-columns: 1fr; } +}