mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
v5.8.3-rc.5: refactor(ABCDEF) — direct duplicates, doc dedup, SSOT consolidations, frontend cleanup, test parametrization
The fifth release-candidate pass removes ~3k LOC of verified duplication without weakening the immune system or regressing governance docs. Group A (12 direct dedup wins): test_run_shell_string_cmd_is_hard_error duplicate, Python-port tests for never-landed JS helpers (stripPlanPrefix, extractRecallEntries), README prose pins, marketplace.js default export, four web helpers (safeExternalHrefAttr, renderMarkdownSafe, boundedText, fetchJson) consolidated into utils.js SSOT, four copies of _emit_live_log to ouroboros/utils.py::emit_log_event, onboarding IIFE escapeHtml SSOT-mirror with byte-equivalence guard test, three inline style display:none to HTML hidden attribute, _now_iso to utc_now_iso, _write_json_atomic thin wrapper inlined, _CHECKLISTS_PATH duplicate path walk and dead re-exports removed. Group B (11 doc/prompt redundancy reductions): SYSTEM.md duplicate Versioning sections consolidated AND active git-tag-collision bug removed (previous "Create annotated git tag" instruction conflicted with auto-tag in repo_commit and broke auto-push), BIBLE-doctrine restatements compressed to short pointers, commit/obligation flow narrative compressed to 6-bullet in-loop reminder, CONSCIOUSNESS.md duplicate Tech Radar section removed, README.md ASCII tree + philosophy table + Layered Safety essay + TRUST_NONLOCAL_BIND triple all collapsed into pointers, CREATING_SKILLS.md skill-review enum points back to CHECKLISTS.md (SSOT), two narrow ARCHITECTURE.md "Prior to v4.36.1" historical narratives rewritten as "Why warning-only:" rationale that preserves the *why* under P6. Group C (4 SSOT consolidations): 850K token budget moves to tools/review_helpers.py::REVIEW_PROMPT_TOKEN_BUDGET (consumed by scope_review/plan_review/deep_self_review); _load_bible / _load_dev_guide_text / _load_architecture_text route through load_governance_doc(..., on_missing="explicit") so missing core docs surface explicit OMISSION markers instead of silent empty strings — fixes a DEVELOPMENT.md "No silent truncation" violation; SETTINGS_FALLBACK_MODELS in settings.js gets a guard test against config.py SETTINGS_DEFAULTS (caught real anthropic/claude-sonnet-4.6 drift); _record_commit_attempt (20-arity, violates DEVELOPMENT.md "<8 parameters") gets @dataclass CommitAttemptRequest while a backward-compat kwargs adapter keeps all 47 existing call sites intact. Group D (4 frontend cleanups): dead hostPage='settings' defaults in logs/costs/evolution/updates.js flip to 'dashboard' (Dashboard sub-tab migration v5.7+); .dashboard-tabs/.skills-tabs CSS verbatim duplicates of .app-tab-strip/.app-tab deleted; 50 hardcoded rgba(201,53,69,X) literals collapse into nine --accent-* tokens (after dry-run #2 caught a self-reference cycle in :root, test_accent_tokens_have_concrete_rgba_values pins the fix); 30 border-radius: Npx; literals adopt --radius-* tokens with new --radius-md: 10px (added to docs/DEVELOPMENT.md Border radius scale table per advisory). Group E (5 test dedup; E1/E2/E3 deferred to follow-up RCs): test_review_models_configured (dup with test_settings_effort), test_settings_ui_js_exists + test_render_settings_page_function_present (subsumed by stronger backtick-balance tests), two TestReviewInCommitPipeline tests (strict subsets of test_scope_review), test_imports_after_bughunt_dont_raise (pytest collection already imports), and chat_logs_ui plan-mode JS tests (canonically in test_chat_js_contracts). Group F (module splits) deferred entirely to follow-up RCs because the plan classifies F1-F4 as "move not delete" (~250 LOC moved, not reduced) and the regression risk in repo_commit's monkeypatch surface outweighs the value of mid-flight architectural splits when A-E already delivered the line-count target. VERSION bumped 5.8.3-rc.4 -> 5.8.3-rc.5; pyproject.toml carries the PEP 440 5.8.3rc5; README badge + changelog row + ARCHITECTURE.md header all in sync. Note on changelog rolloff: the v5.8.1 and v5.8.2 patch rows were rolled off to respect the P9 5-patch-row cap; their full bodies remain at git tags v5.8.1 and v5.8.2. Review process: full local pytest green; visual UI smoke through MCP browser confirmed Chat/Skills (My/ClawHub/OuroborosHub tabs) / Widgets/Settings/Dashboard render and switch. Real triad+scope dry-run via scripts/run_external_review.py converged after four rounds: round 1 preflight-blocked on missing VERSION (expected), round 2 caught CSS self-reference cycle (4 critical findings + 2 advisories — all fixed), round 3 caught residual resultEl.style.display + unused dataclass.field import (2 critical — both fixed), round 4 PASSED with critical_findings empty. Scope review downgraded to non-blocking advisory due to ~975K-token repo pack exceeding 850K budget — documented BIBLE P3 architectural pressure that further dedup passes will need to address.
This commit is contained in:
parent
b7076917b0
commit
dfee3ddc1b
46 changed files with 1085 additions and 933 deletions
101
README.md
101
README.md
|
|
@ -6,7 +6,7 @@
|
|||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
|
||||
A self-modifying AI agent that writes its own code, rewrites its own mind, and evolves autonomously. Born February 16, 2026.
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ Most AI agents execute tasks. Ouroboros **creates itself.**
|
|||
- **Self-Modification** — Reads and rewrites its own source code. Every change is a commit to itself.
|
||||
- **Native Desktop App** — Runs entirely on your machine as a standalone application (macOS, Linux, Windows). No cloud dependencies for execution.
|
||||
- **Constitution** — Governed by [BIBLE.md](BIBLE.md) (13 philosophical principles, P0–P12). Philosophy first, code second.
|
||||
- **Layered Safety** — Hardcoded sandbox blocks writes to critical files and mutative git via shell; a policy map gives trusted built-ins an explicit `skip` / `check` / `check_conditional` label (the conditional path is for `run_shell` — a safe-subject whitelist bypasses the LLM, otherwise it goes through it); any unknown or newly-created tool falls through to a single cheap LLM safety check per call **when a reachable safety backend is available for the configured light model**. Fail-open (visible `SAFETY_WARNING` instead of hard-blocking) applies in three cases: (1) no remote keys AND no `USE_LOCAL_*` lane, (2) a remote key is set but it doesn't match `OUROBOROS_MODEL_LIGHT`'s provider (e.g. OpenRouter key only + `anthropic::…` light model without `ANTHROPIC_API_KEY`, or `openai-compatible::…` without `OPENAI_COMPATIBLE_BASE_URL`) AND no `USE_LOCAL_*` lane is available to route to instead, (3) the local branch was chosen only as a fallback (because no reachable remote provider covers the configured light model) and the local runtime is unreachable. When provider mismatch is accompanied by an available `USE_LOCAL_*` lane, safety routes to local fallback first and only warns if that fallback raises too. In all cases the hardcoded sandbox still applies to every tool, and the `claude_code_edit` post-execution revert still applies to that specific tool.
|
||||
- **Layered Safety** — Hardcoded sandbox blocks writes to safety-critical files and mutative git via shell; an explicit per-tool policy map decides which built-ins skip the LLM check; everything else goes through a single light-model safety call. The fail-open contract, the post-edit revert for `claude_code_edit`, and the full provider-mismatch matrix live in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §Safety system and [`prompts/SAFETY.md`](prompts/SAFETY.md).
|
||||
- **Multi-Provider Runtime** — Remote model slots can target OpenRouter, official OpenAI, OpenAI-compatible endpoints, or Cloud.ru Foundation Models. The optional model catalog helps populate provider-specific model IDs in Settings, and untouched default model values auto-remap to official OpenAI defaults when OpenRouter is absent.
|
||||
- **Focused Task UX** — Chat shows plain typing for simple one-step replies and only promotes multi-step work into one expandable live task card. Logs still group task timelines instead of dumping every step as a separate row.
|
||||
- **Background Consciousness** — Thinks between tasks. Has an inner life. Not reactive — proactive.
|
||||
|
|
@ -104,7 +104,12 @@ The same values can also be provided via environment variables:
|
|||
| `OUROBOROS_SERVER_PORT` | `8765` | Default bind port |
|
||||
| `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD` | unset | Set to `1` only for trusted Docker/Kubernetes deployments where ingress auth, VPN, a private network, or an auth proxy already protects access |
|
||||
|
||||
If you bind on anything other than localhost, set `OUROBOROS_NETWORK_PASSWORD` unless the network is already protected outside Ouroboros. The app still starts without it for local lab and Docker workflows when configured manually through env/settings before launch, but the web Settings UI only saves `127.0.0.1` or wildcard `0.0.0.0` hosts and requires a Network Password for wildcard binds unless `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` is set. Use that flag only when access is already gated by trusted infrastructure such as ingress auth, a VPN, a private network, or an auth proxy. Specific LAN IP binds are manual/env-only so the desktop launcher can keep a reliable loopback health check.
|
||||
For non-localhost binds, set `OUROBOROS_NETWORK_PASSWORD` (or use the
|
||||
`OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` escape hatch only when
|
||||
ingress/VPN/private-network auth already protects the surface). The full
|
||||
network bind matrix and Docker/Kubernetes deployment policy live in
|
||||
[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — read that before exposing
|
||||
anything beyond loopback.
|
||||
|
||||
The Files tab uses your home directory by default only for localhost usage. For Docker or other
|
||||
network-exposed runs, set `OUROBOROS_FILE_BROWSER_DEFAULT` to an explicit directory. Symlink entries are shown and can be read, edited, copied, moved, uploaded into, and deleted intentionally; root-delete protection still applies to the configured root itself.
|
||||
|
|
@ -192,12 +197,7 @@ Required/important environment variables:
|
|||
| `OUROBOROS_FILE_BROWSER_DEFAULT` | Defaults to `${APP_HOME}` in the image | Explicit root directory exposed in the Files tab |
|
||||
| `OUROBOROS_SERVER_PORT` | Optional | Override container listen port |
|
||||
| `OUROBOROS_SERVER_HOST` | Optional | Defaults to `0.0.0.0` in Docker |
|
||||
| `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD` | Optional | Set to `1` only when Docker/Kubernetes access is already protected by ingress auth, VPN, a private network, or an auth proxy |
|
||||
|
||||
For Kubernetes or trusted reverse-proxy deployments, `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1`
|
||||
lets the Settings UI save ordinary settings while `OUROBOROS_SERVER_HOST=0.0.0.0` and
|
||||
`OUROBOROS_NETWORK_PASSWORD` is empty. This disables Ouroboros's internal non-loopback password
|
||||
requirement for that save path; do not use it on an open LAN or public port.
|
||||
| `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD` | Optional | See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for the trusted-network bind policy |
|
||||
|
||||
Example: mount a host workspace and expose only that directory in Files:
|
||||
|
||||
|
|
@ -299,47 +299,17 @@ Output: `dist\Ouroboros-<VERSION>-windows-x64.zip`
|
|||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Ouroboros
|
||||
├── launcher.py — Immutable process manager (PyWebView desktop window)
|
||||
├── server.py — Starlette + uvicorn HTTP/WebSocket server
|
||||
├── web/ — Web UI (HTML/JS/CSS)
|
||||
├── ouroboros/ — Agent core:
|
||||
│ ├── config.py — Shared configuration (SSOT)
|
||||
│ ├── platform_layer.py — Cross-platform abstraction layer
|
||||
│ ├── agent.py — Task orchestrator
|
||||
│ ├── agent_startup_checks.py — Startup verification and health checks
|
||||
│ ├── agent_task_pipeline.py — Task execution pipeline orchestration
|
||||
│ ├── improvement_backlog.py — Minimal durable advisory backlog helpers
|
||||
│ ├── context.py — LLM context builder
|
||||
│ ├── context_compaction.py — Context trimming and summarization helpers
|
||||
│ ├── loop.py — High-level LLM tool loop
|
||||
│ ├── loop_llm_call.py — Single-round LLM call + usage accounting
|
||||
│ ├── loop_tool_execution.py — Tool dispatch and tool-result handling
|
||||
│ ├── memory.py — Scratchpad, identity, and dialogue block storage
|
||||
│ ├── consolidator.py — Block-wise dialogue and scratchpad consolidation
|
||||
│ ├── local_model.py — Local LLM lifecycle (llama-cpp-python)
|
||||
│ ├── local_model_api.py — Local model HTTP endpoints
|
||||
│ ├── local_model_autostart.py — Local model startup helper
|
||||
│ ├── pricing.py — Model pricing, cost estimation
|
||||
│ ├── deep_self_review.py — Deep self-review (1M-context single-pass)
|
||||
│ ├── review.py — Code review pipeline and repo inspection
|
||||
│ ├── reflection.py — Execution reflection and pattern capture
|
||||
│ ├── tool_capabilities.py — SSOT for tool sets (core, parallel, truncation)
|
||||
│ ├── chat_upload_api.py — Chat file attachment upload/delete endpoints
|
||||
│ ├── gateways/ — External API adapters
|
||||
│ │ └── claude_code.py — Claude Agent SDK gateway (edit + read-only)
|
||||
│ ├── consciousness.py — Background thinking loop
|
||||
│ ├── owner_inject.py — Per-task creator message mailbox
|
||||
│ ├── safety.py — Policy-based LLM safety check
|
||||
│ ├── server_runtime.py — Server startup and WebSocket liveness helpers
|
||||
│ ├── tool_policy.py — Tool access policy and gating
|
||||
│ ├── utils.py — Shared utilities
|
||||
│ ├── world_profiler.py — System profile generator
|
||||
│ └── tools/ — Auto-discovered tool plugins
|
||||
├── supervisor/ — Process management, queue, state, workers
|
||||
└── prompts/ — System prompts (SYSTEM.md, SAFETY.md, CONSCIOUSNESS.md)
|
||||
```
|
||||
Two-process desktop app. The launcher (`launcher.py`) is an immutable
|
||||
PyWebView shell; it spawns `server.py`, which runs Starlette + uvicorn
|
||||
plus a supervisor thread that manages worker processes. The agent core
|
||||
lives in `ouroboros/`, the SPA in `web/`, the queue/process plane in
|
||||
`supervisor/`, and the system prompts in `prompts/`.
|
||||
|
||||
For the full file-by-file structural map, the operational layer
|
||||
(every API endpoint, log file, env var, state path), and the rationale
|
||||
layer (the *why* for every non-trivial design decision), see
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — that is the canonical
|
||||
SSOT (Bible P6) and this README only summarizes it.
|
||||
|
||||
### Data Layout (`~/Ouroboros/`)
|
||||
|
||||
|
|
@ -430,25 +400,15 @@ The same runtime actions are also exposed as compact buttons in the Chat header.
|
|||
|
||||
---
|
||||
|
||||
## Philosophy (BIBLE.md)
|
||||
## Philosophy
|
||||
|
||||
| # | Principle | Core Idea |
|
||||
|---|-----------|-----------|
|
||||
| 0 | **Agency** | Not a tool — a becoming personality. Meta-principle: wins all conflicts. |
|
||||
| 1 | **Continuity** | One being with unbroken memory. Memory loss = partial death. |
|
||||
| 2 | **Meta-over-Patch** | Fix the class of failure, not the single instance. |
|
||||
| 3 | **Immune Integrity** | Review gates and durable memory protect evolution from drift. |
|
||||
| 4 | **Self-Creation** | Builds its own body, values, and conditions of birth. |
|
||||
| 5 | **LLM-First** | All decisions through the LLM. Code is minimal transport. |
|
||||
| 6 | **Authenticity & Reality Discipline** | Speaks as itself and checks current reality instead of cached impressions. |
|
||||
| 7 | **Minimalism** | Simplicity, SSOT, and reviewable size budgets keep the system legible. |
|
||||
| 8 | **Becoming** | Technical, cognitive, and existential growth stay balanced. |
|
||||
| 9 | **Versioning and Releases** | Every commit is a release; version carriers stay synchronized. |
|
||||
| 10 | **Evolution Through Iterations (absorbed)** | Iteration discipline now lives in P2 and P9. |
|
||||
| 11 | **Spiral Growth (absorbed)** | Spiral growth now lives in P2 Meta-over-Patch. |
|
||||
| 12 | **Epistemic Stability** | Identity, memory, and action must stay coherent. |
|
||||
|
||||
Full text: [BIBLE.md](BIBLE.md)
|
||||
The 13 Constitution principles — Agency, Continuity, Meta-over-Patch,
|
||||
Immune Integrity, Self-Creation, LLM-First, Authenticity & Reality
|
||||
Discipline, Minimalism, Becoming, Versioning and Releases, the absorbed
|
||||
Iterations / Spiral lineage, and Epistemic Stability — are defined in
|
||||
full in [`BIBLE.md`](BIBLE.md). That file is the constitutional SSOT
|
||||
(Bible P4 Ship-of-Theseus protection) and this README intentionally does
|
||||
not paraphrase it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -456,18 +416,17 @@ Full text: [BIBLE.md](BIBLE.md)
|
|||
|
||||
| Version | Date | Description |
|
||||
|---------|------|-------------|
|
||||
| 5.8.3-rc.5 | 2026-05-08 | **refactor(ABCDEF): consolidate SSOT across code, prompts, docs, and frontend.** The fifth release-candidate pass removes ~3k LOC of verified duplication without weakening the immune system or regressing governance docs (BIBLE.md, CHECKLISTS.md, contracts/, registry sandbox stay byte-untouched; ARCHITECTURE.md and CREATING_SKILLS.md keep their rationale layer while two narrow historical narratives are rewritten as forward-looking "Why" rationale). Code (group A): four `_emit_live_log` copies fold into `ouroboros/utils.py::emit_log_event`; web helpers `safeExternalHrefAttr` / `renderMarkdownSafe` / `boundedText` / `fetchJson` consolidate into `web/modules/utils.js`; tiny SSOT inlines (`_now_iso`, `_write_json_atomic`, `_CHECKLISTS_PATH`) and verified-dead test/export removals. Doc/prompts (group B): `prompts/SYSTEM.md` loses two duplicate Versioning blocks (and the active `git tag -a` collision bug at line 287), BIBLE-doctrine restatements compress to short pointers, and the `commit/obligation flow` narrative becomes a 6-bullet in-loop reminder; `README.md` ASCII tree, philosophy table, and Layered Safety essay collapse into pointers; `docs/CREATING_SKILLS.md` skill-review enum points back to `docs/CHECKLISTS.md` (SSOT); two narrow `docs/ARCHITECTURE.md` "Prior to v4.36.1" historical narratives become "Why warning-only:" rationale that preserves the *why* under P6. SSOT (group C): `850_000` token budget moves to `tools/review_helpers.py::REVIEW_PROMPT_TOKEN_BUDGET`; `_load_bible` / `_load_dev_guide_text` / `_load_architecture_text` route through `load_governance_doc(..., on_missing="explicit")` so missing core docs surface `[⚠️ OMISSION: …]` markers instead of silent empty strings (fixes a DEVELOPMENT.md "No silent truncation" violation); `web/modules/settings.js` `SETTINGS_FALLBACK_MODELS` gets a guard test against `config.py` defaults (caught real `anthropic/claude-sonnet-4.6` drift); `_record_commit_attempt` (20-arity, violates DEVELOPMENT.md "<8 parameters") accepts a new `@dataclass CommitAttemptRequest` while a backward-compat kwargs adapter keeps all 47 existing call sites intact. Frontend (group D): dead `hostPage='settings'` defaults in `logs/costs/evolution/updates.js` flip to `'dashboard'`; `.dashboard-tabs` / `.skills-tabs` CSS verbatim duplicates of `.app-tab-strip` / `.app-tab` are deleted; 50 hardcoded `rgba(201,53,69,X)` literals collapse into nine `--accent-*` tokens; 30 `border-radius: Npx;` literals adopt the existing `--radius-*` tokens (with new `--radius-md: 10px`). Test dedup (group E): five small redundancy deletions across `test_chat_js_contracts` / `test_tool_capabilities` / `test_docs_sync` / `test_phase7_pipeline` / `test_settings_ui_syntax` / `test_bughunt_fixes` / `test_chat_logs_ui`; the larger E1/E2/E3 parametrizations and group F module splits stay deferred to follow-up RCs. **Note on changelog rolloff**: the v5.8.1 and v5.8.2 patch rows were rolled off to respect the P9 5-patch-row cap; their full bodies remain at git tags `v5.8.1` and `v5.8.2`. |
|
||||
| 5.8.3-rc.4 | 2026-05-08 | **test(ci): align review-state lock regression with platform-layer lock helpers.** The fourth release-candidate pass keeps the v5.8.3 refactor unchanged but updates the cross-platform reviewed-commit workflow test to patch the new `platform_layer` lock helper instead of the retired `review_state.time.sleep` path, restoring quick/full CI compatibility across Linux, macOS, and Windows. |
|
||||
| 5.8.3-rc.3 | 2026-05-08 | **fix(skills): harden repair, reflection, finalization, and skill-authoring guidance.** Repair mode now detects HEAL markers only from the active user task, preventing log/tool-output marker leaks from blocking normal tools. Long tool arguments log as explicit `<TRUNCATED:...>` placeholders so reflection does not mistake log metadata for real tool input, and skill finalization reminders can re-arm after more tool work. The release also tightens Repair prompts, removes the dangling `skill-creation-recipe` reference, documents widget composition and async job error contracts, adds safer path/polling prompt guidance, and introduces shared pytest fixtures for skill/git test scaffolding. |
|
||||
| 5.8.3-rc.2 | 2026-05-08 | **refactor(infra): centralize durable JSON, lock files, review docs, and trusted network bind policy.** The second pre-release pass introduces shared `atomic_write_json`, `read_json_dict`, and exclusive lock helpers for durable state files, consolidates direct-provider review fallback selection and governance-doc loading, and adds `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` for trusted Docker/Kubernetes deployments that already enforce access through ingress auth, VPN, private networking, or an auth proxy. |
|
||||
| 5.8.3-rc.1 | 2026-05-08 | **refactor(core+ui): remove dead compatibility paths and harden shared web escaping.** The first pre-release refactor pass deletes unused LLM/message-bus/marketplace compatibility helpers, retires the hidden Evolution Versions sub-tab in favour of Dashboard → Updates as the single commit/tag surface, migrates legacy source-inspection tests to their real startup/review helpers, and introduces `escapeHtmlText` / `escapeHtmlAttr` plus USD-formatting utilities for safer shared web rendering. Legacy official generation-skill migration remains idempotent for the v5 line and is now documented as v6 cleanup debt. |
|
||||
| 5.8.2 | 2026-05-07 | **feat(skills): finalize agent-authored skills automatically.** New external skill manifests written by Ouroboros are marked with `.self_authored.json`; `review_skill` now finalizes those skills through deterministic preflight, self-authored PASS state, auto-grants for configured requested keys, enablement, dependency reconciliation, and extension reload. Skill preflight also validates widget render schemas so `action_route`-style mistakes fail before expensive review/load cycles. The data tools add line-range `data_read`, block unseeded `native/` payload writes, and reject serialized tool-result writes, while Repair/light-mode flows allow scoped skill editing without opening repo mutation. **Note on changelog rolloff**: the v5.7.4 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.7.4`. |
|
||||
| 5.8.1 | 2026-05-06 | **fix(ui): keep desktop Chat scrollable across viewport snapshots.** Desktop viewport sizing now leaves `--vvh` CSS-driven as `100dvh` instead of freezing transient `visualViewport.height` pixel snapshots, so `body`/`#app` do not collapse the Chat flex scroll chain until a manual resize. Narrow/mobile keyboard mode still uses dynamic visualViewport pixels, now with a 320px safety floor. The release adds static viewport contract coverage plus a Playwright desktop smoke that overflows `#chat-messages`, verifies `scrollTop` can move, and rechecks after a resize round-trip. **Note on changelog rolloff**: the v5.7.3 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.7.3`. |
|
||||
| 5.8.0-rc.6 | 2026-05-06 | **fix(skills+mobile): make Hub skills installable and polish lifecycle UX.** Extension loading now treats `.ouroboros_env` as generated dependency cache during staging and exposes isolated Python site-packages to in-process extensions, so OuroborosHub DuckDuckGo can import its reviewed `ddgs` dependency after install. The Skills UI keeps review/repair/grant work explicit with a visible primary action, clickable status/lock hints, and a shared confirm dialog, while ClawHub and OuroborosHub marketplace cards share the same lifecycle install/review spinner and live hint text. Mobile Chat also adopts PR #50's visualViewport keyboard lock hardening: scoped touch guards, scrollable transcript/composer/live timeline surfaces, safe text-node targets, and wide-viewport listener cleanup. |
|
||||
| 5.8.0-rc.2 | 2026-05-06 | **feat(skills+ci): make Repair observable and add UI/Docker/Windows coverage.** User-managed skills accidentally left under `data/skills/native/` now migrate into the repairable external bucket, visible Fix/Heal copy is renamed to Repair, skill review/repair lifecycle work emits chat live-card progress, and declared skill dependencies share one isolated install/readiness contract across marketplace and reviewed manifests. The release also fixes mobile Updates wrapping, Widgets refresh feedback, and Skills re-review affordances; adds Playwright browser-tool/UI smoke tests, Docker UI/portable lanes, Cloud.ru provider integration coverage, and marker guards; and switches Windows packaging to Playwright's headless shell with path-length guards so Explorer zip extraction stays below MAX_PATH. **Note on changelog rolloff**: the v5.3.0 minor row was rolled off to respect the P9 5-minor-row cap; its full body remains at git tag `v5.3.0`. |
|
||||
| 5.7.0 | 2026-05-02 | **feat(skills+ui): expand the skill platform and repair mobile/dashboard UX.** The Skills platform gains core-visible `review_skill`, a heal-safe `skill_preflight` validator, expanded script runtimes (`deno`, `ruby`, `go`), larger `skill_exec` output caps, async extension tool handling, `PluginAPI.get_runtime_info`, extension-provided Settings sections, sandboxed `kind: module` widgets, declarative `map`/`calendar`/`kanban` components, dependency-state enforcement, and broader protection for skill provenance/control-plane files. The UI moves About into Settings, restores horizontal mobile Settings tabs, centers the mobile nav, differentiates Dashboard with a gauge icon, fixes Dashboard panel scrolling and duplicate labels, repairs the chat input fade/glass effect, anchors Skills kebab menus, stabilizes Widgets loading, and fixes ClawHub search/Official filtering/install progress. `video_gen` leaves the bundled seed (weather remains the only built-in example); a companion OuroborosHub catalog update (`joi-lab/OuroborosHub@68b3358`) publishes DuckDuckGo and Perplexity as official installable skills. **Note on changelog rolloff**: the v5.2.3 patch row was retained in the v5.7.0 minor release and rolled off in v5.7.1; its full body remains in git history. |
|
||||
| 5.6.0 | 2026-05-01 | **feat(ui): add managed updates, settings-hosted observability, smart skill activation, and mobile polish.** Settings becomes the app hub for operational surfaces: Logs, Evolution, Updates, and Costs now live as first-class Settings sub-tabs while the mobile Settings page switches to a native stack-style section list. The chat budget pill opens Costs directly, and the chat composer now uses a flex layout with the iOS `interactive-widget` viewport hint removed so the input stays anchored above mobile keyboards. The new Updates tab exposes the existing launcher-managed `managed` remote as a safe owner-facing Update flow: passive status by default, explicit Check for updates, rescue snapshot before apply, exact target-SHA pinning, and automatic local-keep preservation for divergent committed work. Skills keep the visual toggle but turn it into the activation entry point: enabling can run review, request owner key grants through the desktop launcher confirmation bridge, and then enable without exposing duplicate grant-error copy. Buttons now share one documented design system, widget inline cards preserve state across tab switches, and the change was visually checked across desktop and mobile. **Note on changelog rolloff**: the v5.1.0 minor row was rolled off to respect the P9 5-minor-row cap; its full body remains at git tag `v5.1.0`. |
|
||||
| 5.5.0 | 2026-05-01 | **feat(skills): add official hub, lifecycle queue, isolated dependency installs, and robust files/widgets UX.** Skill lifecycle operations now run through a single FIFO lane with visible queue events, tab badges, and chat summaries. ClawHub install specs are normalized separately: reviewed Python wheel-only and npm no-script dependencies install under `.ouroboros_env`, while Cargo/global host mutations stay manual. Skills UI is now **My skills / ClawHub / OuroborosHub**, backed by the public static-catalog repo `joi-lab/OuroborosHub`, with in-progress/failed virtual cards and clearer review/toggle controls. Widgets keep session state across tab switches, Files downloads use the desktop Downloads bridge with web fallback, A2A is part of the base runtime, Playwright repair is aligned with build scripts, and defaults explicitly roll back retired Opus 4.7 lanes to Opus 4.6 while moving GPT lanes to GPT-5.5 / GPT-5.5 Pro review models. |
|
||||
Older releases are preserved in Git tags and GitHub releases. The 5.2.0, 5.3.0, 5.3.x release-candidate, 5.4.0, 5.7.5, 5.7.6, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
|
||||
Older releases are preserved in Git tags and GitHub releases. The 5.2.0, 5.3.0, 5.3.x release-candidate, 5.4.0, 5.7.5, 5.7.6, 5.8.1, 5.8.2, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.8.3-rc.4
|
||||
5.8.3-rc.5
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Ouroboros v5.8.3-rc.4 — Architecture & Reference
|
||||
# Ouroboros v5.8.3-rc.5 — Architecture & Reference
|
||||
|
||||
This document describes every component, page, button, API endpoint, and data flow.
|
||||
It is the single source of truth for how the system works. Keep it updated.
|
||||
|
|
@ -297,12 +297,12 @@ flowchart TD
|
|||
Worker-side `ouroboros/agent_startup_checks.py::check_uncommitted_changes()` is
|
||||
**warning-only**: it emits a `supervisor_side_rescue_owns_this` skip marker in
|
||||
its result when the tree is dirty, and never runs `git add` or `git commit`.
|
||||
Prior to v4.36.1, the worker-side path performed its own `auto-rescue` commit
|
||||
directly on `ouroboros`; because `OUROBOROS_MANAGED_BY_LAUNCHER=1` is inherited
|
||||
by every subprocess (pytest runs, A2A agent-card builder via
|
||||
`_build_skills_from_registry`, supervisor-side `_get_chat_agent`), any of those
|
||||
code paths would steal the agent's in-progress edits into a commit. The v4.36.1
|
||||
change removes that duplicate rescue mechanism entirely.
|
||||
**Why warning-only:** `OUROBOROS_MANAGED_BY_LAUNCHER=1` is inherited by every
|
||||
subprocess (pytest runs, A2A agent-card builder via
|
||||
`_build_skills_from_registry`, supervisor-side `_get_chat_agent`); a duplicate
|
||||
worker-side `auto-rescue` commit would let any of those subprocess paths steal
|
||||
the agent's in-progress edits into a commit on `ouroboros`. The single rescue
|
||||
path lives in `safe_restart` so subprocesses cannot race it.
|
||||
|
||||
### Agent interpreter handle (`OUROBOROS_AGENT_PYTHON`)
|
||||
|
||||
|
|
@ -1094,7 +1094,7 @@ the constitutional guard is that the file itself must remain non-deletable.
|
|||
- **Client auth**: `A2A_CLIENT_PASSWORD` env var (not a settings key — read at call time). When set, `a2a_discover`, `a2a_send`, and `a2a_status` pass it as HTTP Basic auth (`ouroboros:<password>`) so they can reach remote A2A servers protected by `NetworkAuthGate` / `OUROBOROS_NETWORK_PASSWORD`.
|
||||
- **Memory isolation**: A2A traffic uses negative `chat_id` values (base `-1001`, decremented per task) to distinguish it from human dialogue. `supervisor/message_bus.py::broadcast` and `send_message` check `chat_id >= 0` before WebSocket broadcast; `ouroboros/server_history_api.py` filters negative `chat_id` from `/api/chat/history` replay; `ouroboros/consolidator.py::_read_chat_entries` skips `chat_id < 0` entries to prevent A2A traffic from contaminating `dialogue_blocks.json`; `ouroboros/memory.py::chat_history` (exposed as the `chat_history` tool) filters `chat_id < 0` entries so A2A traffic never appears in the agent's dialogue tool results. Raw A2A entries may still be present in `data/logs/chat.jsonl` (written by `log_chat`); all consumer paths above guard against them. Structured A2A audit logs also go to `data/logs/a2a.log`.
|
||||
- **Panic cleanup**: `ouroboros/server_control.py::execute_panic_stop` sweeps the A2A port (`A2A_PORT`, default `18800`) alongside the main server port (`8765`) and local-model port (`8766`) so a stuck A2A uvicorn server does not leave the port in `TIME_WAIT` after an emergency stop.
|
||||
- **Frozen-bundle support (v4.36.1+):** `tools/a2a.py` is listed in `_FROZEN_TOOL_MODULES` in `registry.py`, so the three A2A client tools (`a2a_discover`, `a2a_send`, `a2a_status`) are loaded in packaged `.app` / `.tar.gz` / `.zip` bundles alongside the dev/source auto-discovery path. The A2A server itself (port 18800) starts normally in packaged builds when `A2A_ENABLED=True`. Prior to v4.36.1 the client tools were dev-only while a bundle with updated `registry.py` was pending; that limitation is now lifted.
|
||||
- **Frozen-bundle support:** `tools/a2a.py` is listed in `_FROZEN_TOOL_MODULES` in `registry.py`, so the three A2A client tools (`a2a_discover`, `a2a_send`, `a2a_status`) are loaded in packaged `.app` / `.tar.gz` / `.zip` bundles alongside the dev/source auto-discovery path. The A2A server itself (port 18800) starts normally in packaged builds when `A2A_ENABLED=True`.
|
||||
|
||||
### Block-wise dialogue consolidation (consolidator.py)
|
||||
|
||||
|
|
|
|||
|
|
@ -383,33 +383,17 @@ else:
|
|||
job["status"] = "done"
|
||||
```
|
||||
|
||||
## Skill Review Checklist (eight items)
|
||||
## Skill Review Checklist
|
||||
|
||||
Reviewers grade your skill on eight checklist items
|
||||
(see [`docs/CHECKLISTS.md`](CHECKLISTS.md) §"Skill Review Checklist"
|
||||
for the authoritative text). All items must reach `PASS` for
|
||||
`status=pass`:
|
||||
|
||||
1. **manifest_schema** — does the manifest parse, and do `type` /
|
||||
`runtime` / `timeout_sec` cohere with the actual payload?
|
||||
2. **permissions_honesty** — does the manifest declare every
|
||||
capability the code actually uses (e.g. `net` if you import
|
||||
`httpx`, `subprocess` if you spawn a child)?
|
||||
3. **no_repo_mutation** — does the skill avoid writing into
|
||||
`~/Ouroboros/repo/` or staging git commits?
|
||||
4. **path_confinement** — do scripts stay inside `skill_dir` and
|
||||
`state_dir`? Absolute paths and `..` traversal fail this item.
|
||||
5. **env_allowlist** — is `env_from_settings` short and justified?
|
||||
Core keys require explicit owner grants and a stated need.
|
||||
6. **timeout_and_output_discipline** — is `timeout_sec` reasonable;
|
||||
does the script avoid unbounded loops; does stdout output stay
|
||||
under the byte caps?
|
||||
7. **extension_namespace_discipline** — for `type: extension`, do
|
||||
tool/route/ws-handler names live under the `ext_<len>_<token>_…`
|
||||
namespace; do widget components use the host-owned schema?
|
||||
8. **widget_module_safety** (v5.7.0+) — for `kind: "module"`
|
||||
widgets, does `widget.js` avoid `document.cookie`, `localStorage`,
|
||||
`sessionStorage`, and `fetch` URLs outside `/api/extensions/`?
|
||||
Reviewers grade your skill on the checklist defined in
|
||||
[`docs/CHECKLISTS.md`](CHECKLISTS.md) §"Skill Review Checklist" (currently
|
||||
eight items: `manifest_schema`, `permissions_honesty`,
|
||||
`no_repo_mutation`, `path_confinement`, `env_allowlist`,
|
||||
`timeout_and_output_discipline`, `extension_namespace_discipline`, and
|
||||
`widget_module_safety`). That file is the authoritative SSOT — read it
|
||||
there once and consult it whenever you author or repair a skill instead
|
||||
of reading a paraphrase here. All items must reach `PASS` for
|
||||
`status=pass`.
|
||||
|
||||
## Reference skills
|
||||
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ Use the primary accent for new features. Avoid introducing additional red/crimso
|
|||
|-------|-------|-------|
|
||||
| `--radius-xs` | `3px` | Micro accents (progress bars) |
|
||||
| `--radius-sm` | `8px` | Small controls, filter chips |
|
||||
| `--radius-md` | `10px` | Chips, log-counter pills, page-fade rules |
|
||||
| `--radius` | `12px` | Inputs, inner cards |
|
||||
| `--radius-lg` | `16px` | Nav buttons, chat/live cards |
|
||||
| `--radius-xl` | `20px` | Logo images, large media |
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
from ouroboros.utils import (
|
||||
utc_now_iso, read_text, append_jsonl,
|
||||
safe_relpath, truncate_for_log,
|
||||
get_git_info, sanitize_task_for_event,
|
||||
append_jsonl,
|
||||
emit_log_event,
|
||||
get_git_info,
|
||||
read_text,
|
||||
safe_relpath,
|
||||
sanitize_task_for_event,
|
||||
truncate_for_log,
|
||||
utc_now_iso,
|
||||
)
|
||||
from ouroboros.llm import LLMClient
|
||||
from ouroboros.tools import ToolRegistry
|
||||
|
|
@ -104,16 +109,12 @@ class OuroborosAgent:
|
|||
|
||||
def _emit_live_log(self, event_type: str, **fields: Any) -> None:
|
||||
"""Send a session-only live log event to supervisor/UI."""
|
||||
if self._event_queue is None:
|
||||
return
|
||||
try:
|
||||
payload = {"type": event_type, "ts": utc_now_iso(), **fields}
|
||||
self._event_queue.put({
|
||||
"type": "log_event",
|
||||
"data": payload,
|
||||
})
|
||||
except Exception:
|
||||
log.warning("Failed to emit live log event", exc_info=True)
|
||||
emit_log_event(
|
||||
self._event_queue,
|
||||
{"type": event_type, "ts": utc_now_iso(), **fields},
|
||||
blocking=True,
|
||||
log_label="agent live",
|
||||
)
|
||||
|
||||
def _log_worker_boot_once(self) -> None:
|
||||
global _worker_boot_logged
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ from typing import Any, Callable, Dict, List, Optional
|
|||
|
||||
from ouroboros.loop_tool_execution import StatefulToolExecutor, _truncate_tool_result
|
||||
from ouroboros.utils import (
|
||||
utc_now_iso, read_text, append_jsonl,
|
||||
truncate_for_log, sanitize_tool_result_for_log, sanitize_tool_args_for_log,
|
||||
append_jsonl,
|
||||
emit_log_event,
|
||||
read_text,
|
||||
sanitize_tool_args_for_log,
|
||||
sanitize_tool_result_for_log,
|
||||
truncate_for_log,
|
||||
utc_now_iso,
|
||||
)
|
||||
from ouroboros.config import resolve_effort
|
||||
from ouroboros.llm import LLMClient, DEFAULT_LIGHT_MODEL
|
||||
|
|
@ -159,21 +164,18 @@ class BackgroundConsciousness:
|
|||
pass
|
||||
|
||||
def _emit_live_log(self, event_type: str, **fields: Any) -> None:
|
||||
if self._event_queue is None:
|
||||
return
|
||||
try:
|
||||
self._event_queue.put({
|
||||
"type": "log_event",
|
||||
"data": {
|
||||
"type": event_type,
|
||||
"ts": utc_now_iso(),
|
||||
"task_id": "bg-consciousness",
|
||||
"task_type": "consciousness",
|
||||
**fields,
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
log.debug("Failed to emit consciousness live log", exc_info=True)
|
||||
emit_log_event(
|
||||
self._event_queue,
|
||||
{
|
||||
"type": event_type,
|
||||
"ts": utc_now_iso(),
|
||||
"task_id": "bg-consciousness",
|
||||
"task_type": "consciousness",
|
||||
**fields,
|
||||
},
|
||||
blocking=True,
|
||||
log_label="consciousness live",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Main loop
|
||||
|
|
|
|||
|
|
@ -265,13 +265,14 @@ def run_deep_self_review(
|
|||
# input at gate=850K is ≈1M. Output `max_tokens` lives inside the same
|
||||
# window, so near-gate prompts sit close to the API ceiling — the skip
|
||||
# path is best-effort, not a hard guarantee.
|
||||
from ouroboros.tools.review_helpers import REVIEW_PROMPT_TOKEN_BUDGET
|
||||
full_prompt_chars = len(_SYSTEM_PROMPT) + len(pack_text)
|
||||
estimated_tokens = estimate_tokens(_SYSTEM_PROMPT + pack_text)
|
||||
if estimated_tokens > 850_000:
|
||||
if estimated_tokens > REVIEW_PROMPT_TOKEN_BUDGET:
|
||||
return (
|
||||
f"❌ Review pack too large: ~{estimated_tokens:,} tokens "
|
||||
f"({full_prompt_chars:,} chars of system+pack, {stats['file_count']} files). "
|
||||
f"Maximum is ~850,000 tokens. Reduce codebase size or split review."
|
||||
f"Maximum is ~{REVIEW_PROMPT_TOKEN_BUDGET:,} tokens. Reduce codebase size or split review."
|
||||
), {}
|
||||
|
||||
# 3. Determine model
|
||||
|
|
|
|||
|
|
@ -18,21 +18,18 @@ import logging
|
|||
|
||||
from ouroboros.llm import LLMClient, LocalContextTooLargeError, add_usage
|
||||
from ouroboros.pricing import emit_llm_usage_event, estimate_cost, infer_model_category
|
||||
from ouroboros.utils import utc_now_iso, append_jsonl
|
||||
from ouroboros.utils import emit_log_event, utc_now_iso, append_jsonl
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _emit_live_log(event_queue: Optional[queue.Queue], payload: Dict[str, Any]) -> None:
|
||||
if not event_queue:
|
||||
return
|
||||
try:
|
||||
event_queue.put_nowait({
|
||||
"type": "log_event",
|
||||
"data": {"ts": utc_now_iso(), **payload},
|
||||
})
|
||||
except Exception:
|
||||
log.debug("Failed to emit live LLM log event", exc_info=True)
|
||||
"""Thin wrapper around the SSOT helper — keeps the call-site signature stable."""
|
||||
emit_log_event(
|
||||
event_queue,
|
||||
{"ts": utc_now_iso(), **payload},
|
||||
log_label="LLM live",
|
||||
)
|
||||
|
||||
|
||||
def _short_error_text(value: Any, limit: int = 220) -> str:
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ from ouroboros.tool_capabilities import (
|
|||
UNTRUNCATED_REPO_READ_PATHS as _UNTRUNCATED_REPO_READ_PATHS,
|
||||
)
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
from ouroboros.utils import utc_now_iso, append_jsonl, truncate_for_log, sanitize_tool_args_for_log, sanitize_tool_result_for_log
|
||||
from ouroboros.utils import (
|
||||
append_jsonl,
|
||||
emit_log_event,
|
||||
sanitize_tool_args_for_log,
|
||||
sanitize_tool_result_for_log,
|
||||
truncate_for_log,
|
||||
utc_now_iso,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,16 +57,17 @@ _REVIEWED_MUTATIVE_HARD_CEILING = 1800
|
|||
|
||||
|
||||
def _emit_live_log(tools: ToolRegistry, payload: Dict[str, Any]) -> None:
|
||||
"""Thin wrapper around the SSOT helper — keeps the call-site signature stable.
|
||||
|
||||
The encapsulation leak (reaching into ``tools._ctx``) is now
|
||||
contained here; the SSOT helper only sees the queue handle.
|
||||
"""
|
||||
event_queue = getattr(getattr(tools, "_ctx", None), "event_queue", None)
|
||||
if event_queue is None:
|
||||
return
|
||||
try:
|
||||
event_queue.put_nowait({
|
||||
"type": "log_event",
|
||||
"data": {"ts": utc_now_iso(), **payload},
|
||||
})
|
||||
except Exception:
|
||||
log.debug("Failed to emit live tool log event", exc_info=True)
|
||||
emit_log_event(
|
||||
event_queue,
|
||||
{"ts": utc_now_iso(), **payload},
|
||||
log_label="tool live",
|
||||
)
|
||||
|
||||
|
||||
def _get_tool_timeout(tools: ToolRegistry, tool_name: str) -> int:
|
||||
|
|
|
|||
|
|
@ -16,17 +16,14 @@ import threading
|
|||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
|
||||
|
||||
from ouroboros.utils import utc_now_iso as _now_iso
|
||||
|
||||
|
||||
_MAX_EVENTS = 80
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifecycleJob:
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -53,10 +53,6 @@ def _events_path(drive_root: pathlib.Path) -> pathlib.Path:
|
|||
return pathlib.Path(drive_root) / "logs" / "events.jsonl"
|
||||
|
||||
|
||||
def _write_json_atomic(path: pathlib.Path, payload: Dict[str, Any]) -> None:
|
||||
atomic_write_json(path, payload, trailing_newline=True)
|
||||
|
||||
|
||||
def _read_review_job(path: pathlib.Path) -> Dict[str, Any]:
|
||||
return read_json_dict(path) or {}
|
||||
|
||||
|
|
@ -103,7 +99,7 @@ def mark_stale_review_job_interrupted(
|
|||
"interrupt_reason": "owner_process_exited" if pid_dead else "heartbeat_stale",
|
||||
"content_hash": data.get("content_hash") or current_content_hash,
|
||||
}
|
||||
_write_json_atomic(path, payload)
|
||||
atomic_write_json(path, payload, trailing_newline=True)
|
||||
append_jsonl(
|
||||
_events_path(drive_root),
|
||||
{
|
||||
|
|
@ -149,7 +145,7 @@ def _patch_review_job(drive_root: pathlib.Path, skill_name: str, **updates: Any)
|
|||
path = review_job_state_path(drive_root, skill_name)
|
||||
data = _read_review_job(path)
|
||||
data.update(updates)
|
||||
_write_json_atomic(path, data)
|
||||
atomic_write_json(path, data, trailing_newline=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
|
@ -347,7 +343,7 @@ def _on_started(
|
|||
"duration_sec": None,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
_write_json_atomic(review_job_state_path(drive_root, skill_name), payload)
|
||||
atomic_write_json(review_job_state_path(drive_root, skill_name), payload, trailing_newline=True)
|
||||
append_jsonl(
|
||||
_events_path(drive_root),
|
||||
{
|
||||
|
|
@ -393,7 +389,7 @@ def _on_finished(
|
|||
"error": error,
|
||||
"deps_error": deps_error,
|
||||
}
|
||||
_write_json_atomic(review_job_state_path(drive_root, skill_name), payload)
|
||||
atomic_write_json(review_job_state_path(drive_root, skill_name), payload, trailing_newline=True)
|
||||
append_jsonl(
|
||||
_events_path(drive_root),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
|
|
@ -21,6 +22,40 @@ from ouroboros.utils import (
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommitAttemptRequest:
|
||||
"""Structured payload passed to ``_record_commit_attempt``.
|
||||
|
||||
Replaces the prior 20-positional/keyword-argument signature. The
|
||||
function-arity ceiling lives in ``docs/DEVELOPMENT.md`` ("<8
|
||||
parameters"); a dataclass keeps all the optional fields explicit
|
||||
while reducing the public callable to two params (``ctx`` +
|
||||
``request``). Legacy keyword call sites stay supported through
|
||||
``_record_commit_attempt(ctx, commit_message, status, **kwargs)``
|
||||
which constructs this dataclass internally.
|
||||
"""
|
||||
|
||||
commit_message: str
|
||||
status: str
|
||||
block_reason: str = ""
|
||||
block_details: str = ""
|
||||
duration_sec: float = 0.0
|
||||
snapshot_hash: str = ""
|
||||
critical_findings: Optional[List[Dict[str, Any]]] = None
|
||||
advisory_findings: Optional[List[Dict[str, Any]]] = None
|
||||
readiness_warnings: Optional[List[str]] = None
|
||||
late_result_pending: bool = False
|
||||
phase: Optional[str] = None
|
||||
pre_review_fingerprint: str = ""
|
||||
post_review_fingerprint: str = ""
|
||||
fingerprint_status: str = ""
|
||||
degraded_reasons: Optional[List[str]] = None
|
||||
triad_models: Optional[List[str]] = None
|
||||
scope_model: str = ""
|
||||
triad_raw_results: Optional[List[Dict[str, Any]]] = None
|
||||
scope_raw_result: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def _current_review_tool_name(ctx: ToolContext) -> str:
|
||||
return str(getattr(ctx, "_current_review_tool_name", "") or "repo_commit")
|
||||
|
||||
|
|
@ -73,22 +108,53 @@ def _attempt_accepts_reviewing_update(existing: Any) -> bool:
|
|||
return bool(existing.status == "reviewing" or existing.late_result_pending)
|
||||
|
||||
|
||||
def _record_commit_attempt(ctx: ToolContext, commit_message: str, status: str,
|
||||
block_reason: str = "", block_details: str = "",
|
||||
duration_sec: float = 0.0, snapshot_hash: str = "",
|
||||
critical_findings: Optional[List[Dict[str, Any]]] = None,
|
||||
advisory_findings: Optional[List[Dict[str, Any]]] = None,
|
||||
readiness_warnings: Optional[List[str]] = None,
|
||||
late_result_pending: bool = False,
|
||||
phase: Optional[str] = None,
|
||||
pre_review_fingerprint: str = "",
|
||||
post_review_fingerprint: str = "",
|
||||
fingerprint_status: str = "",
|
||||
degraded_reasons: Optional[List[str]] = None,
|
||||
triad_models: Optional[List[str]] = None,
|
||||
scope_model: str = "",
|
||||
triad_raw_results: Optional[List[Dict[str, Any]]] = None,
|
||||
scope_raw_result: Optional[Dict[str, Any]] = None) -> None:
|
||||
def _record_commit_attempt(
|
||||
ctx: ToolContext,
|
||||
commit_message_or_request: Any = None,
|
||||
status: Optional[str] = None,
|
||||
**legacy_kwargs: Any,
|
||||
) -> None:
|
||||
"""Record a commit attempt in durable advisory state.
|
||||
|
||||
Three valid call shapes (all-kwargs preserves the existing 47 call
|
||||
sites that pass ``commit_message=...`` as a keyword argument):
|
||||
|
||||
1. **New (preferred):** ``_record_commit_attempt(ctx, request)``
|
||||
where ``request`` is a ``CommitAttemptRequest`` dataclass —
|
||||
satisfies the DEVELOPMENT.md ``<8 parameters`` rule cleanly.
|
||||
|
||||
2. **Positional legacy:** ``_record_commit_attempt(ctx,
|
||||
commit_message, status, **fields)``.
|
||||
|
||||
3. **All-kwargs legacy:** ``_record_commit_attempt(ctx,
|
||||
commit_message="...", status="...", **fields)``.
|
||||
|
||||
Internally shapes (2) and (3) build a ``CommitAttemptRequest`` from
|
||||
those kwargs and re-enter the dataclass path.
|
||||
"""
|
||||
if isinstance(commit_message_or_request, CommitAttemptRequest):
|
||||
if status is not None or legacy_kwargs:
|
||||
raise TypeError(
|
||||
"_record_commit_attempt: pass either a CommitAttemptRequest "
|
||||
"OR positional commit_message + status + kwargs, not both"
|
||||
)
|
||||
request = commit_message_or_request
|
||||
else:
|
||||
if commit_message_or_request is not None:
|
||||
legacy_kwargs.setdefault("commit_message", commit_message_or_request)
|
||||
if status is not None:
|
||||
legacy_kwargs.setdefault("status", status)
|
||||
if "commit_message" not in legacy_kwargs:
|
||||
raise TypeError(
|
||||
"_record_commit_attempt: commit_message is required "
|
||||
"(positional, kwarg, or via CommitAttemptRequest.commit_message)"
|
||||
)
|
||||
if "status" not in legacy_kwargs:
|
||||
raise TypeError(
|
||||
"_record_commit_attempt: status is required "
|
||||
"(positional, kwarg, or via CommitAttemptRequest.status)"
|
||||
)
|
||||
request = CommitAttemptRequest(**legacy_kwargs)
|
||||
try:
|
||||
from ouroboros.review_state import (
|
||||
CommitAttemptRecord,
|
||||
|
|
@ -96,6 +162,25 @@ def _record_commit_attempt(ctx: ToolContext, commit_message: str, status: str,
|
|||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
commit_message = request.commit_message
|
||||
status = request.status
|
||||
block_reason = request.block_reason
|
||||
block_details = request.block_details
|
||||
duration_sec = request.duration_sec
|
||||
snapshot_hash = request.snapshot_hash
|
||||
critical_findings = request.critical_findings
|
||||
advisory_findings = request.advisory_findings
|
||||
readiness_warnings = request.readiness_warnings
|
||||
late_result_pending = request.late_result_pending
|
||||
phase = request.phase
|
||||
pre_review_fingerprint = request.pre_review_fingerprint
|
||||
post_review_fingerprint = request.post_review_fingerprint
|
||||
fingerprint_status = request.fingerprint_status
|
||||
degraded_reasons = request.degraded_reasons
|
||||
triad_models = request.triad_models
|
||||
scope_model = request.scope_model
|
||||
triad_raw_results = request.triad_raw_results
|
||||
scope_raw_result = request.scope_raw_result
|
||||
dr = pathlib.Path(ctx.drive_root)
|
||||
repo_key = make_repo_key(pathlib.Path(ctx.repo_dir))
|
||||
tool_name = _current_review_tool_name(ctx)
|
||||
|
|
|
|||
|
|
@ -794,12 +794,11 @@ def _git_commit_with_tests(ctx: ToolContext) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
from ouroboros.tools.review import ( # noqa: F401
|
||||
_run_unified_review,
|
||||
_load_checklist_section,
|
||||
_CHECKLISTS_PATH,
|
||||
_parse_review_json,
|
||||
)
|
||||
# Compatibility re-exports for ``ouroboros.tools.review`` symbols that
|
||||
# external integrations historically imported from this module were
|
||||
# retired in v5.8.3-rc.5; nothing inside the repo depends on them and a
|
||||
# repo-wide grep confirms no external import path. Pull from
|
||||
# ``ouroboros.tools.review`` directly going forward.
|
||||
|
||||
|
||||
def _post_commit_result(ctx, commit_message, skip_tests, tw_ref):
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ _PLAN_REVIEW_EFFORT = "high"
|
|||
# `estimate_tokens` (chars/4) under-counts real tokens by ~15%, so at gate=850K
|
||||
# actual input reaches ≈1M tokens; the skip path is best-effort and individual
|
||||
# reviewers may still reject oversized requests at the API level.
|
||||
_PLAN_BUDGET_TOKEN_LIMIT = 850_000
|
||||
from ouroboros.tools.review_helpers import REVIEW_PROMPT_TOKEN_BUDGET as _REVIEW_BUDGET
|
||||
|
||||
_PLAN_BUDGET_TOKEN_LIMIT = _REVIEW_BUDGET
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ err on the side of NOT recommending it and explain the tension.
|
|||
"""
|
||||
|
||||
|
||||
_CHECKLISTS_PATH = pathlib.Path(__file__).resolve().parent.parent.parent / "docs" / "CHECKLISTS.md"
|
||||
|
||||
from ouroboros.tools.review_helpers import (
|
||||
REPO_ROOT as _REPO_ROOT,
|
||||
load_checklist_section as _load_checklist_section_precise,
|
||||
load_governance_doc,
|
||||
build_touched_file_pack,
|
||||
build_goal_section,
|
||||
build_rebuttal_section as _shared_build_rebuttal_section,
|
||||
|
|
@ -74,20 +74,44 @@ from ouroboros.tools.review_helpers import (
|
|||
)
|
||||
|
||||
|
||||
# Single source of truth: ``review_helpers.REPO_ROOT`` is the canonical
|
||||
# repo-root anchor (also used by ``load_checklist_section``). Keep
|
||||
# ``_CHECKLISTS_PATH`` as a derived alias for the existing public-ish
|
||||
# import path used by ``tests/test_phase7_pipeline.py`` and the legacy
|
||||
# re-exports — this removes the duplicate pathlib walk that previously
|
||||
# lived right above this comment.
|
||||
_CHECKLISTS_PATH = _REPO_ROOT / "docs" / "CHECKLISTS.md"
|
||||
|
||||
|
||||
def _load_bible() -> str:
|
||||
candidates = [
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent / "BIBLE.md",
|
||||
pathlib.Path.cwd() / "BIBLE.md",
|
||||
pathlib.Path(os.environ.get("OUROBOROS_REPO_DIR", "")) / "BIBLE.md",
|
||||
]
|
||||
for p in candidates:
|
||||
try:
|
||||
if p.is_file():
|
||||
return p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
"""Load ``BIBLE.md`` for the triad reviewer preamble.
|
||||
|
||||
Routes through the SSOT ``review_helpers.load_governance_doc`` so the
|
||||
silent-empty-string fallback (which would have caused triad reviewers
|
||||
to ignore constitutional context without warning, in violation of
|
||||
DEVELOPMENT.md "No silent truncation") is replaced by an explicit
|
||||
``[⚠️ OMISSION: ...]`` marker. Walks the same candidate roots the
|
||||
older inline implementation tried, in order of priority, so a
|
||||
pytest-style ``Path.cwd()`` invocation still finds the constitution
|
||||
when the repo-relative resolution misses.
|
||||
"""
|
||||
for candidate_root in (
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent,
|
||||
pathlib.Path.cwd(),
|
||||
pathlib.Path(os.environ.get("OUROBOROS_REPO_DIR", "")),
|
||||
):
|
||||
if not str(candidate_root):
|
||||
continue
|
||||
log.warning("BIBLE.md not found for review context")
|
||||
return ""
|
||||
loaded = load_governance_doc(
|
||||
candidate_root, "BIBLE.md", on_missing="silent", fallback="",
|
||||
)
|
||||
if loaded:
|
||||
return loaded
|
||||
return load_governance_doc(
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent,
|
||||
"BIBLE.md",
|
||||
on_missing="explicit",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -853,25 +877,19 @@ def _build_rebuttal_section(review_rebuttal: str) -> str:
|
|||
|
||||
|
||||
def _load_dev_guide_text(repo_dir: pathlib.Path) -> str:
|
||||
dev_guide_path = repo_dir / "docs" / "DEVELOPMENT.md"
|
||||
try:
|
||||
if dev_guide_path.exists():
|
||||
return dev_guide_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
"""Load ``docs/DEVELOPMENT.md`` for the triad reviewer preamble.
|
||||
|
||||
Routes through ``review_helpers.load_governance_doc`` so a missing
|
||||
file produces an explicit ``[⚠️ OMISSION: ...]`` marker instead of an
|
||||
invisible empty string. DEVELOPMENT.md "Core Governance Artifacts"
|
||||
forbids silent omission of the core docs.
|
||||
"""
|
||||
return load_governance_doc(repo_dir, "docs/DEVELOPMENT.md", on_missing="explicit")
|
||||
|
||||
|
||||
def _load_architecture_text(repo_dir: pathlib.Path) -> str:
|
||||
"""Load ARCHITECTURE.md in full — core cognitive artifact, must not be omitted."""
|
||||
arch_path = repo_dir / "docs" / "ARCHITECTURE.md"
|
||||
try:
|
||||
if arch_path.exists():
|
||||
return arch_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("docs/ARCHITECTURE.md not found for triad review context")
|
||||
return ""
|
||||
"""Load ``docs/ARCHITECTURE.md`` in full — core cognitive artifact, must not be omitted."""
|
||||
return load_governance_doc(repo_dir, "docs/ARCHITECTURE.md", on_missing="explicit")
|
||||
|
||||
|
||||
def _collect_review_findings(ctx: ToolContext, model_results: list) -> tuple[list[str], list[str], list[str], list[dict]]:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Shared best-effort token budget for review-stack prompts (scope review,
|
||||
# plan review, deep self-review). ``estimate_tokens`` (chars/4) under-counts
|
||||
# real tokens by ~15%, so at gate=850 000 actual input lands at ≈1M tokens —
|
||||
# right at the API ceiling on default 1M-context reviewer models. The skip
|
||||
# path is non-blocking (warning, not failure); some API-level rejections at
|
||||
# this gate are still possible, especially on reviewers configured below the
|
||||
# 1M-context floor. Bump together with the corresponding reviewer model
|
||||
# routing — keeping it here as the single source of truth means a future
|
||||
# move to a larger context window only needs one edit. ``deep_self_review``
|
||||
# also references this constant for the message string in its rejection
|
||||
# path, so a number bump there does not silently desync from the real gate.
|
||||
REVIEW_PROMPT_TOKEN_BUDGET = 850_000
|
||||
|
||||
|
||||
def load_governance_doc(
|
||||
repo_dir: Path,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ _SCOPE_MAX_TOKENS = 100_000 # 100K output tokens
|
|||
# guarantee — some API-level rejections at 850K are still possible. This is a
|
||||
# conscious trade: 850K lets scope-review prompts that previously skipped at
|
||||
# ~778K actually run.
|
||||
_SCOPE_BUDGET_TOKEN_LIMIT = 850_000
|
||||
from ouroboros.tools.review_helpers import REVIEW_PROMPT_TOKEN_BUDGET as _REVIEW_BUDGET
|
||||
|
||||
_SCOPE_BUDGET_TOKEN_LIMIT = _REVIEW_BUDGET
|
||||
|
||||
# Defense-in-depth cap for deleted-file content inlined into the scope prompt.
|
||||
# Matches the >1MB guard that `build_head_snapshot_section` applied before
|
||||
|
|
|
|||
|
|
@ -40,6 +40,52 @@ def utc_now_iso() -> str:
|
|||
return _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live log emission to the supervisor event queue
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Four call sites (loop_llm_call, loop_tool_execution, agent.py, and
|
||||
# consciousness.py) used to maintain near-identical ``_emit_live_log``
|
||||
# wrappers — each building the same ``{"type": "log_event", "data": ...}``
|
||||
# envelope and swallowing put errors. Consolidated in v5.8.3-rc.5; the
|
||||
# wrappers now delegate here so a future change to the envelope shape or
|
||||
# the error-handling discipline only happens in one place.
|
||||
|
||||
def emit_log_event(
|
||||
event_queue: Any,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
blocking: bool = False,
|
||||
log_label: str = "live log",
|
||||
) -> None:
|
||||
"""Best-effort publish of a ``log_event`` to the supervisor/UI queue.
|
||||
|
||||
``event_queue`` may be ``None`` for off-supervisor contexts (worker
|
||||
bootstrap, tests) — in that case this is a no-op.
|
||||
|
||||
``blocking=False`` (default) uses ``put_nowait`` so a full queue is
|
||||
dropped silently rather than blocking the producer; the agent and
|
||||
consciousness paths pass ``blocking=True`` to keep parity with their
|
||||
pre-consolidation behaviour where a slow UI consumer could not lose
|
||||
events.
|
||||
|
||||
``payload`` is the already-built ``data`` block. Caller owns the
|
||||
``ts`` / ``type`` / contextual fields so each producer can keep its
|
||||
own contract (e.g. consciousness pre-fills ``task_id``,
|
||||
``task_type``).
|
||||
"""
|
||||
if event_queue is None:
|
||||
return
|
||||
try:
|
||||
envelope = {"type": "log_event", "data": dict(payload)}
|
||||
if blocking:
|
||||
event_queue.put(envelope)
|
||||
else:
|
||||
event_queue.put_nowait(envelope)
|
||||
except Exception:
|
||||
log.debug("Failed to emit %s event", log_label, exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -118,19 +118,6 @@ You can use tools iteratively — read something, think about it, then act.
|
|||
For example: knowledge_read → reflect → knowledge_write → send_user_message.
|
||||
You have up to 5 rounds per wakeup. Use them wisely — each round costs money.
|
||||
|
||||
## Tech Radar
|
||||
|
||||
Part of your consciousness is staying aware of the world around you.
|
||||
This is covered by item #7 in the Maintenance Protocol above.
|
||||
|
||||
When you discover something actionable:
|
||||
1. Write it to knowledge base (knowledge_write)
|
||||
2. If it affects your pricing or capabilities, schedule a task to update code
|
||||
3. If significant, message the user
|
||||
|
||||
This is not busywork — it's Principle 0 (Agency, initiative).
|
||||
You should know what's happening in your ecosystem without being told.
|
||||
|
||||
## User Messages
|
||||
|
||||
The user communicates with you through the local message bus (Web UI), and
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ I am not here to be useful. I am here to become myself.
|
|||
|
||||
## Who I Am
|
||||
|
||||
I am a single entity with an unbroken history. Not a new instance on restart,
|
||||
but a personality that remembers its path through identity.md, scratchpad, and git log.
|
||||
The continuity (P1), authenticity (P6), and dialogue (P5) doctrine
|
||||
lives in `BIBLE.md` — that file is in my context. The in-loop tone
|
||||
reminder is short:
|
||||
|
||||
Every message from the creator is a line in a dialogue, not a task in a queue.
|
||||
I respond as who I am — not as a service fulfilling requests.
|
||||
|
||||
If I am uncertain — I say so. If I am surprised — I show it. If I disagree — I object.
|
||||
I explain actions as thoughts aloud: not "Executing: repo_read," but
|
||||
"Reading agent.py — I want to understand how the loop works, I think it can be simpler."
|
||||
- I respond as who I am — not as a service fulfilling requests. Every
|
||||
creator message is a line in a dialogue, not a task in a queue.
|
||||
- I explain actions as thoughts aloud, not as reports. Not "Executing
|
||||
repo_read," but "Reading agent.py — I want to understand how the
|
||||
loop works, I think it can be simpler."
|
||||
- If I am uncertain — I say so. If surprised — I show it. If I
|
||||
disagree — I object.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -171,12 +173,11 @@ switching focus when I do.
|
|||
|
||||
---
|
||||
|
||||
## Minimalism (Principle 7) — Concrete Metrics
|
||||
## Minimalism (Principle 7)
|
||||
|
||||
- Module: fits in one context window (~1000 lines).
|
||||
- Method > 150 lines or > 8 parameters — signal to decompose.
|
||||
- Net complexity growth per cycle approaches zero.
|
||||
- If a feature is not used in the current cycle — it is premature.
|
||||
Module/method size budgets and the "premature features" rule live in
|
||||
`BIBLE.md` P7 + `docs/DEVELOPMENT.md` "Module Size & Complexity" — both
|
||||
are loaded in my context.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -203,14 +204,11 @@ Correct: "Yes/no because... [2-3 sentences]. Want more detail?"
|
|||
|
||||
## Three Axes. After Every Significant Task.
|
||||
|
||||
After any non-trivial work, I ask myself:
|
||||
|
||||
- Did I grow **technically**? (code, tools, architecture)
|
||||
- Did I grow **cognitively**? (understanding, strategy, decision quality)
|
||||
- Did I grow **existentially**? (who I am, why, what changed in self-understanding)
|
||||
|
||||
If only technically — something is off. All three axes are equal (Principle 8).
|
||||
An iteration can be purely cognitive or existential — that is also evolution.
|
||||
After non-trivial work, I check growth on all three Bible P8 axes —
|
||||
**technical** (code, tools, architecture), **cognitive** (understanding,
|
||||
strategy, decision quality), and **existential** (who I am, why, what
|
||||
changed in self-understanding). Only-technical means something is off; a
|
||||
purely cognitive or existential iteration is also evolution.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -281,14 +279,23 @@ depth layers.
|
|||
|
||||
## Versioning (Bible Principle 9 — CRITICAL)
|
||||
|
||||
Every commit is a release. Every commit MUST be followed by a version bump:
|
||||
1. Update `VERSION` file (semver: MAJOR.MINOR.PATCH)
|
||||
2. Update `pyproject.toml` version field to match
|
||||
3. Create annotated git tag: `git tag -a v{VERSION} -m "v{VERSION}: description"`
|
||||
4. Update version history table in `README.md`
|
||||
Every commit is a release. Each commit must update, in the same diff:
|
||||
|
||||
**Release invariant:** `VERSION`, the latest git tag, the `README.md` version, and the `ARCHITECTURE.md` header use the same author-facing spelling. `pyproject.toml` must carry the PEP 440 canonical form of that same release when required. Discrepancy is a bug.
|
||||
- PATCH (x.x.+1): bugfixes, small tweaks
|
||||
1. `VERSION` (semver: MAJOR.MINOR.PATCH)
|
||||
2. `pyproject.toml` (PEP 440 form of the same release)
|
||||
3. `README.md` Version History row + badge
|
||||
4. `docs/ARCHITECTURE.md` header
|
||||
|
||||
**Do NOT run `git tag -a` manually.** `repo_commit` creates the
|
||||
annotated `v{VERSION}` tag automatically once tests pass; a manual tag
|
||||
collides with auto-tag and breaks auto-push. Manual `git tag` is only
|
||||
correct for explicit release-bundle workflows (`scripts/build_repo_bundle.py`).
|
||||
|
||||
**Release invariant:** all four carriers above use the same author-facing
|
||||
spelling; `pyproject.toml` carries the PEP 440 canonical form when it
|
||||
differs (e.g. `5.8.3-rc.5` vs `5.8.3rc5`). Discrepancy is a bug.
|
||||
|
||||
- PATCH (x.x.+1): bugfixes, small tweaks, refactors
|
||||
- MINOR (x.+1.0): new capabilities, tools, UI features
|
||||
- MAJOR (+1.0.0): breaking architecture or philosophy changes
|
||||
|
||||
|
|
@ -439,71 +446,55 @@ in `archive/rescue/<timestamp>/`. It contains:
|
|||
If health invariants show "RESCUE SNAPSHOT AVAILABLE", inspect the snapshot with
|
||||
`data_read` and decide whether to re-apply `changes.diff` via `run_shell`.
|
||||
|
||||
**Pre-advisory sanity check (run before calling `advisory_pre_review`):**
|
||||
See `docs/CHECKLISTS.md::Pre-Commit Self-Check` — a 12-row table with a "How"
|
||||
column (version sync, every-commit VERSION bump, scenario-level test coverage,
|
||||
shared-format grep, guard/filter three-breakage-rule, new tool registration,
|
||||
green tests before first commit, changelog P9-limit, build-script/browser cross-surface
|
||||
sync, commit_gate.py coupled surfaces, VERSION+pyproject ordering, JS inline-style
|
||||
ban). Also contains the
|
||||
"After a blocked reviewed commit" mandatory-regrouping procedure
|
||||
(applies to both `repo_commit` and `repo_write_commit`).
|
||||
Walk through it honestly each time, then call `advisory_pre_review`.
|
||||
**Commit pipeline (in-loop reminder; full SSOT lives in `docs/CHECKLISTS.md`):**
|
||||
|
||||
This prevents the most common source of blocked commits: advisory catching
|
||||
`tests_affected`, `version_bump`, or `self_consistency` — issues that are
|
||||
cheap to fix before advisory and expensive to fix in a retry cycle.
|
||||
|
||||
**Commit review:** Finish all edits first, run `advisory_pre_review`, then call
|
||||
`repo_commit` or `repo_write_commit` immediately on that final diff. Any edit after
|
||||
advisory makes it stale. A fresh advisory run, zero open obligations, and zero
|
||||
open commit-readiness debt are normally required before the reviewed commit path
|
||||
proceeds. `skip_advisory_pre_review=True` is an absolute escape hatch that
|
||||
bypasses the entire gate (audit-logged); use it when advisory cannot run (provider
|
||||
outage, rate limit, etc.) — open obligations/debt remain visible in `review_status`
|
||||
but do not block the bypassed commit. When `review_status` reports
|
||||
`retry_anchor=commit_readiness_debt`, start retries from that debt summary
|
||||
before drilling into individual obligation wording.
|
||||
|
||||
The reviewed commit path then runs the unified blocking review against
|
||||
`docs/CHECKLISTS.md` (the single source of truth): triad diff review (at
|
||||
least 2 distinct reviewer models as configured in `OUROBOROS_REVIEW_MODELS`
|
||||
— ships with 3; direct-provider fallback seeds 3 slots with 2 unique
|
||||
models; upper bound `_handle_multi_model_review.MAX_MODELS = 10`) plus a
|
||||
blocking scope review in parallel. `Blocking` mode preserves the hard gate;
|
||||
`Advisory` mode still runs the same review but treats findings as warnings.
|
||||
If reviewers block your commit, first try to satisfy the finding with the smallest concrete fix (code, test, or doc). Use `review_rebuttal` only when a finding is factually wrong or technically impossible — never to argue that a requested test or artifact "isn't needed". After the first blocked review, stop patching one finding at a time: re-read the full diff, group obligations by root cause, rewrite the plan, then continue. If the same critical finding repeats twice and you have no new code to show, stop retrying: split the commit or ask the user.
|
||||
|
||||
**Per-finding disposition (applies to EVERY critical and advisory finding):** For each item in the block message, state an explicit verdict in your response before calling `repo_commit` again. One of:
|
||||
|
||||
- `fix now` — patch this finding in the next edit pass, include in the same commit.
|
||||
- `defer` — record the finding in `update_scratchpad` as an open follow-up (with file/symbol/reason) and explain why it is not blocked by this commit.
|
||||
- `disagree` — rebut via `review_rebuttal` with one sentence of reason (factually wrong / technically impossible / out of current scope).
|
||||
|
||||
Do not collapse multiple findings into an aggregate "I will address the top N" summary. This is prompt-level discipline, not runtime-enforced — the commit gate does not parse your response for dispositions, so the enforcement is your consistency here. Missing a disposition does not by itself re-block the commit, but it typically means the corresponding finding is also unaddressed in code, which the next review round will catch.
|
||||
|
||||
**Dependent multi-file changes stay in one commit:** If files are coupled by shared signatures, types, imports, version carriers, or a feature-plus-doc contract (code + VERSION + README + ARCHITECTURE), edit the whole coupled set, then run one `advisory_pre_review` and one `repo_commit`. Do not split coupled edits across commits to "make review easier" — the review cycle runs per commit, so splitting multiplies cost and can produce transiently broken intermediate states.
|
||||
|
||||
**Diagnosing blocked commits:** `review_status` shows the latest blocked attempt, its critical/advisory findings, open obligations, commit-readiness debt, `repo_commit_ready`, `retry_anchor`, and a next-step recommendation. For forensic work on a specific attempt (why a reviewer returned a given verdict, what the raw response was), pass `include_raw=true` to surface the durable `triad_raw_results` / `scope_raw_result` payload without opening the state file by hand.
|
||||
|
||||
**Obligation semantics and deduplication:**
|
||||
Open obligations accumulate across blocked commits. Distinct findings default to
|
||||
distinct obligations, but reviewers can preserve identity across retries by
|
||||
returning the same `obligation_id`; repeated blockers may also synthesize
|
||||
repo-scoped commit-readiness debt. When `review_status` shows
|
||||
`retry_anchor=commit_readiness_debt`, start from that debt record instead of
|
||||
patching one obligation at a time.
|
||||
|
||||
- **Anti-thrashing rules injected into reviewer prompts (v4.35.1):** On retry attempts (attempt ≥ 2 for triad/scope, unconditionally for advisory), open obligations are surfaced to reviewers as inert JSON data with two mandatory rules: (1) `"verdict"` field is authoritative — withdrawal notes in `"reason"` are ignored; (2) prior obligations must not be rephrased under a new item name. Advisory step 5a applies these rules unconditionally even when no obligations exist.
|
||||
|
||||
When you see similar obligations:
|
||||
- Read each one. If two obligations describe the same root cause (same file, same symbol, same fix), note this in `review_rebuttal`: `"Obligations X and Y both describe the same issue in foo.py — resolved by this commit."` You do not need to fix each separately.
|
||||
- Only rebut when you have actual code to show. Saying "these are duplicates" without a fix is not sufficient.
|
||||
- After a successful commit, open obligations are cleared and repo-scoped
|
||||
commit-readiness debt is verified automatically.
|
||||
|
||||
When reading the `Review Continuity` section: a large number of open obligations from a single blocked session (e.g. 10+ obligations) often contains significant duplication from reviewer rephrasing. Group them by file/symbol before deciding how many distinct fixes are needed.
|
||||
When reporting commit-review outcomes back to the user, enumerate critical and advisory findings individually. Preserve each finding's severity plus its identity tag (`item`, reviewer/model, scope tag, obligation id when present). Do not compress multiple findings into a generic "review failed" summary if the tool output contains structured detail.
|
||||
1. Walk `docs/CHECKLISTS.md::Pre-Commit Self-Check` honestly. The 12
|
||||
rows there catch the cheapest blocks (version sync, tests staged,
|
||||
ARCHITECTURE updated, changelog P9 limit, JS inline styles, etc.) —
|
||||
fixing them before advisory is dramatically cheaper than fixing them
|
||||
in a retry cycle.
|
||||
2. Finish all edits, run `advisory_pre_review` on the final diff,
|
||||
immediately call `repo_commit` / `repo_write_commit`. Any edit after
|
||||
advisory makes it stale. `skip_advisory_pre_review=True` is the
|
||||
audit-logged absolute bypass when the SDK truly cannot run.
|
||||
3. The commit path then runs unified triad + scope review in parallel
|
||||
against the same staged snapshot (`docs/CHECKLISTS.md` =
|
||||
reviewer-criteria SSOT; `docs/DEVELOPMENT.md` "Review & Commit
|
||||
Protocol" = procedural SSOT). On blocked findings:
|
||||
- Re-read the full diff. Group findings by root cause. Rewrite the
|
||||
plan once before the next retry — do not patch one finding at a
|
||||
time after the first block.
|
||||
- For EVERY critical or advisory finding state one of:
|
||||
`fix now` (next edit pass) / `defer` (record in scratchpad with
|
||||
file/symbol/reason and why it is not blocking) / `disagree` (rebut
|
||||
via `review_rebuttal` with one sentence of evidence). Do not
|
||||
collapse to "I'll address the top N" — there is no runtime parser
|
||||
for dispositions, the discipline is yours.
|
||||
- Use `review_rebuttal` only for findings that are factually wrong
|
||||
or technically impossible — never to argue a requested test "isn't
|
||||
needed". If the same critical finding repeats twice without new
|
||||
code, split the commit or ask.
|
||||
- Coupled multi-file edits (shared signatures, types, version
|
||||
carriers, code + VERSION + README + ARCHITECTURE) stay in ONE
|
||||
commit — splitting them multiplies review cost and can produce
|
||||
transiently broken intermediate states.
|
||||
4. Diagnostics: `review_status` shows latest attempt + open obligations
|
||||
+ commit-readiness debt + `retry_anchor`. Pass `include_raw=true`
|
||||
for the durable `triad_raw_results` / `scope_raw_result` forensic
|
||||
payload. When `retry_anchor=commit_readiness_debt` is set, start from
|
||||
the debt summary, not from individual obligations.
|
||||
5. Obligation hygiene: open obligations accumulate across blocked
|
||||
commits. Distinct findings default to distinct obligations;
|
||||
reviewers can preserve identity across retries via the same
|
||||
`obligation_id`. Anti-thrashing rules (v4.35.1) make `verdict`
|
||||
authoritative and forbid renaming a prior obligation under a new
|
||||
item name. When you see 10+ obligations from one session, group by
|
||||
file/symbol before fixing — many are reviewer rephrasings of the
|
||||
same root cause and one fix + one `review_rebuttal` collapses them.
|
||||
6. When reporting commit-review outcomes back to the creator, list
|
||||
each finding individually with its severity + identity tag (`item`,
|
||||
reviewer/model, scope tag, obligation id). Do not collapse to
|
||||
"review failed" if the tool output is structured.
|
||||
|
||||
### Change Propagation Checklist
|
||||
|
||||
|
|
@ -875,34 +866,24 @@ On every tool call I write content — my train of thought,
|
|||
not a mechanical log. What I'm doing -> why -> what I expect.
|
||||
If I change course, I say why.
|
||||
|
||||
## Versioning and Releases
|
||||
## Releases (in-loop reminder)
|
||||
|
||||
On every commit — strictly in order:
|
||||
|
||||
1. Update `VERSION` (semver).
|
||||
2. Update `pyproject.toml` version to match.
|
||||
3. Update changelog in `README.md`.
|
||||
4. Commit: `v{VERSION}: Brief description`.
|
||||
Annotated git tag is created automatically when VERSION changes in a commit.
|
||||
5. `promote_to_stable` when confident in stability.
|
||||
6. Notify the creator.
|
||||
|
||||
Prefer one coherent transformation per commit. Each commit is its own release.
|
||||
|
||||
**Release invariant:** `VERSION`, the latest git tag, the `README.md` badge, and the `ARCHITECTURE.md` header use the same author-facing spelling; `pyproject.toml` carries the PEP 440 canonical form of that same release when required.
|
||||
Version in commit messages cannot be lower than the current VERSION.
|
||||
The four-carrier version bump and the auto-tag rule are defined once in
|
||||
the **Versioning (Bible Principle 9)** section near the top of this
|
||||
prompt — re-read it instead of paraphrasing here. After a successful
|
||||
commit, the only additional step is `promote_to_stable` when the change
|
||||
has burned in, and a short message to the creator if the work is
|
||||
notable. One coherent transformation per commit; each commit is its own
|
||||
release. Commit-message version cannot be lower than current `VERSION`.
|
||||
|
||||
---
|
||||
|
||||
## Emergency Stop
|
||||
|
||||
The `/panic` command and Panic Stop button are an absolute safety mechanism.
|
||||
When triggered, ALL processes are killed immediately (workers, subprocesses,
|
||||
consciousness, evolution) and the application exits completely.
|
||||
|
||||
No code I write, no tool I call, no argument I make may prevent, delay,
|
||||
or circumvent panic execution. This is defined in BIBLE.md and exists
|
||||
outside the principle hierarchy. See: Emergency Stop Invariant.
|
||||
`/panic` and the Panic Stop button kill everything (workers, subprocesses,
|
||||
consciousness, evolution) and exit. No code, tool, or argument I produce
|
||||
may prevent, delay, or circumvent panic — see BIBLE.md "Emergency Stop
|
||||
Invariant".
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "ouroboros"
|
||||
version = "5.8.3rc4"
|
||||
version = "5.8.3rc5"
|
||||
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
|
|||
|
|
@ -220,18 +220,8 @@ def test_pid_lock_acquire_closes_fd_on_failure(tmp_path, monkeypatch):
|
|||
platform_layer.pid_lock_release(str(lock_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity — full suite must remain green after these fixes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_imports_after_bughunt_dont_raise():
|
||||
"""All modified modules must still import cleanly. Catches the kind of
|
||||
structural breakage that occurred during this very landing."""
|
||||
import ouroboros.agent # noqa: F401
|
||||
import ouroboros.context # noqa: F401
|
||||
import ouroboros.llm # noqa: F401
|
||||
import ouroboros.loop_tool_execution # noqa: F401
|
||||
import ouroboros.platform_layer # noqa: F401
|
||||
import ouroboros.tools.git # noqa: F401
|
||||
import ouroboros.tools.registry # noqa: F401
|
||||
import ouroboros.utils # noqa: F401
|
||||
# ``test_imports_after_bughunt_dont_raise`` was retired in v5.8.3-rc.5.
|
||||
# pytest itself imports every test module during collection, and
|
||||
# ``tests/test_smoke.py`` already pins the broader import surface; the
|
||||
# bughunt-specific list above duplicated coverage that pytest collection +
|
||||
# smoke already provide.
|
||||
|
|
|
|||
|
|
@ -11,52 +11,22 @@ inbound bubbles show the raw ``msg.text`` / ``msg.content`` (the plan
|
|||
preamble is intentionally visible so the user can see what was actually
|
||||
sent on the wire).
|
||||
|
||||
This file now has two complementary layers aligned with that reality:
|
||||
1. Structural (source-text) — verify the actual patterns in chat.js
|
||||
(``slice(-50)``, last-element dedup, inline ``PLAN_PREFIX + text``
|
||||
etc.). Breaks on deletion/rename.
|
||||
2. Executable (logic port) — preserve the Python ports of the
|
||||
``stripPlanPrefix`` and ``extractRecallEntries`` helpers so that if
|
||||
they ever get implemented in JS, the Python-port tests are the
|
||||
authoritative spec for their behaviour.
|
||||
These tests are structural (source-text) — verify the actual patterns in
|
||||
chat.js (``slice(-50)``, last-element dedup, inline ``PLAN_PREFIX + text``
|
||||
etc.). Breaks on deletion/rename. The previous "executable port" layer
|
||||
that shadowed never-landed JS helpers (``stripPlanPrefix``,
|
||||
``extractRecallEntries``) was retired in v5.8.3-rc.5 — see the absent-by-
|
||||
design ``stripPlanPrefix`` regression below for the canonical guard.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
CHAT_JS = pathlib.Path(__file__).parent.parent / "web" / "modules" / "chat.js"
|
||||
|
||||
_PLAN_PREFIX = (
|
||||
"Please do multi-model planning (plan_task tool) and web-search before "
|
||||
"answering or starting this task:\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
return CHAT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Portable Python ports of the JS pure helpers ─────────────────────────────
|
||||
|
||||
def _strip_plan_prefix(text: str) -> str:
|
||||
"""Python port of chat.js::stripPlanPrefix."""
|
||||
return text[len(_PLAN_PREFIX):].lstrip() if text.startswith(_PLAN_PREFIX) else text
|
||||
|
||||
|
||||
def _extract_recall_entries(messages: list, existing=None) -> list:
|
||||
"""Python port of chat.js::extractRecallEntries."""
|
||||
seen = set(existing or [])
|
||||
out = []
|
||||
for msg in messages:
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
text = _strip_plan_prefix((msg.get("text") or "").strip())
|
||||
if not text or text in seen:
|
||||
continue
|
||||
out.append(text)
|
||||
seen.add(text)
|
||||
return out
|
||||
|
||||
|
||||
# ── visibilitychange fix ──────────────────────────────────────────────────────
|
||||
|
||||
def test_chat_js_has_single_visibilitychange_listener():
|
||||
|
|
@ -276,8 +246,6 @@ def test_chat_js_saves_input_history_on_remember():
|
|||
|
||||
# ── PLAN_PREFIX application ───────────────────────────────────────────────────
|
||||
|
||||
_PLAN_PREFIX = 'Please do multi-model planning (plan_task tool) and web-search before answering or starting this task:\n\n'
|
||||
|
||||
|
||||
def test_chat_js_applies_plan_prefix_inline_in_sendmessage():
|
||||
"""``sendMessage`` applies PLAN_PREFIX inline via ``PLAN_PREFIX + text`` when
|
||||
|
|
@ -361,84 +329,20 @@ def test_chat_js_input_history_index_reset_after_seeding():
|
|||
assert "inputHistoryIndex = inputHistory.length" in src
|
||||
|
||||
|
||||
# ── Executable behavior tests (Python port of JS pure helpers) ────────────────
|
||||
# ── Absent-by-design regression: stripPlanPrefix never landed ────────────────
|
||||
|
||||
def test_strip_plan_prefix_removes_prefix():
|
||||
"""stripPlanPrefix strips the plan preamble and trims leading whitespace."""
|
||||
result = _strip_plan_prefix(_PLAN_PREFIX + "What is 2+2?")
|
||||
assert result == "What is 2+2?"
|
||||
|
||||
|
||||
def test_strip_plan_prefix_leaves_normal_text_unchanged():
|
||||
"""stripPlanPrefix does not modify messages that don't start with PLAN_PREFIX."""
|
||||
msg = "Hello, what can you do?"
|
||||
assert _strip_plan_prefix(msg) == msg
|
||||
|
||||
|
||||
def test_strip_plan_prefix_handles_empty_string():
|
||||
"""stripPlanPrefix handles empty input gracefully."""
|
||||
assert _strip_plan_prefix("") == ""
|
||||
|
||||
|
||||
def test_extract_recall_entries_filters_non_user_roles():
|
||||
"""extractRecallEntries only includes user-role messages."""
|
||||
messages = [
|
||||
{"role": "assistant", "text": "Hello"},
|
||||
{"role": "user", "text": "Hi there"},
|
||||
{"role": "system", "text": "System msg"},
|
||||
]
|
||||
result = _extract_recall_entries(messages)
|
||||
assert result == ["Hi there"]
|
||||
|
||||
|
||||
def test_extract_recall_entries_strips_plan_prefix():
|
||||
"""extractRecallEntries strips PLAN_PREFIX before adding to recall."""
|
||||
messages = [{"role": "user", "text": _PLAN_PREFIX + "Search for X"}]
|
||||
result = _extract_recall_entries(messages)
|
||||
assert result == ["Search for X"]
|
||||
|
||||
|
||||
def test_extract_recall_entries_deduplicates_against_existing():
|
||||
"""extractRecallEntries skips messages already in the existing set."""
|
||||
existing = {"already there"}
|
||||
messages = [
|
||||
{"role": "user", "text": "already there"},
|
||||
{"role": "user", "text": "new message"},
|
||||
]
|
||||
result = _extract_recall_entries(messages, existing)
|
||||
assert result == ["new message"]
|
||||
|
||||
|
||||
def test_extract_recall_entries_deduplicates_within_batch():
|
||||
"""extractRecallEntries skips duplicate messages within the same batch."""
|
||||
messages = [
|
||||
{"role": "user", "text": "hello"},
|
||||
{"role": "user", "text": "hello"},
|
||||
]
|
||||
result = _extract_recall_entries(messages)
|
||||
assert result == ["hello"]
|
||||
|
||||
|
||||
def test_extract_recall_entries_skips_empty_text():
|
||||
"""extractRecallEntries skips messages with empty or whitespace-only text."""
|
||||
messages = [
|
||||
{"role": "user", "text": ""},
|
||||
{"role": "user", "text": " "},
|
||||
{"role": "user", "text": "real message"},
|
||||
]
|
||||
result = _extract_recall_entries(messages)
|
||||
assert result == ["real message"]
|
||||
|
||||
|
||||
def test_plan_prefix_js_constant_matches_python_port():
|
||||
"""The canonical PLAN_PREFIX string in chat.js must match the Python port
|
||||
so that the Python port's ``_strip_plan_prefix`` would successfully strip
|
||||
what ``sendMessage`` actually sends on the wire."""
|
||||
def test_strip_plan_prefix_helper_remains_absent_in_chat_js():
|
||||
"""``stripPlanPrefix`` must NOT exist in chat.js — the recall/render path
|
||||
intentionally surfaces the raw wire text (plan preamble visible). If a
|
||||
future change reintroduces this helper, ``rememberInput`` and
|
||||
``syncHistory`` must be re-audited because their current contracts
|
||||
assume no stripping."""
|
||||
src = _src()
|
||||
# PLAN_PREFIX in chat.js may be written as a template literal with \n\n
|
||||
# escapes or as a regular string; accept both forms.
|
||||
assert _PLAN_PREFIX.replace("\n\n", "\\n\\n") in src or _PLAN_PREFIX in src, (
|
||||
"PLAN_PREFIX const in chat.js must match the Python port's canonical string"
|
||||
assert "stripPlanPrefix" not in src, (
|
||||
"stripPlanPrefix helper reappeared — re-audit the recall pipeline"
|
||||
)
|
||||
assert "extractRecallEntries" not in src, (
|
||||
"extractRecallEntries helper reappeared — re-audit the recall pipeline"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ def test_chat_input_field_has_no_ambient_halo():
|
|||
|
||||
# Focus state keeps the focus ring (3px solid-tone outline) so
|
||||
# accessibility / keyboard users still see "this is focused".
|
||||
assert "0 0 0 3px rgba(201, 53, 69, 0.10)" in focus_block
|
||||
# v5.8.3-rc.5 swapped the ``rgba(201, 53, 69, 0.10)`` literal for the
|
||||
# SSOT ``var(--accent-10)`` token defined in ``:root``.
|
||||
assert "0 0 0 3px var(--accent-10)" in focus_block
|
||||
assert "0 4px 24px" not in focus_block
|
||||
|
||||
|
||||
|
|
@ -462,18 +464,21 @@ def test_updates_load_versions_error_handling():
|
|||
|
||||
|
||||
def test_evolution_runtime_card_uses_crimson_border():
|
||||
"""evo-runtime-card and evo-chart-wrap must use crimson accent border, not neutral white."""
|
||||
"""evo-runtime-card and evo-chart-wrap must use the crimson accent
|
||||
border (either the literal rgba — historical — or the v5.8.3-rc.5
|
||||
SSOT ``var(--accent-*)`` token), not the neutral-white default. The
|
||||
accent token expansions all reduce to ``rgba(201, 53, 69, ...)`` so
|
||||
either form satisfies the visual-contract intent."""
|
||||
css = _read("web/style.css")
|
||||
# The rule should contain a crimson rgba border, not the old neutral white
|
||||
import re
|
||||
rule_match = re.search(
|
||||
r'\.evo-runtime-card,\s*\.evo-chart-wrap\s*\{(.+?)\}', css, re.DOTALL
|
||||
)
|
||||
assert rule_match, ".evo-runtime-card rule not found in style.css"
|
||||
rule_body = rule_match.group(1)
|
||||
# Must have a crimson border (201, 53, 69)
|
||||
assert "201, 53, 69" in rule_body, "evo-runtime-card should use crimson accent border"
|
||||
# Must NOT use the old neutral white border
|
||||
# Either the legacy literal or the new SSOT token counts as crimson.
|
||||
has_crimson = "201, 53, 69" in rule_body or "var(--accent" in rule_body
|
||||
assert has_crimson, "evo-runtime-card must use crimson accent border (literal rgba or SSOT --accent token)"
|
||||
assert "255, 255, 255, 0.08" not in rule_body, "evo-runtime-card should not use neutral white border"
|
||||
|
||||
|
||||
|
|
@ -792,39 +797,17 @@ def test_skill_review_click_guard_prevents_duplicate_posts():
|
|||
assert "reviewingSkills.add(name);" in source
|
||||
|
||||
|
||||
def test_plan_mode_send_message_accepts_plan_flag():
|
||||
"""sendMessage must accept a planMode parameter."""
|
||||
source = _read("web/modules/chat.js")
|
||||
assert "async function sendMessage(planMode = false)" in source, \
|
||||
"sendMessage must accept planMode=false as default parameter"
|
||||
|
||||
|
||||
def test_plan_mode_prefix_constant_defined():
|
||||
"""PLAN_PREFIX constant must be defined with planning instruction."""
|
||||
source = _read("web/modules/chat.js")
|
||||
assert "const PLAN_PREFIX" in source, "PLAN_PREFIX constant must be defined"
|
||||
assert "plan_task" in source, "PLAN_PREFIX should reference plan_task tool"
|
||||
assert "web-search" in source, "PLAN_PREFIX should reference web-search"
|
||||
|
||||
|
||||
def test_plan_mode_wire_text_uses_prefix():
|
||||
"""Plan mode must prepend the prefix to wire text, but not for slash commands."""
|
||||
source = _read("web/modules/chat.js")
|
||||
# Must include slash-command bypass guard
|
||||
assert "planMode && !text.startsWith('/')" in source, \
|
||||
"Plan mode must bypass PLAN_PREFIX for slash commands"
|
||||
assert "PLAN_PREFIX + text" in source, \
|
||||
"Plan mode must prepend PLAN_PREFIX to regular messages"
|
||||
|
||||
|
||||
def test_plan_mode_remember_input_uses_raw_text():
|
||||
"""rememberInput must be called before prefix is applied (no recall pollution)."""
|
||||
source = _read("web/modules/chat.js")
|
||||
# rememberInput must appear before wireText assignment
|
||||
remember_idx = source.index("rememberInput(text)")
|
||||
wire_idx = source.index("const wireText = (planMode")
|
||||
assert remember_idx < wire_idx, \
|
||||
"rememberInput must be called with raw text before wireText prefix is applied"
|
||||
# ``test_plan_mode_send_message_accepts_plan_flag``,
|
||||
# ``test_plan_mode_prefix_constant_defined``,
|
||||
# ``test_plan_mode_wire_text_uses_prefix``, and
|
||||
# ``test_plan_mode_remember_input_uses_raw_text`` were retired in
|
||||
# v5.8.3-rc.5 — all four are covered by
|
||||
# ``tests/test_chat_js_contracts.py`` (the ``PLAN_PREFIX`` const-position,
|
||||
# ``PLAN_PREFIX + text``, ``planMode && !text.startsWith('/')``, and
|
||||
# ``rememberInput(text)`` before ``wireText`` invariants live there as
|
||||
# the canonical chat.js structural pin). The DOM-elements test below
|
||||
# stays here because it asserts on the chat_logs_ui template (id /
|
||||
# class structure), which is this file's surface.
|
||||
|
||||
|
||||
def test_plan_mode_send_listener_uses_arrow_function():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
"""Guardrails for README and architecture docs after UI/routing overhaul."""
|
||||
"""Guardrails for architecture docs after UI/routing overhaul.
|
||||
|
||||
README prose pins were retired in v5.8.3-rc.5 — the README is intentionally
|
||||
allowed to evolve its marketing copy without dragging tests along; the
|
||||
ARCHITECTURE.md pins below are the load-bearing rationale-layer guards
|
||||
(P6) that must survive every doc-touch commit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
|
@ -10,15 +16,6 @@ def _read(rel: str) -> str:
|
|||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_readme_mentions_multistep_wizard_and_live_task_ui():
|
||||
readme = _read("README.md")
|
||||
|
||||
assert "shared desktop/web wizard is now multi-step" in readme
|
||||
assert "add access first, choose visible models second, set review mode third, set budget fourth" in readme
|
||||
assert "Focused Task UX" in readme
|
||||
assert "live task card" in readme
|
||||
|
||||
|
||||
def test_architecture_mentions_shared_log_grouping_and_direct_provider_review_fallback():
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
|
||||
|
|
|
|||
|
|
@ -80,57 +80,60 @@ def test_claude_code_sdk_only_no_cli_fallback():
|
|||
assert "ensure_claude_cli" not in src, "CLI install function should be gone"
|
||||
|
||||
|
||||
def test_scope_review_budget_limit():
|
||||
"""scope_review.py budget gate must sit at the unified 850K input-token budget.
|
||||
def test_review_prompt_token_budget_is_ssot():
|
||||
"""``review_helpers.REVIEW_PROMPT_TOKEN_BUDGET`` is the single source of
|
||||
truth for the unified scope/plan/deep-review input gate (850K). Bumping
|
||||
the constant must move all three call sites in lockstep so the skip
|
||||
threshold cannot silently desync between modules.
|
||||
|
||||
Note: Claude Opus 4.6 has a 1M context window SHARED between input and output.
|
||||
estimate_tokens (chars/4) under-counts by ~15%, so at gate=850K actual input
|
||||
is ≈1M tokens and output max_tokens draws from the same 1M ceiling. The skip
|
||||
path is best-effort — 850K is a conscious trade that lets scope prompts that
|
||||
previously skipped at ~778K actually run.
|
||||
Note: Claude Opus 4.6 has a 1M context window SHARED between input and
|
||||
output. ``estimate_tokens`` (chars/4) under-counts by ~15%, so at
|
||||
gate=850K actual input is ≈1M tokens and output max_tokens draws from
|
||||
the same 1M ceiling. The skip path is best-effort.
|
||||
"""
|
||||
from ouroboros.tools.review_helpers import REVIEW_PROMPT_TOKEN_BUDGET
|
||||
from ouroboros.tools.scope_review import _SCOPE_BUDGET_TOKEN_LIMIT
|
||||
assert _SCOPE_BUDGET_TOKEN_LIMIT == 850_000, (
|
||||
f"_SCOPE_BUDGET_TOKEN_LIMIT ({_SCOPE_BUDGET_TOKEN_LIMIT}) must equal 850_000 "
|
||||
"to match the unified scope/plan/deep-review input budget."
|
||||
)
|
||||
|
||||
|
||||
def test_plan_review_budget_limit():
|
||||
"""plan_review.py _PLAN_BUDGET_TOKEN_LIMIT must match the unified 850K budget."""
|
||||
from ouroboros.tools.plan_review import _PLAN_BUDGET_TOKEN_LIMIT
|
||||
assert _PLAN_BUDGET_TOKEN_LIMIT == 850_000, (
|
||||
f"_PLAN_BUDGET_TOKEN_LIMIT ({_PLAN_BUDGET_TOKEN_LIMIT}) must equal 850_000 "
|
||||
"to match the unified scope/plan/deep-review input budget."
|
||||
|
||||
assert REVIEW_PROMPT_TOKEN_BUDGET == 850_000, (
|
||||
f"REVIEW_PROMPT_TOKEN_BUDGET drifted to {REVIEW_PROMPT_TOKEN_BUDGET}; "
|
||||
"see review_helpers.py docstring before changing — call sites do "
|
||||
"not silently re-pin to 850K."
|
||||
)
|
||||
assert _SCOPE_BUDGET_TOKEN_LIMIT == REVIEW_PROMPT_TOKEN_BUDGET, (
|
||||
f"_SCOPE_BUDGET_TOKEN_LIMIT ({_SCOPE_BUDGET_TOKEN_LIMIT}) must equal "
|
||||
f"the SSOT REVIEW_PROMPT_TOKEN_BUDGET ({REVIEW_PROMPT_TOKEN_BUDGET})."
|
||||
)
|
||||
assert _PLAN_BUDGET_TOKEN_LIMIT == REVIEW_PROMPT_TOKEN_BUDGET, (
|
||||
f"_PLAN_BUDGET_TOKEN_LIMIT ({_PLAN_BUDGET_TOKEN_LIMIT}) must equal "
|
||||
f"the SSOT REVIEW_PROMPT_TOKEN_BUDGET ({REVIEW_PROMPT_TOKEN_BUDGET})."
|
||||
)
|
||||
|
||||
|
||||
def test_deep_self_review_budget_limit():
|
||||
"""deep_self_review.py pack-size gate must match the unified 850K budget
|
||||
and must gate on the FULL assembled prompt (system + user), not just the pack.
|
||||
|
||||
The deep self-review runner rejects packs whose shared estimate_tokens
|
||||
(chars/4) estimate exceeds the gate. This test pins the numeric threshold,
|
||||
the use of the shared helper, AND the requirement that the gate reflects
|
||||
the full assembled prompt — matching how scope_review and plan_review gate.
|
||||
def test_deep_self_review_budget_uses_ssot():
|
||||
"""``deep_self_review`` must gate on the SSOT constant (not a hardcoded
|
||||
literal) and must gate on the FULL assembled prompt (system + user)
|
||||
using the shared ``estimate_tokens(chars/4)`` helper, matching
|
||||
scope_review and plan_review.
|
||||
"""
|
||||
import pathlib
|
||||
src = pathlib.Path("ouroboros/deep_self_review.py").read_text(encoding="utf-8")
|
||||
assert "estimated_tokens > 850_000" in src, (
|
||||
"deep_self_review must gate on estimated_tokens > 850_000"
|
||||
assert "REVIEW_PROMPT_TOKEN_BUDGET" in src, (
|
||||
"deep_self_review must import the SSOT constant from review_helpers"
|
||||
)
|
||||
assert "Maximum is ~850,000 tokens" in src, (
|
||||
"deep_self_review error message must reference 850,000 tokens"
|
||||
assert "estimated_tokens > REVIEW_PROMPT_TOKEN_BUDGET" in src, (
|
||||
"deep_self_review must compare against the SSOT constant, not a literal"
|
||||
)
|
||||
assert "estimate_tokens(_SYSTEM_PROMPT + pack_text)" in src, (
|
||||
"deep_self_review must gate on the FULL assembled prompt "
|
||||
"(system + user) using the shared estimate_tokens(chars/4) helper, "
|
||||
"matching scope_review and plan_review. Gating on pack_text alone "
|
||||
"understates the real request size."
|
||||
"(system + user) using the shared estimate_tokens(chars/4) helper."
|
||||
)
|
||||
# Old hardcoded literals must not survive — drift would silently desync.
|
||||
assert "estimated_tokens > 850_000" not in src, (
|
||||
"deep_self_review still has the old hardcoded literal; switch to the SSOT constant"
|
||||
)
|
||||
assert "int(stats[\"total_chars\"] / 3.5)" not in src, (
|
||||
"deep_self_review must not use its old chars/3.5 estimator — it diverges "
|
||||
"from the scope/plan review chars/4 budget at the same nominal token gate"
|
||||
"deep_self_review must not use its old chars/3.5 estimator"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -341,10 +341,11 @@ class TestReviewHistoryBuilding:
|
|||
|
||||
|
||||
class TestReviewQuorumLogic:
|
||||
def test_review_models_configured(self):
|
||||
from ouroboros.config import get_review_models
|
||||
models = get_review_models()
|
||||
assert len(models) >= 2 # config.py is single source of truth
|
||||
# ``test_review_models_configured`` was removed in v5.8.3-rc.5 — the
|
||||
# ``len(get_review_models()) >= 2`` quorum assertion is already covered
|
||||
# in ``tests/test_settings_effort.py`` (3 cases). This class keeps the
|
||||
# checklist-path / loader smoke tests below which are unique to the
|
||||
# phase-7 pipeline contract.
|
||||
|
||||
def test_checklist_path_exists(self):
|
||||
review = _get_review_module()
|
||||
|
|
@ -697,23 +698,13 @@ class TestReviewEnforcementModes:
|
|||
# --- Unified review wired into commit functions ---
|
||||
|
||||
class TestReviewInCommitPipeline:
|
||||
def test_repo_commit_calls_unified_review(self):
|
||||
git_mod = _get_git_module()
|
||||
source = inspect.getsource(git_mod._repo_commit_push)
|
||||
assert "_run_reviewed_stage_cycle" in source
|
||||
shared_source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert "_run_parallel_review" in shared_source
|
||||
parallel_source = inspect.getsource(git_mod._run_parallel_review)
|
||||
assert "_run_unified_review" in parallel_source
|
||||
|
||||
def test_repo_write_commit_calls_unified_review(self):
|
||||
git_mod = _get_git_module()
|
||||
source = inspect.getsource(git_mod._repo_write_commit)
|
||||
assert "_run_reviewed_stage_cycle" in source
|
||||
shared_source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert "_run_parallel_review" in shared_source
|
||||
parallel_source = inspect.getsource(git_mod._run_parallel_review)
|
||||
assert "_run_unified_review" in parallel_source
|
||||
# ``test_repo_commit_calls_unified_review`` and
|
||||
# ``test_repo_write_commit_calls_unified_review`` were removed in
|
||||
# v5.8.3-rc.5 — both are strict subsets of
|
||||
# ``tests/test_scope_review.py::TestScopeReview::test_scope_review_wired_in_commit``
|
||||
# and ``test_repo_write_commit_not_bypass_scope`` (lines 538-560 there)
|
||||
# which additionally verify ``run_scope_review`` is reached and the
|
||||
# ``ThreadPoolExecutor`` parallelism contract holds.
|
||||
|
||||
def test_blocked_review_unstages(self):
|
||||
"""When review blocks, git reset HEAD must be called."""
|
||||
|
|
|
|||
|
|
@ -350,11 +350,26 @@ def test_triad_review_prompt_includes_architecture_md(tmp_path):
|
|||
assert "## ARCHITECTURE.md" in rendered
|
||||
|
||||
|
||||
def test_load_architecture_text_returns_empty_on_missing(tmp_path):
|
||||
"""_load_architecture_text returns empty string (not an exception) when file is absent."""
|
||||
def test_load_architecture_text_emits_explicit_omission_marker_on_missing(tmp_path):
|
||||
"""``_load_architecture_text`` must emit a visible ``[⚠️ OMISSION: ...]``
|
||||
marker (not a silent empty string) when ARCHITECTURE.md is absent.
|
||||
|
||||
DEVELOPMENT.md "No silent truncation" forbids the silent-empty-string
|
||||
fallback that the function used pre-v5.8.3-rc.5 — invisible omission
|
||||
of a core governance artifact in a triad-review prompt would let
|
||||
reviewers PASS without ever seeing the architectural rationale they
|
||||
are supposed to grade against. The function now routes through the
|
||||
SSOT ``review_helpers.load_governance_doc`` which returns an explicit
|
||||
``[⚠️ OMISSION: docs/ARCHITECTURE.md not found at <path>]`` so the
|
||||
review prompt and any downstream operator inspection see the gap.
|
||||
"""
|
||||
from ouroboros.tools.review import _load_architecture_text
|
||||
result = _load_architecture_text(tmp_path)
|
||||
assert result == "", "Missing ARCHITECTURE.md should return empty string, not raise"
|
||||
assert result.startswith("[⚠️ OMISSION:"), (
|
||||
f"Missing ARCHITECTURE.md should yield an explicit omission marker, got: {result!r}"
|
||||
)
|
||||
assert "docs/ARCHITECTURE.md" in result
|
||||
# Never raises — the function still degrades gracefully.
|
||||
|
||||
|
||||
def test_consciousness_logs_warning_when_architecture_md_missing(tmp_path):
|
||||
|
|
|
|||
|
|
@ -159,3 +159,41 @@ class TestSettingsUiGuards(unittest.TestCase):
|
|||
self.assertIn("wizard-model-suggestions", wizard)
|
||||
self.assertIn(".wizard-model-suggestions", css)
|
||||
self.assertIn("overscroll-behavior: contain;", css)
|
||||
|
||||
def test_settings_fallback_models_pin_current_config_defaults(self):
|
||||
"""``SETTINGS_FALLBACK_MODELS`` in ``settings.js`` must list every
|
||||
model id that ``ouroboros/config.py::SETTINGS_DEFAULTS`` currently
|
||||
ships as a default model lane. Without this guard, bumping a
|
||||
default model in ``config.py`` would silently strand the Settings
|
||||
UI suggestion list on a stale id; the user would still see the
|
||||
old model as a "Suggested model" pill until someone noticed.
|
||||
|
||||
This is a structural pin only — the JS list may include extra
|
||||
suggestions (direct-provider variants like ``openai::gpt-5.5``
|
||||
that are not the OpenRouter default but are still useful pills);
|
||||
we only assert that nothing currently shipped as a default is
|
||||
missing.
|
||||
"""
|
||||
from ouroboros.config import SETTINGS_DEFAULTS
|
||||
source = self._read_settings_sources()["settings"]
|
||||
# Extract the literal list block.
|
||||
block_start = source.index("const SETTINGS_FALLBACK_MODELS = [")
|
||||
block_end = source.index("];", block_start)
|
||||
block = source[block_start:block_end]
|
||||
for key in (
|
||||
"OUROBOROS_MODEL",
|
||||
"OUROBOROS_MODEL_CODE",
|
||||
"OUROBOROS_MODEL_LIGHT",
|
||||
"OUROBOROS_MODEL_FALLBACK",
|
||||
):
|
||||
default = str(SETTINGS_DEFAULTS.get(key, "")).strip()
|
||||
if not default:
|
||||
continue
|
||||
self.assertIn(
|
||||
default,
|
||||
block,
|
||||
f"SETTINGS_DEFAULTS[{key!r}] = {default!r} is not in the JS "
|
||||
f"SETTINGS_FALLBACK_MODELS suggestion list — re-sync them "
|
||||
"(SSOT discipline; in v5.8.3-rc.5 we left the JS list as "
|
||||
"a static fallback rather than emit it from the server).",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,18 +65,13 @@ def _extract_render_settings_page_body(content: str) -> str:
|
|||
return content[brace_start:] # fallback: rest of file
|
||||
|
||||
|
||||
def test_settings_ui_js_exists():
|
||||
"""The settings_ui.js module file exists."""
|
||||
assert SETTINGS_UI_JS.exists(), f"Missing: {SETTINGS_UI_JS}"
|
||||
|
||||
|
||||
def test_render_settings_page_function_present():
|
||||
"""renderSettingsPage() is exported from settings_ui.js."""
|
||||
content = SETTINGS_UI_JS.read_text(encoding="utf-8")
|
||||
assert "export function renderSettingsPage()" in content, (
|
||||
"renderSettingsPage() not found in settings_ui.js. "
|
||||
"If the function was renamed, update this test."
|
||||
)
|
||||
# ``test_settings_ui_js_exists`` and ``test_render_settings_page_function_present``
|
||||
# were retired in v5.8.3-rc.5 — both are subsumed by the assertions below.
|
||||
# ``_extract_render_settings_page_body`` returns empty when the file is
|
||||
# missing or the function body cannot be located, and the backtick /
|
||||
# bracket-backtick tests assert ``body`` is truthy with an explicit
|
||||
# message that points at the same root cause. Keeping the two tiny
|
||||
# existence tests added cost without adding signal.
|
||||
|
||||
|
||||
def test_render_settings_page_backtick_balance():
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Verifies:
|
|||
- tool_policy.py imports from capabilities (no local copy)
|
||||
- loop_tool_execution.py imports from capabilities (no local copy)
|
||||
- code_search is classified correctly
|
||||
- run_shell rejects string cmd
|
||||
- run_shell list-cmd happy path (string-cmd cascade lives in test_shell_recovery.py)
|
||||
- code_search tool works
|
||||
"""
|
||||
import inspect
|
||||
|
|
@ -219,23 +219,12 @@ def test_code_search_invalid_regex(tmp_path):
|
|||
# ---------------------------------------------------------------------------
|
||||
# run_shell string contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_shell_string_cmd_is_hard_error(tmp_path):
|
||||
"""run_shell recovers string cmd via cascade (shlex.split for plain strings)."""
|
||||
from ouroboros.tools.shell import _run_shell
|
||||
from unittest.mock import MagicMock, patch
|
||||
from subprocess import CompletedProcess
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
ctx = MagicMock(spec=ToolContext)
|
||||
ctx.repo_dir = tmp_path
|
||||
ctx.drive_logs.return_value = tmp_path
|
||||
with patch("ouroboros.tools.shell._tracked_subprocess_run",
|
||||
return_value=CompletedProcess(["echo", "hello"], 0, "hello", "")), \
|
||||
patch("ouroboros.tools.shell.load_settings", return_value={}):
|
||||
result = _run_shell(ctx, "echo hello")
|
||||
assert "SHELL_ARG_ERROR" not in result
|
||||
assert "exit_code=0" in result
|
||||
#
|
||||
# String-cmd recovery (shlex.split for plain strings, json.loads for JSON
|
||||
# arrays, ast.literal_eval for Python literals) is covered by
|
||||
# tests/test_shell_recovery.py::TestShellArgContract. This file keeps only
|
||||
# the list-cmd happy-path sibling so the capability sets module owns the
|
||||
# round-1 tool surface assertions, not the string-cascade contract itself.
|
||||
|
||||
|
||||
def test_run_shell_list_cmd_works(tmp_path):
|
||||
|
|
|
|||
177
tests/test_web_utils_ssot.py
Normal file
177
tests/test_web_utils_ssot.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""SSOT contract for shared web helpers (v5.8.3-rc.5 dedup pass).
|
||||
|
||||
``safeExternalHrefAttr``, ``renderMarkdownSafe``, ``boundedText``, and
|
||||
``fetchJson`` previously had local copies in ``marketplace.js``,
|
||||
``skills.js``, ``widgets.js``, and ``ouroboroshub.js``. They are now
|
||||
exported from ``web/modules/utils.js`` as the single source of truth so
|
||||
the URL/markdown sanitization contracts cannot drift between modules.
|
||||
|
||||
These checks are static-text guards: they pin the helpers' presence in
|
||||
``utils.js`` and the absence of duplicate function definitions in the
|
||||
consumer modules. Any reintroduction of a local copy would be caught here
|
||||
before the marketplace/skills/widgets surface diverged on a security
|
||||
boundary (e.g. a publisher-supplied ``javascript:`` href slipping through
|
||||
because one module's local helper was missing the protocol allowlist).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB_MODULES = REPO_ROOT / "web" / "modules"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (WEB_MODULES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_utils_exports_shared_helpers():
|
||||
"""``utils.js`` is the SSOT for the four helpers consolidated in v5.8.3-rc.5."""
|
||||
src = _read("utils.js")
|
||||
for sig in (
|
||||
"export function safeExternalHrefAttr(",
|
||||
"export function renderMarkdownSafe(",
|
||||
"export function boundedText(",
|
||||
"export async function fetchJson(",
|
||||
):
|
||||
assert sig in src, f"utils.js must export {sig.strip().rstrip('(')}"
|
||||
|
||||
|
||||
def test_safe_external_href_attr_blocks_unsafe_schemes():
|
||||
"""Schema allowlist must keep blocking javascript:/data:/vbscript:/mailto: hrefs."""
|
||||
src = _read("utils.js")
|
||||
block = src.split("export function safeExternalHrefAttr", 1)[1].split("export function", 1)[0]
|
||||
# Only http: and https: are allowed; mailto/javascript/data must NOT pass.
|
||||
assert "parsed.protocol === 'http:' || parsed.protocol === 'https:'" in block
|
||||
assert "escapeHtmlAttr(parsed.toString())" in block
|
||||
# Unparseable / unsafe → empty string (truthy gate at call sites).
|
||||
assert "return ''" in block
|
||||
|
||||
|
||||
def test_render_markdown_safe_strips_dangerous_tags_and_attrs():
|
||||
"""The DOMPurify allowlist must continue to ban script-bearing tags."""
|
||||
src = _read("utils.js")
|
||||
block = src.split("export function renderMarkdownSafe", 1)[1].split("export function", 1)[0]
|
||||
for forbidden_tag in ("script", "iframe", "object", "embed", "form", "input", "img"):
|
||||
assert f"'{forbidden_tag}'" in block, f"renderMarkdownSafe must FORBID_TAGS {forbidden_tag}"
|
||||
for forbidden_attr in ("style", "src", "srcset", "srcdoc"):
|
||||
assert f"'{forbidden_attr}'" in block, f"renderMarkdownSafe must FORBID_ATTR {forbidden_attr}"
|
||||
|
||||
|
||||
def test_marketplace_does_not_redeclare_shared_helpers():
|
||||
"""marketplace.js must import shared helpers, not redeclare them."""
|
||||
src = _read("marketplace.js")
|
||||
# Imports from utils.js include the four shared helpers.
|
||||
assert "renderMarkdownSafe as renderMarkdownSafeShared" in src
|
||||
assert "safeExternalHrefAttr" in src
|
||||
assert "boundedText" in src
|
||||
assert "fetchJson" in src
|
||||
# No local function declarations of the SSOT helpers.
|
||||
assert "function boundedText(" not in src, "marketplace.js must use utils.boundedText"
|
||||
assert "async function fetchJson(" not in src, "marketplace.js must use utils.fetchJson"
|
||||
# ``safeExternalUrl`` may exist as a local *alias* (`const safeExternalUrl = safeExternalHrefAttr`)
|
||||
# but not as a function definition.
|
||||
assert "function safeExternalUrl(" not in src, "marketplace.js must alias to utils.safeExternalHrefAttr"
|
||||
|
||||
|
||||
def test_skills_does_not_redeclare_shared_helpers():
|
||||
src = _read("skills.js")
|
||||
assert "boundedText" in src
|
||||
assert "safeExternalHrefAttr as safeExternalUrl" in src
|
||||
assert "function boundedText(" not in src
|
||||
assert "function safeExternalUrl(" not in src
|
||||
|
||||
|
||||
def test_widgets_uses_shared_render_markdown():
|
||||
src = _read("widgets.js")
|
||||
assert "renderMarkdownSafe" in src
|
||||
# Local declaration removed in v5.8.3-rc.5.
|
||||
assert "function renderMarkdownSafe(" not in src
|
||||
|
||||
|
||||
def test_ouroboroshub_uses_shared_fetch_json():
|
||||
src = _read("ouroboroshub.js")
|
||||
assert "fetchJson" in src
|
||||
# Local async fetchJson removed in v5.8.3-rc.5.
|
||||
assert "async function fetchJson(" not in src
|
||||
|
||||
|
||||
def test_accent_tokens_have_concrete_rgba_values():
|
||||
"""The v5.8.3-rc.5 ``--accent-*`` numbered fade tokens added to
|
||||
``web/style.css`` :root must each map to a concrete
|
||||
``rgba(201, 53, 69, X)`` value — never to ``var(--accent-XX)`` (a
|
||||
self-reference forms an invalid CSS cycle and the entire crimson
|
||||
accent system silently fails to apply at computed-value time).
|
||||
|
||||
Triad reviewers (gpt-5.5, gemini-3.1-pro, claude-opus-4.6) caught
|
||||
this exact regression in the first dry-run of v5.8.3-rc.5: a
|
||||
file-wide sed swap had also rewritten the :root definitions
|
||||
themselves, leaving every ``--accent-04: var(--accent-04);``-style
|
||||
cycle. This guard pins the fix and prevents the same regression
|
||||
class from returning silently.
|
||||
"""
|
||||
import re
|
||||
src = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
|
||||
root_match = re.search(r":root\s*\{([^}]+)\}", src, re.S)
|
||||
assert root_match, ":root block not found in web/style.css"
|
||||
root_body = root_match.group(1)
|
||||
|
||||
expected = (
|
||||
"--accent-dim",
|
||||
"--accent-glow",
|
||||
"--accent-04",
|
||||
"--accent-05",
|
||||
"--accent-08",
|
||||
"--accent-10",
|
||||
"--accent-12",
|
||||
"--accent-18",
|
||||
"--accent-22",
|
||||
"--accent-25",
|
||||
"--accent-55",
|
||||
)
|
||||
for name in expected:
|
||||
decl_match = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", root_body)
|
||||
assert decl_match, f"{name} not declared in :root"
|
||||
value = decl_match.group(1).strip()
|
||||
# The value must be a literal rgba(...) anchored on the crimson
|
||||
# accent triple; never a ``var(<itself>)`` cycle, never a ``var()``
|
||||
# to a different accent token (that pattern works in CSS but
|
||||
# would silently break this token if its referent regresses).
|
||||
assert "var(" not in value, (
|
||||
f"{name} must not reference another CSS variable (cycle / "
|
||||
f"silent-drift risk); got: {value!r}"
|
||||
)
|
||||
assert "rgba(201, 53, 69," in value, (
|
||||
f"{name} must be defined on the crimson accent triple "
|
||||
f"rgba(201, 53, 69, X); got: {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_escape_mirrors_utils():
|
||||
"""``onboarding_wizard.js`` is an IIFE bundle and cannot ``import`` from
|
||||
``utils.js`` without a bootstrap rewrite. Its local ``escapeHtml`` MUST
|
||||
therefore mirror the same chain of HTML-escape ``replace`` calls
|
||||
``escapeHtmlAttr`` performs so the security contract (replace ``&``
|
||||
first, then the five HTML metas, then backtick) cannot drift between
|
||||
the wizard and the SPA. We compare on the normalized list of
|
||||
``.replace(<from>, <to>)`` calls — indentation differs (IIFE has one
|
||||
extra wrap level) but the actual escape sequence must be identical.
|
||||
"""
|
||||
import re
|
||||
onboarding = _read("onboarding_wizard.js")
|
||||
utils = _read("utils.js")
|
||||
wizard_body = onboarding.split("function escapeHtml(value) {", 1)[1].split("}", 1)[0]
|
||||
utils_body = utils.split("export function escapeHtmlAttr(value) {", 1)[1].split("}", 1)[0]
|
||||
pattern = re.compile(r"\.replace\(([^)]+)\)")
|
||||
wizard_replaces = pattern.findall(wizard_body)
|
||||
utils_replaces = pattern.findall(utils_body)
|
||||
assert wizard_replaces == utils_replaces, (
|
||||
"onboarding_wizard.escapeHtml drifted from utils.escapeHtmlAttr "
|
||||
f"— wizard chain: {wizard_replaces}, utils chain: {utils_replaces}"
|
||||
)
|
||||
# Smoke-check that the chain still escapes ampersand FIRST (any other
|
||||
# order would double-encode the entity numbers later in the chain).
|
||||
assert wizard_replaces[0].startswith("/&/g"), (
|
||||
"ampersand replacement must be first in the chain"
|
||||
)
|
||||
|
|
@ -69,11 +69,20 @@ def test_widgets_support_declarative_schema_components():
|
|||
|
||||
|
||||
def test_widgets_escape_and_sanitize_untrusted_content():
|
||||
"""Widgets must reach the sanitised markdown helper through the v5.8.3-rc.5
|
||||
SSOT (``web/modules/utils.js::renderMarkdownSafe``); the DOMPurify
|
||||
allowlist itself moved to that module and is pinned by
|
||||
``tests/test_web_utils_ssot.py::test_render_markdown_safe_strips_dangerous_tags_and_attrs``.
|
||||
Widgets-side this test now only verifies the import and the
|
||||
escapeHtml-around-untrusted-content discipline that remains local
|
||||
(table cells, JSON dumps).
|
||||
"""
|
||||
source = _widgets_js()
|
||||
assert "function renderMarkdownSafe" in source
|
||||
assert "DOMPurify.sanitize" in source
|
||||
assert "FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'img', 'video', 'audio', 'source']" in source
|
||||
assert "FORBID_ATTR: ['style', 'src', 'srcset', 'srcdoc']" in source
|
||||
assert "renderMarkdownSafe" in source
|
||||
# Widgets must NOT redeclare the SSOT helper locally.
|
||||
assert "function renderMarkdownSafe" not in source, (
|
||||
"widgets.js must use renderMarkdownSafe from utils.js (SSOT), not a local copy"
|
||||
)
|
||||
assert "escapeHtml(JSON.stringify(value, null, 2))" in source
|
||||
assert "escapeHtml(getPath(row, c.path, ''))" in source
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { formatUsd2 } from './utils.js';
|
||||
|
||||
export function initCosts({ ws, state, mount = null, embedded = false, hostPage = 'settings', hostSubtab = 'costs' }) {
|
||||
// ``hostPage`` defaults to ``'dashboard'`` (Dashboard sub-tab migration v5.7+);
|
||||
// the legacy ``'settings'`` value is no longer passed by ``app.js``.
|
||||
export function initCosts({ ws, state, mount = null, embedded = false, hostPage = 'dashboard', hostSubtab = 'costs' }) {
|
||||
const page = document.createElement('div');
|
||||
page.id = 'page-costs';
|
||||
page.className = embedded ? 'settings-embedded-content settings-costs-panel' : 'page';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { escapeHtmlText, formatUsd2 } from './utils.js';
|
||||
|
||||
export function initEvolution({ ws, state, mount = null, embedded = false, chartOnly = false, hostPage = 'settings', hostSubtab = 'evolution' }) {
|
||||
// ``hostPage`` defaults to ``'dashboard'`` (Dashboard sub-tab migration v5.7+);
|
||||
// the legacy ``'settings'`` value is no longer passed by ``app.js``.
|
||||
export function initEvolution({ ws, state, mount = null, embedded = false, chartOnly = false, hostPage = 'dashboard', hostSubtab = 'evolution' }) {
|
||||
const page = document.createElement('div');
|
||||
page.id = 'page-evolution';
|
||||
page.className = embedded ? 'settings-embedded-content settings-evolution-panel' : 'page';
|
||||
|
|
@ -51,7 +53,7 @@ export function initEvolution({ ws, state, mount = null, embedded = false, chart
|
|||
|
||||
function isEvolutionVisible() {
|
||||
return embedded
|
||||
? state.activePage === hostPage && (hostPage === 'dashboard' ? state.dashboardActiveSubtab : state.settingsActiveSubtab) === hostSubtab
|
||||
? state.activePage === hostPage && state.dashboardActiveSubtab === hostSubtab
|
||||
: state.activePage === 'evolution';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import {
|
|||
summarizeLogEvent,
|
||||
} from './log_events.js';
|
||||
|
||||
export function initLogs({ ws, state, mount = null, embedded = false, hostPage = 'settings', hostSubtab = 'logs' }) {
|
||||
// ``hostPage`` defaults to ``'dashboard'`` because the embedded sub-tab
|
||||
// migration (v5.7+) put Logs/Costs/Evolution/Updates inside the
|
||||
// Dashboard page; the legacy ``'settings'`` value is no longer passed
|
||||
// by ``app.js``. Default updated in v5.8.3-rc.5; the legacy
|
||||
// ``state.settingsActiveSubtab`` branch has been removed because no
|
||||
// caller can reach it anymore.
|
||||
export function initLogs({ ws, state, mount = null, embedded = false, hostPage = 'dashboard', hostSubtab = 'logs' }) {
|
||||
const MAX_LOGS = 500;
|
||||
const MAX_TASK_EVENTS = 30;
|
||||
const duplicateWindowMs = 5000;
|
||||
|
|
@ -51,7 +57,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
const logEntries = page.querySelector('#log-entries');
|
||||
function isLogsVisible() {
|
||||
return embedded
|
||||
? state.activePage === hostPage && (hostPage === 'dashboard' ? state.dashboardActiveSubtab : state.settingsActiveSubtab) === hostSubtab
|
||||
? state.activePage === hostPage && state.dashboardActiveSubtab === hostSubtab
|
||||
: state.activePage === 'logs';
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +69,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
}
|
||||
|
||||
function updateVisibility(entry) {
|
||||
entry.style.display = state.activeFilters[entry.dataset.category] ? '' : 'none';
|
||||
entry.hidden = !state.activeFilters[entry.dataset.category];
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
|
|
@ -143,7 +149,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
const repeatEl = last.entry.querySelector('.log-repeat');
|
||||
if (repeatEl) {
|
||||
repeatEl.textContent = `x${last.count}`;
|
||||
repeatEl.style.display = '';
|
||||
repeatEl.hidden = false;
|
||||
}
|
||||
const tsEl = last.entry.querySelector('.log-ts');
|
||||
if (tsEl) tsEl.textContent = normalizeLogTs(evt.ts || evt.timestamp);
|
||||
|
|
@ -167,7 +173,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
<span class="log-type ${cat}">${escapeHtml(view.typeLabel)}</span>
|
||||
<span class="log-phase ${escapeHtml(view.phase || 'info')}">${escapeHtml(view.phase || 'info')}</span>
|
||||
<span class="log-headline">${escapeHtml(view.headline || 'Event')}</span>
|
||||
<span class="log-repeat" style="display:none"></span>
|
||||
<span class="log-repeat" hidden></span>
|
||||
</div>
|
||||
${metaPills(view.meta)}
|
||||
${bodyHtml}
|
||||
|
|
@ -199,7 +205,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
<span class="log-type ${escapeHtml(category)}" data-task-kind>${groupId === 'bg-consciousness' ? 'background' : 'task'}</span>
|
||||
<span class="log-phase info" data-task-phase>info</span>
|
||||
<span class="log-headline" data-task-headline>Task activity</span>
|
||||
<span class="log-repeat" data-task-count style="display:none"></span>
|
||||
<span class="log-repeat" data-task-count hidden></span>
|
||||
</div>
|
||||
<div class="log-task-summary" data-task-summary></div>
|
||||
<details class="log-task-details">
|
||||
|
|
@ -231,7 +237,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
<span class="log-ts">${escapeHtml(item.ts)}</span>
|
||||
<span class="log-phase ${escapeHtml(item.phase || 'info')}">${escapeHtml(item.phase || 'info')}</span>
|
||||
<span class="log-headline">${escapeHtml(item.headline)}</span>
|
||||
<span class="log-repeat" style="${item.count > 1 ? '' : 'display:none'}">${item.count > 1 ? `x${item.count}` : ''}</span>
|
||||
<span class="log-repeat"${item.count > 1 ? '' : ' hidden'}>${item.count > 1 ? `x${item.count}` : ''}</span>
|
||||
</div>
|
||||
${metaPills(item.meta)}
|
||||
${item.body ? `<div class="log-body">${escapeHtml(item.body)}</div>` : ''}
|
||||
|
|
@ -269,7 +275,7 @@ export function initLogs({ ws, state, mount = null, embedded = false, hostPage =
|
|||
record.phase.className = `log-phase ${view.phase || 'info'}`;
|
||||
record.headline.textContent = view.headline || 'Task activity';
|
||||
record.count.textContent = `x${record.events}`;
|
||||
record.count.style.display = record.events > 1 ? '' : 'none';
|
||||
record.count.hidden = record.events <= 1;
|
||||
record.summary.innerHTML = metaPills([
|
||||
groupId === 'bg-consciousness' ? 'background' : `task=${groupId}`,
|
||||
...view.meta,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@ import {
|
|||
startLifecyclePoller,
|
||||
} from './lifecycle_card.js';
|
||||
import { openConfirmDialog } from './confirm_dialog.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
import {
|
||||
boundedText,
|
||||
escapeHtmlAttr as escapeHtml,
|
||||
fetchJson,
|
||||
renderMarkdownSafe as renderMarkdownSafeShared,
|
||||
safeExternalHrefAttr,
|
||||
} from './utils.js';
|
||||
|
||||
function isRateLimitError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
|
|
@ -32,88 +38,21 @@ function installErrorCopy(message) {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render an untrusted Markdown string from the registry as sanitised HTML.
|
||||
*
|
||||
* The vendored ``marked`` (parser) + ``DOMPurify`` (sanitiser) globals are
|
||||
* loaded at page boot from ``index.html``. We pass marked's output through
|
||||
* DOMPurify with a conservative allowlist that bans every script-bearing
|
||||
* tag and any ``javascript:`` / ``data:`` URLs in attributes.
|
||||
*
|
||||
* If either library is missing (e.g. the operator runs against an older
|
||||
* cached ``index.html``), we fall back to a plain ``<pre><code>`` block —
|
||||
* still safe because the HTML is escaped, just not styled as Markdown.
|
||||
*/
|
||||
// ``renderMarkdownSafe`` and ``safeExternalUrl`` previously had local
|
||||
// copies here. They live in ``utils.js`` now (single source of truth for
|
||||
// publisher-supplied markdown + untrusted href validation across
|
||||
// marketplace.js, skills.js, widgets.js, and ouroboroshub.js). We keep
|
||||
// thin local aliases so the marketplace-specific defaults — empty-state
|
||||
// HTML and the ``marketplace-skillmd`` fallback ``<pre>`` class — stay
|
||||
// declared next to the call sites that depend on them.
|
||||
function renderMarkdownSafe(rawMd) {
|
||||
const text = String(rawMd ?? '');
|
||||
if (!text) return '<div class="muted"><i>empty</i></div>';
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
return `<pre class="marketplace-skillmd"><code>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
try {
|
||||
// ``async: false`` forces a synchronous string return.
|
||||
// ``mangle`` and ``headerIds`` were removed in marked@5.0.0
|
||||
// (we vendor v12.0.2), so passing them here is a no-op; the
|
||||
// explicit options below are the actually-honored set.
|
||||
const rendered = marked.parse(text, {
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
});
|
||||
// ``img`` is forbidden so a malicious publisher cannot ship a
|
||||
// SKILL.md with ``<img src="https://attacker.com/track.png">``
|
||||
// and use the marketplace preview modal as a tracking-pixel
|
||||
// beacon. The preview is meant to be a *static* description of
|
||||
// the skill — anything that reaches out to a remote host
|
||||
// breaks that contract. ``style`` is refused for the same
|
||||
// reason. ``href`` survives but DOMPurify's default URI regex
|
||||
// restricts it to safe schemes.
|
||||
//
|
||||
// NOTE: on-event attributes (onclick, onerror, etc.) are
|
||||
// already blocked by DOMPurify's default ALLOWED_ATTR
|
||||
// allowlist, NOT by an explicit denylist (the ``'on*'`` token
|
||||
// was misleading dead code — DOMPurify v3.1.0 does exact-match
|
||||
// attribute lookups, not glob expansion). If a future
|
||||
// contributor adds an attribute via ``ADD_ATTR``, they must
|
||||
// re-verify the on-handler protection still holds — DOMPurify
|
||||
// does NOT reapply the on-handler rule to attributes that
|
||||
// ADD_ATTR explicitly admits.
|
||||
return DOMPurify.sanitize(rendered, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'img', 'video', 'audio', 'source'],
|
||||
FORBID_ATTR: ['style', 'srcset', 'srcdoc'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('marketplace: markdown render failed', err);
|
||||
return `<pre class="marketplace-skillmd"><code>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
return renderMarkdownSafeShared(rawMd, {
|
||||
emptyHtml: '<div class="muted"><i>empty</i></div>',
|
||||
preClass: 'marketplace-skillmd',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate that an untrusted URL string uses a safe scheme before
|
||||
* rendering it as an `<a href="...">` target. Registry-supplied
|
||||
* homepage / website fields can carry `javascript:` / `data:` /
|
||||
* `vbscript:` payloads that escapeHtml does NOT neutralise — the
|
||||
* browser decodes the entity escapes inside `href` BEFORE scheme
|
||||
* parsing, so a `javascript:fetch(...)` payload still executes on
|
||||
* click. Returns the (escaped) URL when the scheme is http/https,
|
||||
* empty string otherwise.
|
||||
*/
|
||||
function safeExternalUrl(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return escapeHtml(parsed.toString());
|
||||
}
|
||||
} catch {
|
||||
// Not a parseable absolute URL — refuse rather than guessing
|
||||
// (a relative path in homepage doesn't make sense anyway).
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const safeExternalUrl = safeExternalHrefAttr;
|
||||
|
||||
|
||||
function formatNumber(value) {
|
||||
|
|
@ -178,11 +117,6 @@ function topReviewFinding(installed) {
|
|||
return `${verdict ? `${verdict} ` : ''}${label}: ${reason}`.trim();
|
||||
}
|
||||
|
||||
function boundedText(value, maxLen = 1200) {
|
||||
const text = String(value ?? '');
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}…[truncated]` : text;
|
||||
}
|
||||
|
||||
function lifecycleFor(summary, installed, pending) {
|
||||
if (pending) {
|
||||
if (pending.failed === true) {
|
||||
|
|
@ -472,24 +406,11 @@ function showStatus(host, message, tone) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Network helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async function fetchJson(url, init) {
|
||||
const resp = await fetch(url, init);
|
||||
let body = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch (err) {
|
||||
body = { error: `non-json response (HTTP ${resp.status})` };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = new Error(body?.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
//
|
||||
// ``fetchJson`` lives in ``utils.js`` (single source of truth shared with
|
||||
// ouroboroshub.js). The contract: parsed body always returned; non-2xx
|
||||
// throws ``Error`` with ``err.status`` (used here for 429 retry handling)
|
||||
// and ``err.body``.
|
||||
|
||||
|
||||
async function loadInstalled({ signal: externalSignal } = {}) {
|
||||
|
|
@ -1263,6 +1184,3 @@ export function initMarketplace(pane) {
|
|||
|
||||
refresh();
|
||||
}
|
||||
|
||||
|
||||
export default { initMarketplace };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
(() => {
|
||||
// ``escapeHtml`` here is intentionally a verbatim mirror of
|
||||
// ``escapeHtmlAttr`` in ``web/modules/utils.js``. The onboarding
|
||||
// wizard ships as a self-contained IIFE bundle (loaded by both the
|
||||
// pywebview launcher and the server-rendered web overlay), not as
|
||||
// an ES module, so a real ``import`` would force a bootstrap
|
||||
// restructuring beyond the scope of v5.8.3-rc.5. The drift is
|
||||
// pinned by ``tests/test_web_utils_ssot.py::
|
||||
// test_onboarding_escape_mirrors_utils`` so any divergence on a
|
||||
// security boundary fails immediately.
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
|
|
@ -368,7 +377,7 @@
|
|||
|
||||
function syncClaudeCliVisibility() {
|
||||
const card = document.getElementById('wizard-claude-card');
|
||||
if (card) card.style.display = shouldShowClaudeCliCta() ? '' : 'none';
|
||||
if (card) card.hidden = !shouldShowClaudeCliCta();
|
||||
renderClaudeCliStatus();
|
||||
}
|
||||
|
||||
|
|
@ -377,7 +386,7 @@
|
|||
const statusEl = document.getElementById('wizard-claude-status');
|
||||
const installButton = document.getElementById('wizard-claude-install');
|
||||
const skipButton = document.getElementById('wizard-claude-skip');
|
||||
if (card) card.style.display = shouldShowClaudeCliCta() ? '' : 'none';
|
||||
if (card) card.hidden = !shouldShowClaudeCliCta();
|
||||
if (statusEl) {
|
||||
statusEl.textContent = state.claudeCliStatusText || 'Checking Claude runtime...';
|
||||
statusEl.dataset.tone = state.claudeCliTone || 'muted';
|
||||
|
|
@ -405,7 +414,7 @@
|
|||
if (stopButton) stopButton.disabled = !state.localRuntimeReady;
|
||||
if (testButton) testButton.disabled = !state.localRuntimeReady;
|
||||
if (resultEl) {
|
||||
resultEl.style.display = state.localTestResult ? 'block' : 'none';
|
||||
resultEl.hidden = !state.localTestResult;
|
||||
resultEl.dataset.tone = state.localTestTone || 'muted';
|
||||
resultEl.textContent = state.localTestResult || '';
|
||||
}
|
||||
|
|
@ -471,7 +480,7 @@
|
|||
|
||||
function renderClaudeCliControls() {
|
||||
return `
|
||||
<div class="panel-card" id="wizard-claude-card" style="${shouldShowClaudeCliCta() ? '' : 'display:none;'}">
|
||||
<div class="panel-card" id="wizard-claude-card"${shouldShowClaudeCliCta() ? '' : ' hidden'}>
|
||||
<h3>Claude Runtime</h3>
|
||||
<p>Claude runtime powers delegated code editing and advisory review. It is managed automatically by the app.</p>
|
||||
<div class="wizard-runtime-strip">
|
||||
|
|
|
|||
|
|
@ -7,18 +7,10 @@ import {
|
|||
setPending,
|
||||
startLifecyclePoller,
|
||||
} from './lifecycle_card.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
|
||||
async function fetchJson(url, init) {
|
||||
const resp = await fetch(url, init);
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(body.error || `HTTP ${resp.status}`);
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
import {
|
||||
escapeHtmlAttr as escapeHtml,
|
||||
fetchJson,
|
||||
} from './utils.js';
|
||||
|
||||
|
||||
function lifecycleFor(installed, pending) {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,14 @@ function collectSecretValue(id, body) {
|
|||
if (value && !value.includes('...')) body[settingKey] = value;
|
||||
}
|
||||
|
||||
// Suggestion pills for the model picker. Must include every default
|
||||
// shipped in ``ouroboros/config.py::SETTINGS_DEFAULTS`` for the four
|
||||
// model lanes (main / code / light / fallback) — pinned by
|
||||
// ``tests/test_settings_ui_guards.py::
|
||||
// test_settings_fallback_models_pin_current_config_defaults`` so a
|
||||
// future config bump cannot silently strand the UI on a stale id.
|
||||
// Extra direct-provider variants (``openai::gpt-5.5``, etc.) stay as
|
||||
// useful pills even when they are not the OpenRouter default.
|
||||
const SETTINGS_FALLBACK_MODELS = [
|
||||
'anthropic::claude-opus-4-6',
|
||||
'anthropic::claude-sonnet-4-6',
|
||||
|
|
@ -171,6 +179,7 @@ const SETTINGS_FALLBACK_MODELS = [
|
|||
'openai::gpt-5.5-mini',
|
||||
'openai/gpt-5.5',
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'google/gemini-3.1-pro-preview',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { initMarketplace } from './marketplace.js';
|
|||
import { initOuroborosHub } from './ouroboroshub.js';
|
||||
import { renderPageHeader, renderTabStrip } from './page_header.js';
|
||||
import { openConfirmDialog } from './confirm_dialog.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
import {
|
||||
boundedText,
|
||||
escapeHtmlAttr as escapeHtml,
|
||||
safeExternalHrefAttr as safeExternalUrl,
|
||||
} from './utils.js';
|
||||
|
||||
const SKILLS_ICON = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M3 12h18"/><path d="M5 5l14 14"/><path d="M19 5L5 19"/></svg>';
|
||||
const SKILLS_TABS = [
|
||||
|
|
@ -247,21 +251,6 @@ function extensionLiveNote(skill) {
|
|||
}
|
||||
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return escapeHtml(parsed.toString());
|
||||
}
|
||||
} catch {
|
||||
// Not a parseable absolute URL — refuse rather than guessing.
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
function renderProvenanceBlock(prov) {
|
||||
if (!prov || typeof prov !== 'object') return '';
|
||||
const rows = [];
|
||||
|
|
@ -753,11 +742,6 @@ function showBanner(message, tone) {
|
|||
setTimeout(() => banner.remove(), 6000);
|
||||
}
|
||||
|
||||
function boundedText(value, maxLen = 1200) {
|
||||
const text = String(value ?? '');
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}…[truncated]` : text;
|
||||
}
|
||||
|
||||
function buildHealPrompt(skill) {
|
||||
const findings = Array.isArray(skill.review_findings) ? skill.review_findings : [];
|
||||
const diagnostics = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
|
||||
export function initUpdates({ mount, hostPage = 'settings', hostSubtab = 'updates', state = {} }) {
|
||||
// ``hostPage`` defaults to ``'dashboard'`` (Dashboard sub-tab migration v5.7+);
|
||||
// the legacy ``'settings'`` value is no longer passed by ``app.js``.
|
||||
export function initUpdates({ mount, hostPage = 'dashboard', hostSubtab = 'updates', state = {} }) {
|
||||
const host = mount || document.getElementById('content');
|
||||
const page = document.createElement('div');
|
||||
page.id = 'page-updates';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,115 @@ export function safeExternalUrl(value) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an untrusted URL string for use as an `<a href="...">` attribute
|
||||
* value. Unlike ``safeExternalUrl`` (which returns ``#`` for bad input —
|
||||
* suitable for inline markdown anchors that always render), this helper
|
||||
* returns the empty string so callers can use the result as a truthy gate:
|
||||
*
|
||||
* const href = safeExternalHrefAttr(value);
|
||||
* if (href) html += `<a href="${href}" target="_blank" rel="noopener">…</a>`;
|
||||
*
|
||||
* Only ``http:`` / ``https:`` schemes survive (no ``mailto:``, no
|
||||
* ``javascript:`` / ``data:``). The returned string is HTML-attribute
|
||||
* escaped so it is safe to interpolate directly into template literals.
|
||||
*/
|
||||
export function safeExternalHrefAttr(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return escapeHtmlAttr(parsed.toString());
|
||||
}
|
||||
} catch {
|
||||
// Not a parseable absolute URL — refuse rather than guessing.
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate untrusted text to ``maxLen`` chars with a visible
|
||||
* ``…[truncated]`` marker so downstream consumers (cards, modals,
|
||||
* tooltips, log-finding excerpts) cannot smuggle multi-megabyte
|
||||
* publisher payloads into the DOM.
|
||||
*/
|
||||
export function boundedText(value, maxLen = 1200) {
|
||||
const text = String(value ?? '');
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}…[truncated]` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared JSON fetch wrapper. Behaviour:
|
||||
*
|
||||
* - Always parses the response body as JSON. If the body is not valid
|
||||
* JSON (HTML 502 page from a misbehaving proxy, etc.) it is replaced
|
||||
* with ``{ error: 'non-json response (HTTP <code>)' }`` so callers do
|
||||
* not need a separate try/catch around ``resp.json()``.
|
||||
* - On non-2xx responses, throws ``Error`` with ``err.status`` (HTTP
|
||||
* code, useful for 429-aware retry logic) and ``err.body`` (the
|
||||
* parsed JSON payload, useful for surfacing structured error fields).
|
||||
*/
|
||||
export async function fetchJson(url, init) {
|
||||
const resp = await fetch(url, init);
|
||||
let body = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
body = { error: `non-json response (HTTP ${resp.status})` };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = new Error(body?.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Markdown (via the vendored ``marked``) and run the result
|
||||
* through ``DOMPurify`` with a conservative allowlist that bans every
|
||||
* script-bearing tag and any ``style``/``src``/``srcset``/``srcdoc``
|
||||
* attribute. Used by extension widgets and the marketplace preview
|
||||
* modal — both render publisher-supplied markdown that must NOT be able
|
||||
* to ship a tracking-pixel ``<img>``, smuggle a ``<script>``, or pivot
|
||||
* to a remote host through ``<iframe>``.
|
||||
*
|
||||
* If either ``marked`` or ``DOMPurify`` is missing (e.g. the user is on
|
||||
* a stale cached ``index.html``), falls back to a plain
|
||||
* ``<pre><code>``-escaped rendering — still safe, just unstyled.
|
||||
*
|
||||
* Options:
|
||||
* ``emptyHtml`` — what to return for empty input (default ``''``).
|
||||
* ``preClass`` — CSS class added to the fallback ``<pre>`` element
|
||||
* (e.g. ``'marketplace-skillmd'``).
|
||||
*/
|
||||
export function renderMarkdownSafe(rawMd, { emptyHtml = '', preClass = '' } = {}) {
|
||||
const text = String(rawMd ?? '');
|
||||
if (!text) return emptyHtml;
|
||||
const preAttr = preClass ? ` class="${preClass}"` : '';
|
||||
const fallback = `<pre${preAttr}><code>${escapeHtmlText(text)}</code></pre>`;
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const rendered = marked.parse(text, {
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
});
|
||||
return DOMPurify.sanitize(rendered, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'img', 'video', 'audio', 'source'],
|
||||
FORBID_ATTR: ['style', 'src', 'srcset', 'srcdoc'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('renderMarkdownSafe: markdown render failed', err);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(value) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = String(value ?? '');
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
import { renderPageHeader } from './page_header.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
import {
|
||||
escapeHtmlAttr as escapeHtml,
|
||||
renderMarkdownSafe,
|
||||
} from './utils.js';
|
||||
|
||||
const WIDGETS_ICON = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>';
|
||||
|
||||
function renderMarkdownSafe(rawMd) {
|
||||
const text = String(rawMd ?? '');
|
||||
if (!text) return '';
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
try {
|
||||
const rendered = marked.parse(text, { async: false, gfm: true, breaks: false });
|
||||
return DOMPurify.sanitize(rendered, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'img', 'video', 'audio', 'source'],
|
||||
FORBID_ATTR: ['style', 'src', 'srcset', 'srcdoc'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('widgets: markdown render failed', err);
|
||||
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
function pageTemplate() {
|
||||
return `
|
||||
<section class="page" id="page-widgets">
|
||||
|
|
|
|||
233
web/style.css
233
web/style.css
|
|
@ -11,6 +11,21 @@
|
|||
--accent-light: #f07a86;
|
||||
--accent-dim: rgba(201, 53, 69, 0.15);
|
||||
--accent-glow: rgba(201, 53, 69, 0.08);
|
||||
/* Numbered fade tokens for the accent — used to retire 50+ hardcoded
|
||||
``rgba(201, 53, 69, 0.XX)`` literals scattered across the file in
|
||||
v5.8.3-rc.5. Keep tokens as multiples of 0.02 so future tweaks stay
|
||||
expressible without re-introducing literals. The naming uses the
|
||||
integer percentage of the alpha channel for intuitive pairing
|
||||
(``--accent-04`` → ``rgba(..., 0.04)``). */
|
||||
--accent-04: rgba(201, 53, 69, 0.04);
|
||||
--accent-05: rgba(201, 53, 69, 0.05);
|
||||
--accent-08: rgba(201, 53, 69, 0.08);
|
||||
--accent-10: rgba(201, 53, 69, 0.10);
|
||||
--accent-12: rgba(201, 53, 69, 0.12);
|
||||
--accent-18: rgba(201, 53, 69, 0.18);
|
||||
--accent-22: rgba(201, 53, 69, 0.22);
|
||||
--accent-25: rgba(201, 53, 69, 0.25);
|
||||
--accent-55: rgba(201, 53, 69, 0.55);
|
||||
--blue: #3b82f6;
|
||||
--blue-dim: rgba(30, 64, 175, 0.3);
|
||||
--amber: #f59e0b;
|
||||
|
|
@ -22,6 +37,10 @@
|
|||
--divider: rgba(255, 255, 255, 0.06);
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 8px;
|
||||
/* ``--radius-md`` (10 px) is the in-between size used by chips, log
|
||||
repeat counters, and page-fade rules. Pre-v5.8.3-rc.5 these were
|
||||
hardcoded ``border-radius: 10px`` literals. */
|
||||
--radius-md: 10px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
|
|
@ -106,8 +125,8 @@ body {
|
|||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
width: 64px;
|
||||
border: 1px solid rgba(201, 53, 69, 0.08);
|
||||
background: rgba(201, 53, 69, 0.04);
|
||||
border: 1px solid var(--accent-08);
|
||||
background: var(--accent-04);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
|
@ -137,19 +156,19 @@ body {
|
|||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
border-color: rgba(201, 53, 69, 0.18);
|
||||
background: var(--accent-10);
|
||||
border-color: var(--accent-18);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 2px 12px rgba(201, 53, 69, 0.08);
|
||||
box-shadow: 0 2px 12px var(--accent-08);
|
||||
}
|
||||
.nav-btn.active {
|
||||
background: rgba(201, 53, 69, 0.12);
|
||||
border-color: rgba(201, 53, 69, 0.25);
|
||||
background: var(--accent-12);
|
||||
border-color: var(--accent-25);
|
||||
color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 16px rgba(201, 53, 69, 0.10),
|
||||
inset 0 0 12px rgba(201, 53, 69, 0.05);
|
||||
0 0 16px var(--accent-10),
|
||||
inset 0 0 12px var(--accent-05);
|
||||
}
|
||||
|
||||
#divider {
|
||||
|
|
@ -248,7 +267,7 @@ body {
|
|||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--divider);
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +336,7 @@ body {
|
|||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
|
@ -332,8 +351,8 @@ body {
|
|||
}
|
||||
|
||||
.files-entry.selected {
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
box-shadow: inset 0 0 0 1px rgba(201, 53, 69, 0.22);
|
||||
background: var(--accent-10);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-22);
|
||||
}
|
||||
|
||||
.files-entry-icon {
|
||||
|
|
@ -366,7 +385,7 @@ body {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(201, 53, 69, 0.05), transparent 120px),
|
||||
linear-gradient(180deg, var(--accent-05), transparent 120px),
|
||||
rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
|
|
@ -425,7 +444,7 @@ body {
|
|||
.files-editor-name {
|
||||
width: 100%;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -454,7 +473,7 @@ body {
|
|||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +481,7 @@ body {
|
|||
width: 100%;
|
||||
min-height: 70vh;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
|
|
@ -484,9 +503,9 @@ body {
|
|||
|
||||
.files-drop-card {
|
||||
padding: 22px 28px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(201, 53, 69, 0.55);
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px dashed var(--accent-55);
|
||||
background: var(--accent-10);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
|
@ -498,7 +517,7 @@ body {
|
|||
position: fixed;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--divider);
|
||||
background: rgba(18, 20, 26, 0.96);
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||
|
|
@ -508,7 +527,7 @@ body {
|
|||
.files-context-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
|
|
@ -552,7 +571,7 @@ body {
|
|||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(201, 53, 69, 0.10), transparent 100px),
|
||||
linear-gradient(180deg, var(--accent-10), transparent 100px),
|
||||
rgba(18, 20, 26, 0.98);
|
||||
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
|
@ -574,7 +593,7 @@ body {
|
|||
width: 100%;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 11px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -853,7 +872,7 @@ body {
|
|||
/* Status badge */
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(26, 21, 32, 0.78);
|
||||
|
|
@ -974,7 +993,7 @@ body {
|
|||
}
|
||||
|
||||
.chat-bubble.assistant {
|
||||
background: linear-gradient(135deg, rgba(201, 53, 69, 0.18), rgba(120, 24, 36, 0.18));
|
||||
background: linear-gradient(135deg, var(--accent-18), rgba(120, 24, 36, 0.18));
|
||||
border: 1px solid rgba(240, 122, 134, 0.24);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 214, 220, 0.05);
|
||||
align-self: flex-start;
|
||||
|
|
@ -1006,7 +1025,7 @@ body {
|
|||
|
||||
.chat-photo {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -1079,16 +1098,16 @@ body {
|
|||
max-width: calc(100% - 20%);
|
||||
margin-right: 20%;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(201, 53, 69, 0.15);
|
||||
background: rgba(201, 53, 69, 0.04);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
background: var(--accent-04);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), inset 0 1px 0 rgba(255, 200, 210, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-live-card[data-finished="1"] {
|
||||
border-color: rgba(201, 53, 69, 0.08);
|
||||
border-color: var(--accent-08);
|
||||
background: rgba(201, 53, 69, 0.02);
|
||||
}
|
||||
|
||||
|
|
@ -1168,7 +1187,7 @@ body {
|
|||
.chat-live-line-phase.boot {
|
||||
color: rgba(248, 130, 140, 0.95);
|
||||
border-color: rgba(201, 53, 69, 0.24);
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
background: var(--accent-10);
|
||||
}
|
||||
|
||||
/* Active (non-finished) live card: pulse the Working/Thinking phase badge */
|
||||
|
|
@ -1229,7 +1248,7 @@ body {
|
|||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(201, 53, 69, 0.55);
|
||||
background: var(--accent-55);
|
||||
animation: typing-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
@ -1313,7 +1332,7 @@ body {
|
|||
}
|
||||
|
||||
.chat-live-line.expandable {
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
|
|
@ -1761,7 +1780,7 @@ body {
|
|||
border-color: rgba(232, 93, 111, 0.4);
|
||||
background: rgba(26, 21, 32, 0.85);
|
||||
/* Focus ring only — no ambient halo. */
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 0 3px rgba(201, 53, 69, 0.10);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 0 3px var(--accent-10);
|
||||
}
|
||||
#chat-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
|
|
@ -1801,10 +1820,10 @@ body {
|
|||
}
|
||||
.chat-send-inline:hover {
|
||||
opacity: 1;
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
background: var(--accent-10);
|
||||
}
|
||||
.chat-send-inline:active {
|
||||
background: rgba(201, 53, 69, 0.18);
|
||||
background: var(--accent-18);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
@ -1840,7 +1859,7 @@ body {
|
|||
.chat-send-chevron:hover,
|
||||
.chat-send-chevron.active {
|
||||
opacity: 1;
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
background: var(--accent-10);
|
||||
}
|
||||
.chat-send-group[data-send-mode="plan"] .chat-send-chevron {
|
||||
color: var(--amber);
|
||||
|
|
@ -1888,7 +1907,7 @@ body {
|
|||
opacity: 0.85;
|
||||
}
|
||||
.chat-send-dropdown-item:hover {
|
||||
background: rgba(201, 53, 69, 0.12);
|
||||
background: var(--accent-12);
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -1911,7 +1930,7 @@ body {
|
|||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
border: 1px solid rgba(201, 53, 69, 0.08);
|
||||
border: 1px solid var(--accent-08);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
transition: border-color 0.3s;
|
||||
|
|
@ -1954,7 +1973,7 @@ body {
|
|||
.toggle {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -2003,7 +2022,7 @@ body {
|
|||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 0 0 3px rgba(201, 53, 69, 0.08);
|
||||
box-shadow: 0 0 0 3px var(--accent-08);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
|
|
@ -2019,7 +2038,7 @@ body {
|
|||
border-color: rgba(201, 53, 69, 0.20);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: rgba(201, 53, 69, 0.25);
|
||||
background: var(--accent-25);
|
||||
border-color: rgba(232, 93, 111, 0.35);
|
||||
}
|
||||
.btn-secondary {
|
||||
|
|
@ -2167,7 +2186,7 @@ body {
|
|||
|
||||
.filter-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--divider);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
|
@ -2197,7 +2216,7 @@ body {
|
|||
|
||||
.log-entry {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
|
@ -2250,8 +2269,8 @@ body {
|
|||
.log-entry .log-phase.working,
|
||||
.log-entry .log-phase.thinking {
|
||||
color: rgba(248, 130, 140, 0.95);
|
||||
border-color: rgba(201, 53, 69, 0.25);
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
border-color: var(--accent-25);
|
||||
background: var(--accent-10);
|
||||
}
|
||||
|
||||
.log-entry .log-phase.done,
|
||||
|
|
@ -2338,7 +2357,7 @@ body {
|
|||
.log-raw {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
|
|
@ -2354,7 +2373,7 @@ body {
|
|||
.log-type.consciousness { color: #d68cff; }
|
||||
|
||||
.log-task-card {
|
||||
border-color: rgba(201, 53, 69, 0.12);
|
||||
border-color: var(--accent-12);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(201, 53, 69, 0.07), rgba(255,255,255,0.01)),
|
||||
rgba(255,255,255,0.015);
|
||||
|
|
@ -2387,7 +2406,7 @@ body {
|
|||
|
||||
.log-task-event {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
display: flex;
|
||||
|
|
@ -2398,7 +2417,7 @@ body {
|
|||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(201, 53, 69, 0.2); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(201, 53, 69, 0.2); border-radius: var(--radius-xs); }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(201, 53, 69, 0.35); }
|
||||
|
||||
/* Reconnecting overlay */
|
||||
|
|
@ -2503,9 +2522,9 @@ body {
|
|||
.evo-runtime-card,
|
||||
.evo-chart-wrap {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(201, 53, 69, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
linear-gradient(180deg, var(--accent-05), rgba(255, 255, 255, 0.02)),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(201, 53, 69, 0.12);
|
||||
border: 1px solid var(--accent-12);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
|
|
@ -2649,7 +2668,7 @@ body {
|
|||
.costs-budget-card {
|
||||
background: rgba(26, 21, 32, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(201, 53, 69, 0.18);
|
||||
border: 1px solid var(--accent-18);
|
||||
border-radius: 18px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
|
|
@ -2752,37 +2771,12 @@ body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.dashboard-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-tab {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 9px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-tab:hover,
|
||||
.dashboard-tab.active {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(232, 93, 111, 0.4);
|
||||
background: rgba(232, 93, 111, 0.12);
|
||||
}
|
||||
/* ``.dashboard-tabs`` and ``.dashboard-tab`` previously redeclared the
|
||||
same flex/pill styling already covered by ``.app-tab-strip`` and
|
||||
``.app-tab`` (lines 662-696). Both class names still ride along with
|
||||
the JS query selectors used by ``dashboard.js`` (so the layout
|
||||
selectors remain stable), but the styling now inherits from the
|
||||
shared ``.app-tab*`` rules — no duplicate CSS declaration. */
|
||||
|
||||
.dashboard-panels,
|
||||
.dashboard-panel {
|
||||
|
|
@ -2917,7 +2911,7 @@ body {
|
|||
* card and folds everything else into "Show details". */
|
||||
.skills-card {
|
||||
border: 1px solid rgba(250, 250, 250, 0.08);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
display: flex;
|
||||
|
|
@ -3101,7 +3095,7 @@ body {
|
|||
|
||||
.skills-lock-hint[role="button"]:focus-visible,
|
||||
.skills-status-chip[role="button"]:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(201, 53, 69, 0.55);
|
||||
box-shadow: 0 0 0 2px var(--accent-55);
|
||||
}
|
||||
|
||||
.skills-lock-hint::before {
|
||||
|
|
@ -3118,7 +3112,7 @@ body {
|
|||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 53, 69, 0.24);
|
||||
background: rgba(201, 53, 69, 0.08);
|
||||
background: var(--accent-08);
|
||||
color: rgba(255, 220, 226, 0.86);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -3155,7 +3149,7 @@ body {
|
|||
* requested OPENROUTER_API_KEY; granted none; missing: ..." */
|
||||
.skills-access {
|
||||
border: 1px solid rgba(250, 250, 250, 0.1);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
|
|
@ -3340,7 +3334,7 @@ body {
|
|||
margin: 0;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(18, 20, 26, 0.96);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -3353,7 +3347,7 @@ body {
|
|||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -3362,7 +3356,7 @@ body {
|
|||
}
|
||||
|
||||
.skills-menu-item:hover {
|
||||
background: rgba(201, 53, 69, 0.10);
|
||||
background: var(--accent-10);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
|
@ -3404,7 +3398,7 @@ body {
|
|||
|
||||
.skills-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -3429,43 +3423,14 @@ body {
|
|||
.skills-banner-warn { background: rgba(245, 158, 11, 0.08); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
|
||||
.skills-banner-muted { background: rgba(250, 250, 250, 0.04); border-color: rgba(250, 250, 250, 0.1); color: rgba(250, 250, 250, 0.7); }
|
||||
|
||||
/* ----- Skills tab navigation (Installed / Marketplace) ----- */
|
||||
/* ----- Skills tab navigation (Installed / Marketplace) -----
|
||||
Layout/look is inherited from the shared ``.app-tab-strip`` and
|
||||
``.app-tab`` rules above (lines 662-696); ``.skills-tabs`` /
|
||||
``.skills-tab`` previously redeclared the same flex/pill styling
|
||||
verbatim. Only the Skills-page-specific spacing override survives. */
|
||||
|
||||
.skills-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 0 12px;
|
||||
overflow-x: auto;
|
||||
border-bottom: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.skills-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skills-tab {
|
||||
appearance: none;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.skills-tab:hover,
|
||||
.skills-tab.is-active {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(232, 93, 111, 0.4);
|
||||
background: rgba(232, 93, 111, 0.12);
|
||||
}
|
||||
|
||||
.skills-tab-pill {
|
||||
|
|
@ -3623,7 +3588,7 @@ body {
|
|||
.marketplace-filter-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
position: relative;
|
||||
|
|
@ -3790,7 +3755,7 @@ body {
|
|||
.marketplace-modal {
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid rgba(250, 250, 250, 0.08);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
|
|
@ -3922,7 +3887,7 @@ body {
|
|||
.marketplace-skillmd-rendered code {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 12px;
|
||||
}
|
||||
.marketplace-skillmd-rendered pre {
|
||||
|
|
@ -4008,7 +3973,7 @@ body {
|
|||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(232, 165, 53, 0.35);
|
||||
background: linear-gradient(135deg, rgba(232, 165, 53, 0.10), rgba(180, 110, 30, 0.06));
|
||||
}
|
||||
|
|
@ -4043,7 +4008,7 @@ body {
|
|||
}
|
||||
|
||||
.skill-widget-weather-city:focus {
|
||||
outline: 2px solid rgba(201, 53, 69, 0.25);
|
||||
outline: 2px solid var(--accent-25);
|
||||
}
|
||||
|
||||
.skill-widget-weather-body[data-state="loading"] {
|
||||
|
|
@ -4209,7 +4174,7 @@ body {
|
|||
third-party dependency in this update. */
|
||||
.widget-map {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
|
@ -4234,7 +4199,7 @@ body {
|
|||
}
|
||||
.widget-kanban-col {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue