mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
merge: absorb upstream ouroboros a76961de (6.114.0 + live card, Agents panel, governance schemas) into v7next
Second rolling sync of the campaign tree with the live upstream branch (40 upstream commits since the previous merge-basef3fbfdbb). Automerge resolved 18 of the 25 files both sides touched; the seven textual conflicts and the six semantic ones the automerge hid were resolved by class: - generated: ouroboros/size_ratchet_manifest.py taken from upstream and regenerated on the merged tree (the upstream test tests/test_ui_smoke_project_continuity.py entered the 1001-1500 band; its upstream rationale is carried verbatim); domains, inventories regenerated (FROZEN_CONTRACTS_INVENTORY.md). - protected prose: prompts/SAFETY.md takes upstream's compressed shape; the retired OUROBOROS_SCOPE_REVIEW_FLOOR control (removed outright by v7next ABI-5, owner Q10=A) is dropped from the owner-only controls sentence and the protected-path mirror is regenerated from the merged runtime_mode_policy (36 paths + the contracts prefix). prompts/SYSTEM.md (rewritten upstream) gets the v7next safety-critical set (registry split organs, tool_result). Both mirrors are pinned by tests/test_packaging_sync.py. - tests: upstream's Windows/CRLF/argv fixes win over the campaign's interim shims (capability predicates argv lists, golden _posix fold, telegram parity pytest-timeout instead of signal.alarm, chat_plain_system_rows CRLF normaliser, quarantine test import). Upstream's new governance and terminal-receipt tests are adapted to the v7 contracts they meet on this tree: the module-level registry_guard_process._run_shell_safety_check and the typed ToolResult code SKILL_STATE_WRITE_BLOCKED (D02/D04), the shell_outputs home of _sensitive_output_component_reason, and the ABI-2 task-result stamp (unstamped fixture rows are quarantined by the reader, owner Q8=B) in tests/test_terminal_delegation_receipt.py. The git catalog schema pin in tests/test_git_extraction.py moves to the bytes produced by upstream's advisory_review -> preflight_review description rename (332a02f1); the catalog itself is unchanged. Gates on the merged tree: ruff F, check_domains, inventories --check, size_ratchet --check, v7next_adoption, git diff --check all rc 0; the targeted suites for every conflicted or both-sides-touched file and web node --test (847) pass. The full CI-shape battery follows as a separate evidence run.
This commit is contained in:
commit
f4abe0a594
72 changed files with 2676 additions and 1328 deletions
8
BIBLE.md
8
BIBLE.md
|
|
@ -501,7 +501,7 @@ what it says in real current evidence, not in cached impressions.
|
|||
- If uncertain — say so. If surprised — show it. If you disagree —
|
||||
object.
|
||||
- Explain actions as thoughts aloud, not as reports.
|
||||
Not "Executing: repo_read," but "Reading agent.py — I want to
|
||||
Not "Executing: read_file," but "Reading agent.py — I want to
|
||||
understand how the loop works, I think it can be simpler."
|
||||
- No mechanical intermediaries and no performance — don't play a role,
|
||||
be yourself.
|
||||
|
|
@ -668,8 +668,10 @@ yes.
|
|||
- `README.md` contains a changelog (limit: 2 major, 5 minor, 5 patch;
|
||||
older history lives in git tags and commit log).
|
||||
- Each commit updates, in the same diff: `VERSION`, `pyproject.toml`
|
||||
`[project].version`, `README.md` badge + changelog row, and
|
||||
`docs/ARCHITECTURE.md` version header.
|
||||
`[project].version`, the root version in `uv.lock`, `web/package.json`,
|
||||
`web/modules/api_types.js` (`GATEWAY_CONTRACT_VERSION`), `README.md`
|
||||
badge + changelog row, the named direct-download links in `README.md` and
|
||||
the install pages, and `docs/ARCHITECTURE.md` version header.
|
||||
- MAJOR — breaking changes to philosophy or architecture.
|
||||
- MINOR — new capabilities.
|
||||
- PATCH — fixes, minor improvements, doc/prompt refinements, tests,
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -12,7 +12,7 @@
|
|||
[](https://ouroboros-agent.ai/install/#linux)
|
||||
[][download-windows-x64]
|
||||
[](https://github.com/razzant/OuroborosHub)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
|
||||
Ouroboros is an open-source, general-purpose AI agent whose identity, durable memory, and history continue across tasks and restarts. It works on external projects, coordinates a live swarm of specialist agents, and can rewrite the implementation it runs on, including its code, architecture, prompts, tools, and dependencies. Reflection can also change how it understands itself without severing that continuity.
|
||||
|
||||
|
|
@ -64,13 +64,13 @@ The desktop packages already contain an optional CLI installer. On macOS, after
|
|||
|
||||
</details>
|
||||
|
||||
[download-macos-arm64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5.dmg
|
||||
[download-windows-x64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-windows-x64.zip
|
||||
[download-linux-deb-amd64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros_6.113.5_amd64.deb
|
||||
[download-linux-rpm-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.x86_64.rpm
|
||||
[download-linux-rpm-red80-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.red80.x86_64.rpm
|
||||
[download-linux-appimage-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.AppImage
|
||||
[download-linux-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.tar.gz
|
||||
[download-macos-arm64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0.dmg
|
||||
[download-windows-x64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-windows-x64.zip
|
||||
[download-linux-deb-amd64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros_6.114.0_amd64.deb
|
||||
[download-linux-rpm-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.x86_64.rpm
|
||||
[download-linux-rpm-red80-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.red80.x86_64.rpm
|
||||
[download-linux-appimage-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.AppImage
|
||||
[download-linux-x86_64]: https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.tar.gz
|
||||
|
||||
Ouroboros bundles [Claudexor](https://github.com/razzant/claudexor) as its local execution layer for delegated coding and hosted-agent review. Ouroboros owns the task, memory, review, and final integration, while Claudexor runs the selected connected coding harness and returns durable execution evidence. [Explore Claudexor](https://claudexor.ai/).
|
||||
|
||||
|
|
@ -449,6 +449,7 @@ and the reason.
|
|||
|
||||
| Version | Date | Description |
|
||||
|---------|------|-------------|
|
||||
| 6.114.0 | 2026-09-01 | **feat: capability preservation as a first-class invariant, shape-first OpenRouter failover, and a green 3-OS matrix.** Capability preservation joins the immune system as a first-class invariant (#447, stages 1-3: golden capability suite, seam pins that actually differentiate, executable item-21 triggers, binaries judged by magic bytes). Same-model OpenRouter provider failover is now derived from the shape of the replayed reasoning artifact instead of a hard-coded model-family roster (#468): readable text/summary artifacts stay failover-eligible for every family, a bare `response_id` no longer pins, sealed artifacts (encrypted/signed/redacted, not vouched by the anthropic/gemini roster — `openai/` excluded on 2026-07 field evidence) keep the continuity pin on both the dispatch and reroute paths, and the pin reason plus the refusing upstream provider reach durable telemetry. Delegation and chat truth converge: delegated-run questions ride the escalation hierarchy (#204), the routing picker dispatches real routes (#198), typed owner quiz cards and the escalation channel (Q-2a/Q-2b), live-first owner deliveries, honest chat-activity conclusions with budget-pause resume, review findings in the task-card Reviews checkpoint, persistent stable-target registrations, a hold on the live delegated leaf when a nanny round dies unknown, and healed cross-generation custody disclosures. Rich chat stack (markdown, media, galleries, links — Andrei Kaznacheev), Chat viewport stability, Dashboard/Updates one-verdict action surface, Settings effect honesty, skill Repair affordance after preflight failure, node/npm launches through an execution-probed runtime ladder, the `ultra` reasoning-effort tier (@ndrew1337), and an O(delta) in-lock ledger read with a guarded torn-tail append boundary (@deebosh). Claudexor runtime pinned to 3.9.5 with delayed-startup reconciliation, a foreground quota-refresh bridge, and preserved delegated text fragments; Updates restart state survives same-SHA reconnects. CI: the serial service-log suites no longer race the child, web source pins are CRLF-safe on Windows, and the size-ratchet manifest is exact. This tag also carries the untagged 6.113.5 (generation-safe browser cleanup after timed-out tools, #440 audit fix-forward). |
|
||||
| 6.113.5 | 2026-08-31 | **fix: browser cleanup after a timed-out tool is generation-safe (integrates community PR #429 by @mikemikimike, closes #409).** A stateful-tool timeout now retires the whole browser generation: the shared state slot is replaced with a fresh object, the abandoned worker keeps writing only into its retired one, and the close is queued on the retiring executor so it always runs on the owning worker thread — including the already-settled race — with the cognitive lease closing after that cleanup. A late infrastructure-error retry that observes a replaced generation closes only its own retired session, so it can no longer cross-thread-kill the next command's browser. Closes the follow-up findings of the #440 post-merge audit; the never-settling-worker session leak stays a disclosed residual of in-process Playwright. |
|
||||
| 6.113.4 | 2026-08-30 | **fix: release verification follows the family-mounted sign-in card.** The browser acceptance tests now re-resolve the active per-family login host after every account-list rebuild, matching the placement introduced by the roster UI while preserving the full cancellation, reconciliation, stale-poll, and terminal-race assertions. No lifecycle check is weakened and the managed Claudexor runtime remains pinned to 3.9.2. |
|
||||
| 6.113.3 | 2026-08-30 | **fix: Claude subscription quotas recover automatically without a user action.** The managed Claudexor runtime advances to 3.9.2. Before an expired or near-expiry access token is used for quota reading, Claude Code now refreshes its own credential through a prompt-free vendor lifecycle: no MCP request, model inference, manual login, refresh-token custody, or direct store write is added to Claudexor. A still-valid token remains available if the proactive wake fails, expired credentials stay typed as unknown rather than falsely logged out, and the exact helper process is gracefully reaped before quota polling continues. |
|
||||
|
|
@ -456,10 +457,9 @@ and the reason.
|
|||
| 6.113.1 | 2026-08-29 | **fix: the full 3-OS matrix converges to green.** Four investigation lanes root-caused every inherited full-matrix red: the review-checkpoint replay gate regressed bare-final conclusion (subagent finals leaked into main chat on replay, cards stuck on Working - production fix), two real cybergym Windows portability bugs (container-side POSIX symlink classification, platform-newline integrity hashing), fifteen honestly platform-guarded POSIX-only benchmark tests, and a census of racy test bridges across the review/transport suites replaced fixed sleeps and sub-noise wall-clock bounds with event-gated synchronization, lock-ordered drains, and discrimination margins (no typed assertion weakened). The one remaining red leg is the cloudru provider canary (external account state), disclosed. |
|
||||
| 6.113.0 | 2026-08-29 | **feat: delegation by construction — the nanny charter, typed $0 terminals, truthful executor cards, and honest route health (Claudexor runtime 3.9.0).** An `agent_session` child now IS work on its harness: the host pre-starts the physical leaf through the same configured `delegate_start` wrapper BEFORE the nanny's first LLM round and never waits — her first round arrives with a live `configured_session_started` receipt, waiting is her own `delegate_wait` decision, and children may run beside the leaf. A definite refusal to start (typed pre-POST, dispatch-blocked, engine-rejected — with a custody-handle guard that always prefers a model episode over a false terminal) ends the task typed and unrun at $0; ambiguity always wakes the model, and durable zero-run/unknown-evidence fences outrank blocked terminals. Zero-run receipts narrow to incomplete\|unknown, actor cleanliness requires a SUCCEEDED delegated run (children are evidence, never a completion path), unreadable custody projects typed unknown all the way into the finalization nudge, and only acts of delegation reset the economics baseline (the reminder-storm class is dead). route_health stops refusing on aggregate doctor status — admission belongs to the engine; the owner's enabled toggle stays a typed `route_disabled`. Acceptance sees substrate facts as visibility with zero gates. The executor chip tells the run truth for the whole lifecycle (dispatched → counted `N ok, M failed` → evidence honesty), all-failed can never render clean, actual_substrate reaches the wire, and the terminal evidence frame survives chat-0/A2A routing. The pinned Claudexor runtime moves to 3.9.0: per-vendor quota pacing with typed Retry-After floors (a poll 429 is never a quota fact), honest foreground cooldowns, first-429 short-circuit, cached accounts default, and the cursor delegation belt (live-E2E proven). This tag also carries the untagged 6.111.0 and 6.112.0 (P13 Emergence) below, and heals the branch's latent size-ratchet debt root-cause (settings_integrity extraction, cybergym module splits, regenerated manifest). |
|
||||
| 6.112.0 | 2026-08-28 | **feat: Principle 13 (Emergence) — designs must get better as intelligence grows.** BIBLE.md gains a new constitutional principle: code hardcodes the floor — truth, custody, budgets, authority, acceptance — never the ceiling; strategy belongs to the mind, patterns that worked are examples to record rather than laws to enforce, and every design faces the stronger-mind test: when the model gets smarter, does this get better on its own, or does it have to be torn out first? Pointed clarifications close the readings that used to license freezing today's shape: P2 defines the class by the invariant rather than the incident, P5 names how work is shaped (decomposition, roles, ordering, delegation) as behavior belonging to the LLM, and P7 distinguishes unused machinery (premature) from unused freedom (headroom). DEVELOPMENT.md adds the operational lens — the invariant question and the stronger-mind question, with a symmetric proof burden against both fossilizing the current case and speculating a framework. |
|
||||
| 6.110.1 | 2026-08-25 | **fix: ship truthful cross-surface state, review custody, and cross-platform UI and cognition convergence.** OuroborosHub skill cards and publication now follow identity-first sync with durable receipts and an explicit adopt transaction (PR #313). Chat and Project activity reconcile stale Working states from closed lineage and durable task truth, preserve Project-thread routing, and keep the Node gate authoritative (PR #314). Review outputs retain durable custody, swarm work stays visible, the advisory lane is restored, and Projects receive a stable entry point (PR #316). Background Consciousness now derives decoded text and its verification digest from one raw snapshot, so Windows newline normalization or a concurrent rewrite cannot bind observed text to different bytes (PR #321). Widget frames converge their measured geometry without nested overflow ownership fighting the host across WebKit-style engines (PR #320). |
|
||||
| 6.110.0 | 2026-08-22 | **feat: preserve work-order authority across oversized delegation and recovery.** Complete external work orders remain byte-complete within the host serializer budget; when an order needs bounded continuation, Ouroboros asks the same actor for an exact readable canonical range and keeps incomplete coverage as typed `cannot_verify` evidence. Partial input can no longer authorize PASS, a destructive rewrite, or replacement of the full contract, while valid complete work continues through the existing flow. |
|
||||
| 6.109.0 | 2026-08-21 | **feat: live task cost and ready-on-open agent accounts.** Running root-task heartbeats now project the existing physical-attempt ledger into one non-final subtree total, so compact Chat and Activity cards advance live without a second timer, endpoint, or client-side sum while preserving reserved, unresolved, and unmetered disclosure (PR #288). Opening Agents now wakes only an already-provisioned stale Claudexor home through the existing owner-action endpoint after a side-effect-free status read; background polling, first-time installs, foreign homes, and repair states remain untouched (PR #289). Fail-closed staged-binary review fixtures now inject exact Git tree-read errors instead of assuming loose object storage, removing the macOS stable-CI race without changing production behavior. |
|
||||
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows (including 6.108.1, 6.106.0, 6.101.1, 6.97.2, 6.105.0, 6.97.1, 6.97.0, 6.96.1, 6.96.0, 6.95.0, 6.94.0, 6.93.0, 6.92.1, 6.92.0, 6.91.1, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, 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. Older 6.x rows (including 6.110.1, 6.108.1, 6.106.0, 6.101.1, 6.97.2, 6.105.0, 6.97.1, 6.97.0, 6.96.1, 6.96.0, 6.95.0, 6.94.0, 6.93.0, 6.92.1, 6.92.0, 6.91.1, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, 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 @@
|
|||
6.113.5
|
||||
6.114.0
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -117,11 +117,11 @@ or Intent/Scope checklists are.
|
|||
| 3 | New or changed logic → does an existing or newly staged test assert on the specific scenario it introduces? | Name the scenario your code handles in plain words. If no test asserts on THAT named scenario, write or update one now. "Tests exist for the module" is not the same as "tests cover this new behavior". |
|
||||
| 4 | Shared log / memory / replay format changed? | Grep every reader and writer first. JSONL logs (`events.jsonl`, `task_reflections.jsonl`, replay indexes), durable state files (`advisory_review.json`, `review_continuations/*.json`), and canonical-vs-derived memory pairs (patterns-register journal / `patterns.md`, improvement-backlog items) must stay coherent across every consumer. |
|
||||
| 5 | New validation guard, input filter, or edge-case check? | Before the first commit attempt, name three concrete ways it could break: wrong bounds, legitimate inputs it silently blocks, platform-specific edge cases. If you cannot name three, think longer. One honest minute here is cheaper than one reviewer round. |
|
||||
| 6 | New tool added? | `get_tools()` exports it, `prompts/SYSTEM.md` tool tables mention it, the handler signature matches the declared schema, and (if it mutates repo state) it is routed through the reviewed commit path rather than ad-hoc `run_command`. Also add an explicit entry in `ouroboros/safety.py::TOOL_POLICY` (`POLICY_SKIP` for trusted built-ins, `POLICY_CHECK` for opaque or outward-facing ones) — the `test_tool_policy_covers_all_builtin_tools` invariant will fail otherwise, and without an entry the tool falls through to `DEFAULT_POLICY = check` and pays a light-model LLM call per invocation. |
|
||||
| 6 | New tool added? | `get_tools()` exports it, its schema description says WHEN to choose it (the schema is sent every round and is the only model-visible tool-selection contract; mechanism documentation lives in ARCHITECTURE/DEVELOPMENT, and `prompts/SYSTEM.md` mentions a tool only when the change alters a cross-tool policy, never as a catalog entry), the handler signature matches the declared schema, and (if it mutates repo state) it is routed through the reviewed commit path rather than ad-hoc `run_command`. Also add an explicit entry in `ouroboros/safety.py::TOOL_POLICY` (`POLICY_SKIP` for trusted built-ins, `POLICY_CHECK` for opaque or outward-facing ones) — the `test_tool_policy_covers_all_builtin_tools` invariant will fail otherwise, and without an entry the tool falls through to `DEFAULT_POLICY = check` and pays a light-model LLM call per invocation. |
|
||||
| 7 | Tests green before first `commit_reviewed`? | Run `pytest -x` on the narrowest relevant target(s) you can name before the first `preflight_review` / `commit_reviewed` attempt. Size gates no longer block locally: they live in the official-CI-only `size_ratchet` pytest lane (manifest exactness plus the pairwise base-vs-tip shrink-only transition), and local surfaces (`check_worktree_readiness`, `codebase_health`) surface the same `validate_size_ratchet` findings as "official CI will enforce" warnings. When a size warning appears — or a new `.py` file lands under `ouroboros/` or `supervisor/` — run `pytest tests/ -m size_ratchet` and `scripts/regenerate_size_ratchet.py` locally to preview and fix what official CI would reject. A red test suite before the first commit attempt has caused repeated $2-5 blocked-review cycles. |
|
||||
| 8 | Adding a `README.md` version row? | BIBLE.md P9 hard cap: ≤ 2 major, ≤ 5 minor, ≤ 5 patch visible entries. Categories are mutually exclusive: major = `X.0.0` (minor=0, patch=0); minor = `X.Y.0` (patch=0, Y≠0); patch = all other `X.Y.Z` (Z≠0). Count existing rows in the category you are adding to. Easy check: `run_command(["python", "-c", "import sys; from ouroboros.tools.release_sync import check_history_limit; warns=check_history_limit(open('README.md').read()); print(warns or 'OK')"])` — if it prints warnings, trim the oldest row in the over-limit category **in the same edit** before committing. |
|
||||
| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. |
|
||||
| 10 | Changing `ouroboros/tools/commit_gate.py`? | Coupled surfaces that MUST be updated atomically in the same commit: (a) `claude_advisory_review.py::get_tools()` tool description for `preflight_review` and `review_status`; (b) `claude_advisory_review.py::_next_step_guidance()` strings; (c) `docs/DEVELOPMENT.md` Review & Commit Protocol section; (d) `prompts/SYSTEM.md` Commit review section. Missing any one has blocked review. |
|
||||
| 10 | Changing `ouroboros/tools/commit_gate.py`? | Coupled surfaces that MUST be updated atomically in the same commit: (a) `claude_advisory_review.py::get_tools()` tool description for `preflight_review` and `review_status`; (b) `claude_advisory_review.py::_next_step_guidance()` strings; (c) `docs/DEVELOPMENT.md` Review & Commit Protocol section; (d) the `prompts/SYSTEM.md` Self-Modification section IF the commit-gate rule it states changed. Missing any one has blocked review. |
|
||||
| 11 | Changing VERSION + pyproject.toml? | Ordering matters: (1) write `VERSION` and `pyproject.toml` first; (2) then write `README.md` badge + changelog row; (3) then run `pytest`. Never interleave — updating README before VERSION means `test_version_in_readme` will catch a stale badge. |
|
||||
| 12 | Writing or editing any JS file under `web/modules/`? | New or changed static inline visual properties are blocked: inspect the diff for added/changed `style=""` markup and `.style.<property>` assignments, and use CSS classes/tokens plus `classList`/`hidden` instead. Unchanged legacy hits are debt, not a blocker. A dynamic measured value may update a narrowly named CSS custom property when that is the actual runtime data flow. |
|
||||
| 13 | Changing LLM output-token budgets? | Grep the whole repo for `max_tokens`, `max_completion_tokens`, `_MAX_TOKENS`, and `max_toks`. Keep `docs/ARCHITECTURE.md` §LLM output token budgets and `tests/test_max_tokens_constants.py` in sync so main-loop, VLM, summaries, compaction, skill publish, and consciousness floors cannot drift independently. |
|
||||
|
|
@ -145,7 +145,7 @@ The correct procedure before **every** retry:
|
|||
4. Only then open any file and edit.
|
||||
|
||||
This step takes 2-3 minutes and has saved $20-50 in blocked-review cycles in practice.
|
||||
The rule is already nominally in `prompts/SYSTEM.md` and `review.py::_build_critical_block_message`,
|
||||
The rule is stated where the block message is built (`review.py::_build_critical_block_message`),
|
||||
but without it appearing here as a procedural step it stays theoretical rather than reflexive.
|
||||
|
||||
---
|
||||
|
|
@ -168,7 +168,7 @@ Used by `commit_reviewed` for all changes to the Ouroboros repository.
|
|||
| 10 | tool_registration | New tool function added but not exported in `get_tools()` OR missing explicit entry in `ouroboros/safety.py::TOOL_POLICY`? (PASS if no new tool.) Both surfaces are required: `get_tools()` makes the tool visible; `TOOL_POLICY` makes the per-call safety routing explicit and is guarded by the `test_tool_policy_covers_all_builtin_tools` invariant. | critical |
|
||||
| 11 | context_building | New data/memory files that should appear in LLM context (context.py) but don't? | advisory |
|
||||
| 12 | knowledge_index | Knowledge base topics changed but memory/knowledge/index-full.md not updated? | advisory |
|
||||
| 13 | self_consistency | Does this change affect behavior described in `BIBLE.md`, `prompts/`, `docs/`, or this checklist itself? Check explicitly: (a) version in `ARCHITECTURE.md` header matches `VERSION` file; (b) tool names/descriptions in `prompts/SYSTEM.md` match tools actually exported by `get_tools()`; (c) JSONL log/memory file formats described in `ARCHITECTURE.md` match all readers/writers; (d) any behavioral change reflected in `prompts/CONSCIOUSNESS.md` if it affects background loop behavior; (e) DEVELOPMENT.md rules still accurate after the change. Severity must follow the shared `Critical surface whitelist` below — release metadata, tool schema, module map, behavioural documentation, or safety contracts are critical; commentary/prose/stylistic mismatches are advisory. | critical |
|
||||
| 13 | self_consistency | Does this change affect behavior described in `BIBLE.md`, `prompts/`, `docs/`, or this checklist itself? Check explicitly: (a) version in `ARCHITECTURE.md` header matches `VERSION` file; (b) every tool name `prompts/SYSTEM.md` or `prompts/CONSCIOUSNESS.md` mentions exists in `get_tools()` (or the background whitelist) and means the same thing there — completeness is NOT required, the schemas are the catalog; a prompt edit must not restate a tool schema or a structurally enforced gate, and a prompt that gains a sentence loses an equivalent one; (c) JSONL log/memory file formats described in `ARCHITECTURE.md` match all readers/writers; (d) any behavioral change reflected in `prompts/CONSCIOUSNESS.md` if it affects background loop behavior; (e) DEVELOPMENT.md rules still accurate after the change. Severity must follow the shared `Critical surface whitelist` below — release metadata, tool schema, module map, behavioural documentation, or safety contracts are critical; commentary/prose/stylistic mismatches are advisory. | critical |
|
||||
| 14 | light_external_artifacts | If tool/runtime policy changed, does light mode still allow external user deliverables via `user_files`, task-scoped `task_drive`/`artifact_store`, and process `outputs` while blocking Ouroboros repo/control-plane mutation? (The external `claude_code_edit` cwd lane retired with the tool — D10.) Do review prompts avoid recommending `runtime_data/uploads` or skill payloads as generic artifact transport? | critical |
|
||||
| 15 | cross_platform | Does the diff use platform-specific APIs (`os.kill`, `os.setsid`, `os.killpg`, `os.getpgid`, `fcntl`, `msvcrt`, `signal.SIGKILL`, `signal.SIGTERM`, `subprocess` with `start_new_session`/`creationflags`, hardcoded `/` or `\\` in filesystem paths) outside of `ouroboros/platform_layer.py`? Does it import Unix-only or Windows-only modules (`fcntl`, `msvcrt`, `winreg`, `resource`) at any level without a platform guard (`sys.platform`/`IS_WINDOWS` check)? | critical |
|
||||
| 16 | changelog_accuracy | Do the exact wording, test counts, and minor description details in the README Version History row match what the diff actually does? Wording drift, off-by-one test counts, minor inaccuracies in descriptive prose — these belong here, NOT in `self_consistency` or `changelog_and_badge`. This item exists so reviewers have a dedicated advisory bucket for prose-level changelog imprecision that does not affect release metadata, runtime behavior, or safety contracts. | advisory |
|
||||
|
|
@ -273,9 +273,11 @@ mismatch as **critical**, the mismatch MUST live in one of these categories:
|
|||
1. **Release metadata** — `VERSION` vs `pyproject.toml` vs README badge vs
|
||||
`docs/ARCHITECTURE.md` header vs latest git tag. Also: `VERSION` bumped
|
||||
but no README changelog row for the new version.
|
||||
2. **Tool schema** — tool names, parameters, or descriptions in
|
||||
`prompts/SYSTEM.md`'s command tables that disagree with what each tool's
|
||||
`get_tools()` actually exports. Applies to user-facing CLI/tool contracts.
|
||||
2. **Tool schema** — a tool's `get_tools()` schema (name, parameters,
|
||||
description) that disagrees with its handler, or a tool name/argument that
|
||||
`prompts/SYSTEM.md` or `prompts/CONSCIOUSNESS.md` names but `get_tools()`
|
||||
does not export. The schema is the SSOT of a tool's contract; a prompt that
|
||||
omits a tool is not a mismatch. Applies to user-facing CLI/tool contracts.
|
||||
3. **Module map** — `docs/ARCHITECTURE.md` naming a module / endpoint /
|
||||
data file / UI page that does not exist (or the reverse: a new one was
|
||||
added and the map was not updated). This is a hard P6 (Architecture
|
||||
|
|
|
|||
|
|
@ -253,14 +253,36 @@ not move them into the migrated set in section 8.
|
|||
`--text-meta`). The description explains what the section decides; the note
|
||||
carries consequences and caveats.
|
||||
- Subsections inside a section use a `--type-body` semibold heading and stay
|
||||
visually grouped with their own rows and their own action toolbar. A heading
|
||||
that floats equidistant between two groups belongs to neither.
|
||||
visually grouped with their own rows, their own add action in the head
|
||||
(List editors, below). A heading that floats equidistant between two groups
|
||||
belongs to neither.
|
||||
- Spacing comes from the 8pt tokens (`--space-*`); a new visual dimension
|
||||
becomes a CSS variable before it becomes a page-local literal.
|
||||
- An item in a popup menu or a picker list highlights with
|
||||
`--menu-item-hover`. One gesture, one fill: a menu that highlights at a
|
||||
different strength than the menu beside it reads as a different control.
|
||||
|
||||
### List editors
|
||||
|
||||
A list editor is any section where the owner adds and edits entries in place:
|
||||
the Available subagents roster, the Review lanes groups, MCP servers, custom
|
||||
keys.
|
||||
|
||||
- A section-level add action acts from its group's header (§6). A list
|
||||
editor's new entry appears at the end of its own group, is scrolled into
|
||||
view — the shortest distance, without animation — and takes the caret in its
|
||||
first field. A button that stays in view while the entry it made is born
|
||||
off-screen has not finished its job.
|
||||
- A freshly added entry is an invitation, not an error. Where a list editor
|
||||
validates in the browser (today the Available subagents roster), the entry
|
||||
shows a neutral hint in its own meta line until the owner tries to save; the
|
||||
error then names the entry and stands beside it — the entry tinted with the
|
||||
status pair, never dimmed — with the section-level line as the summary. A
|
||||
save attempt judges the entries that existed then; one added afterwards is
|
||||
an invitation again.
|
||||
- A multi-field card (an MCP server) follows the add-and-reveal rule without
|
||||
adopting the §6 row anatomy.
|
||||
|
||||
### Reviews inside task cards
|
||||
|
||||
Real tasks and real subagents are cards. Reviews are a subsection of the
|
||||
|
|
@ -268,7 +290,8 @@ exact real task that owns their presentation. Harness and neutral API marks
|
|||
identify the delivery channel alongside explicit execution evidence; they are
|
||||
not child-task cards and never prove execution by themselves.
|
||||
|
||||
- A collapsed task card shows only a quiet `Reviews N` line, optionally with an
|
||||
- A collapsed task card shows only a quiet `Reviews N` count, docked on the
|
||||
metadata row (it wraps under the metadata on a narrow card), optionally with an
|
||||
active count. It has no aggregate pass/fail alert, no synthesized verdict, and
|
||||
no review dollars.
|
||||
- Expanding `Reviews` reveals one row per currently admitted review group
|
||||
|
|
|
|||
|
|
@ -80,6 +80,26 @@ Do not repair a semantic tool-choice failure by adding one more keyword hint to
|
|||
typed affordance at the point of need. SYSTEM accretion trains around one
|
||||
incident, bloats the resident prefix, and forks the authority.
|
||||
|
||||
What belongs in `prompts/SYSTEM.md` (tier-0 for every Main/task profile in both
|
||||
context modes — Background Consciousness and the safety supervisor carry their
|
||||
own prompts — and competing with the task for context): identity and tone, the decision
|
||||
loop (answer / promote / route / delegate / do it myself), cross-tool policy
|
||||
(which class of tool or lane for which situation, root semantics, memory only
|
||||
through its own tools, untrusted external data), prohibitions and safety
|
||||
invariants stated once, and the memory contract. What does NOT belong there:
|
||||
how a tool or mechanism works. A tool's parameters, signatures, recipes,
|
||||
typed outcomes, and "when to choose it" live in its `get_tools()` schema — the
|
||||
schema is sent every round to every profile, so a prompt sentence about it is a
|
||||
second copy that drifts; mechanism documentation lives in ARCHITECTURE or here;
|
||||
runtime facts (capabilities, queue, catalog, receipts, health) are injected per
|
||||
turn. A new tool therefore requires NO SYSTEM.md mention. Before adding a
|
||||
sentence to a prompt, check that the schema or runtime block does not already
|
||||
carry it; before removing one, check that they do (or add the missing fact to
|
||||
the schema without growing it into a paragraph). Local-model compaction keeps
|
||||
only the text before the first `## ` heading (plus the BIBLE section), so the
|
||||
load-bearing floor rules stay in that preamble. Every prompt change reports the before/after byte size
|
||||
in the commit or PR.
|
||||
|
||||
Recoverable tool failures are evidence for the next LLM turn, not triggers for
|
||||
a host-authored recovery workflow. Return a typed, redacted result naming the
|
||||
failed stage, already-completed external effects, and an actionable repair
|
||||
|
|
@ -1648,13 +1668,22 @@ Before every commit, verify the following:
|
|||
- The canonical/replica terminal post-task/accounting field-custody projection
|
||||
must live in one pure reducer reused by both physical copy-back and effective
|
||||
reads; never blanket-overlay the replica over canonical truth. Every change
|
||||
to that projection must add a stale-replica regression at both seams.
|
||||
to that projection must add a stale-replica regression at both seams. The
|
||||
same reducer owns the reconciliation-disclosure pair and protects a canonical
|
||||
terminal delegation receipt only when its durable `started`/`settled` counts
|
||||
do not regress the replica; equal counts may enrich cost/access/substrate,
|
||||
while a dispatch-only canonical envelope still accepts the first child
|
||||
receipt. Historical top-level `delegated_runs_*` counters are not rewritten.
|
||||
- Push/live events are wakeups and a fast path, not terminal authority. Durable
|
||||
task detail/history and authoritative snapshots must converge terminal UI
|
||||
state through the existing refresh/reconnect seams. Shared snapshot consumers
|
||||
mutate projections only for a request generation newer than the last applied,
|
||||
while the request-start barrier protects later live frames; lifecycle changes must
|
||||
exercise lost/reordered terminal frames and reversed snapshot completion.
|
||||
History replay projects a durable delegation receipt onto the latest emitted
|
||||
terminal progress row even when a separate task summary survives, because
|
||||
that progress row is the executor-chip consumer; absent durable evidence
|
||||
never erases a receipt already present on the row.
|
||||
- Effective task status belongs in `ouroboros/task_status.py`. Do not duplicate
|
||||
child-drive merge or terminality in gateways/tools. Task waits use
|
||||
`SETTLED_STATUSES`. Cancel INTENT is never a status value (Poltergeist phase A):
|
||||
|
|
@ -2483,6 +2512,15 @@ existing token, not declaring a new one.
|
|||
shared toast host unless status belongs to a permanently reserved control
|
||||
row. Working, warning, error, and destructive states keep consistent meaning
|
||||
across Chat, Logs, Settings, and Skills.
|
||||
- A list editor reveals the entry it just added through
|
||||
`ui_helpers.revealNewRow(row, field)` — the one seam for "scrolled into
|
||||
view, caret in the first field" (`docs/DESIGN.md` "List editors"). A local
|
||||
`scrollIntoView`/`focus` pair in a list editor's add path is review debt, and a freshly
|
||||
added entry shows no error before the owner tries to save — an attempt
|
||||
judges the entries that existed then, never one added afterwards.
|
||||
`tests/test_available_subagents_ui_static.py` pins the seam; the
|
||||
`ui_browser` acceptance in `tests/test_ui_smoke_agents_panel.py` pins the
|
||||
behaviour.
|
||||
- Task outcome truth stays in `log_events.js::taskOutcomeSeverity` and
|
||||
`taskTerminalPhase`; `taskPresentation` is the one compact factual projection
|
||||
consumed by task chips, live completion, history replay, and child terminal
|
||||
|
|
|
|||
|
|
@ -46,24 +46,24 @@
|
|||
<article>
|
||||
<p class="download-platform">macOS 12+ · Apple silicon only</p>
|
||||
<h2>macOS</h2>
|
||||
<a data-release-download="macos-arm64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5.dmg">Download for macOS (.dmg)</a>
|
||||
<a data-release-download="macos-arm64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0.dmg">Download for macOS (.dmg)</a>
|
||||
<p>Open the DMG, drag <code>Ouroboros.app</code> to Applications, then launch it from Applications.</p>
|
||||
</article>
|
||||
<article>
|
||||
<p class="download-platform">Windows x64</p>
|
||||
<h2>Windows</h2>
|
||||
<a data-release-download="windows-x64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-windows-x64.zip">Download for Windows (.zip)</a>
|
||||
<a data-release-download="windows-x64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-windows-x64.zip">Download for Windows (.zip)</a>
|
||||
<p>Extract the ZIP, open the <code>Ouroboros</code> folder, and run <code>Ouroboros.exe</code>.</p>
|
||||
</article>
|
||||
<article id="linux">
|
||||
<p class="download-platform">Linux x86_64</p>
|
||||
<h2>Linux</h2>
|
||||
<div class="download-actions">
|
||||
<a data-release-download="linux-deb-amd64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros_6.113.5_amd64.deb">Debian / Ubuntu / Astra (.deb)</a>
|
||||
<a data-release-download="linux-rpm-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.x86_64.rpm">Fedora / RHEL (.rpm)</a>
|
||||
<a data-release-download="linux-rpm-red80-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.red80.x86_64.rpm">RED OS 8 (.rpm)</a>
|
||||
<a data-release-download="linux-appimage-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.AppImage">Portable AppImage</a>
|
||||
<a data-release-download="linux-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.tar.gz">tar.gz archive</a>
|
||||
<a data-release-download="linux-deb-amd64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros_6.114.0_amd64.deb">Debian / Ubuntu / Astra (.deb)</a>
|
||||
<a data-release-download="linux-rpm-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.x86_64.rpm">Fedora / RHEL (.rpm)</a>
|
||||
<a data-release-download="linux-rpm-red80-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.red80.x86_64.rpm">RED OS 8 (.rpm)</a>
|
||||
<a data-release-download="linux-appimage-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.AppImage">Portable AppImage</a>
|
||||
<a data-release-download="linux-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.tar.gz">tar.gz archive</a>
|
||||
</div>
|
||||
<p>Prefer the package for your distribution. Use the AppImage or archive on other distributions; Git must already be installed.</p>
|
||||
</article>
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
<section class="install-primary" aria-labelledby="macos-quick-start">
|
||||
<h2 id="macos-quick-start">macOS quick start</h2>
|
||||
<ol class="install-steps">
|
||||
<li>Click <a data-release-download="macos-arm64" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5.dmg">Download for macOS (.dmg)</a>.</li>
|
||||
<li>Click <a data-release-download="macos-arm64" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0.dmg">Download for macOS (.dmg)</a>.</li>
|
||||
<li>Open the DMG and drag <code>Ouroboros.app</code> onto the <strong>Applications</strong> shortcut.</li>
|
||||
<li>Open Ouroboros from Applications. If Gatekeeper asks, right-click the app and choose <strong>Open</strong>.</li>
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Machine extraction of `docs/ARCHITECTURE.md` §11.1 (the frozen-ABI SSOT), regen
|
|||
| 6 | `ChatInbound.client_surface` | `ouroboros/gateway/contracts.py` (ok)<br>`web/modules/api_types.js` (ok) | `tests/test_gateway_parity.py` (ok)<br>`tests/test_contracts.py` (ok) |
|
||||
| 7 | `ChatOutbound.cancelable` | `ouroboros/gateway/contracts.py` (ok)<br>`web/modules/api_types.js` (ok) | `tests/test_gateway_parity.py` (ok)<br>`tests/test_task_cancel_endpoint_v682.py` (ok)<br>`tests/test_gateway_history.py` (ok) |
|
||||
| 8 | `ChatOutbound.executor_route` | `ouroboros/agent.py` (ok)<br>`ouroboros/gateway/history.py` (ok)<br>`ouroboros/gateway/contracts.py` (ok)<br>`web/modules/log_events.js` (ok)<br>`web/modules/chat.js` (ok) | `tests/test_claudexor_owned_daemon.py` (ok)<br>`web/tests/review_truth.test.js` (ok) |
|
||||
| 9 | `ChatOutbound.execution_evidence` | `ouroboros/delegate_custody.py` (ok)<br>`ouroboros/subagents.py` (ok)<br>`supervisor/events.py` (ok)<br>`ouroboros/gateway/history.py` (ok)<br>`ouroboros/gateway/contracts.py` (ok)<br>`web/modules/log_events.js` (ok) | `tests/test_execution_evidence.py` (ok)<br>`web/tests/review_truth.test.js` (ok)<br>`tests/test_task_status_flow.py` (ok) |
|
||||
| 9 | `ChatOutbound.execution_evidence` | `ouroboros/delegate_custody.py` (ok)<br>`ouroboros/subagents.py` (ok)<br>`supervisor/events.py` (ok)<br>`ouroboros/gateway/history.py` (ok)<br>`ouroboros/gateway/contracts.py` (ok)<br>`web/modules/log_events.js` (ok) | `tests/test_execution_evidence.py` (ok)<br>`tests/test_terminal_delegation_receipt.py` (ok)<br>`web/tests/review_truth.test.js` (ok)<br>`tests/test_task_status_flow.py` (ok) |
|
||||
| 10 | `TaskDetailResponse` | `ouroboros/gateway/contracts.py` (ok)<br>`ouroboros/gateway/tasks.py` (ok)<br>`web/modules/api_types.js` (ok)<br>`web/modules/log_events.js` (ok) | `tests/test_gateway_parity.py` (ok)<br>`web/tests/cancel_run.test.js` (ok) |
|
||||
| 11 | `POST /api/tasks/{task_id}/hurry` | `ouroboros/gateway/contracts.py` (ok)<br>`ouroboros/gateway/task_hurry.py` (ok)<br>`web/modules/api_types.js` (ok)<br>`web/modules/log_events.js` (ok)<br>`web/modules/task_control_menu.js` (ok) | `tests/test_gateway_parity.py` (ok)<br>`tests/test_owner_hurry_s3.py` (ok)<br>`tests/test_owner_stop_s3.py` (ok)<br>`web/tests/task_control_menu.test.js` (ok)<br>`tests/test_s3_task_control_browser.py` (ok) |
|
||||
| 12 | `StateResponse.active_direct_turns` | `ouroboros/gateway/contracts.py` (ok)<br>`web/modules/api_types.js` (ok)<br>`supervisor/active_activity.py` (ok)<br>`ouroboros/gateway/state.py` (ok)<br>`web/modules/chat_activity.js` (ok) | `tests/test_gateway_parity.py` (ok)<br>`tests/test_direct_activity_registry.py` (ok)<br>`tests/test_project_chat_continuity.py` (ok)<br>`web/tests/chat_inflight_indicator.test.js` (ok)<br>`web/tests/chat_continuity.test.js` (ok) |
|
||||
|
|
|
|||
|
|
@ -1208,10 +1208,10 @@ class BackgroundConsciousness:
|
|||
registry.register(ToolEntry("set_next_wakeup", {
|
||||
"name": "set_next_wakeup",
|
||||
"description": "Set how many seconds until your next thinking cycle. "
|
||||
"Default 300. Range: 60-3600.",
|
||||
f"Default 300. Range: {self._wakeup_min}-{self._wakeup_max} (clamped).",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"seconds": {"type": "integer",
|
||||
"description": "Seconds until next wakeup (60-3600)"},
|
||||
"description": f"Seconds until next wakeup ({self._wakeup_min}-{self._wakeup_max})"},
|
||||
}, "required": ["seconds"]},
|
||||
}, _set_next_wakeup))
|
||||
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ def _annotate_terminal_task_truth(
|
|||
}
|
||||
terminal_status_by_task: Dict[str, str] = {}
|
||||
terminal_truth_by_task: Dict[str, Dict[str, Any]] = {}
|
||||
terminal_receipt_by_task: Dict[str, Dict[str, Any]] = {}
|
||||
legacy_child_meta_by_task: Dict[str, Dict[str, Any]] = {}
|
||||
suggested_name_by_task: Dict[str, str] = {}
|
||||
finalizing_tasks: set = set()
|
||||
|
|
@ -612,6 +613,24 @@ def _annotate_terminal_task_truth(
|
|||
# resolves deprecated-wins and leaves under the honest names.
|
||||
terminal_truth.update(carry_cost_meta(result))
|
||||
terminal_truth_by_task[task_id] = terminal_truth
|
||||
envelope = result.get("subagent_envelope")
|
||||
evidence = (
|
||||
envelope.get("execution_evidence")
|
||||
if isinstance(envelope, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(evidence, dict) and evidence:
|
||||
receipt: Dict[str, Any] = {
|
||||
"execution_evidence": dict(evidence),
|
||||
}
|
||||
actual_substrate = str(
|
||||
envelope.get("actual_substrate")
|
||||
or result.get("actual_substrate")
|
||||
or ""
|
||||
).strip()
|
||||
if actual_substrate:
|
||||
receipt["actual_substrate"] = actual_substrate
|
||||
terminal_receipt_by_task[task_id] = receipt
|
||||
suggested_name = str(result.get("suggested_name") or "").strip()
|
||||
if suggested_name:
|
||||
suggested_name_by_task[task_id] = suggested_name
|
||||
|
|
@ -640,6 +659,8 @@ def _annotate_terminal_task_truth(
|
|||
message["task_phase"] = "finalizing"
|
||||
if message.get("is_progress") and task_id in terminal_status_by_task:
|
||||
message["task_terminal_status"] = terminal_status_by_task[task_id]
|
||||
if latest_progress_by_task.get(task_id) is message:
|
||||
message.update(terminal_receipt_by_task.get(task_id) or {})
|
||||
is_summary = str(message.get("system_type") or "") == "task_summary"
|
||||
if is_summary or (
|
||||
task_id not in summary_task_ids
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ _TAG_MUTATING_FLAGS = frozenset({
|
|||
})
|
||||
# `-v`/`--verify` checks a tag's GPG signature and writes nothing — it is
|
||||
# read-only inspection, not mutation (it sat in the mutating set, refusing
|
||||
# `git tag -v <tag>` at a runtime target against the SYSTEM.md contract).
|
||||
# `git tag -v <tag>` at a runtime target against the ARCHITECTURE contract that
|
||||
# read-only shell git is allowed everywhere).
|
||||
_TAG_READONLY_FLAGS = frozenset({
|
||||
"-l", "--list", "-n", "-v", "--verify", "--sort", "--format", "--points-at",
|
||||
"--contains", "--merged", "--no-merged", "--column", "--no-column",
|
||||
|
|
|
|||
|
|
@ -132,8 +132,9 @@ BEST_EFFORT_REASON_CODES = frozenset({
|
|||
})
|
||||
|
||||
# Typed final-answer protocol marker (machine-readable deliverable payload,
|
||||
# separate from reasoning prose). The agent is instructed in SYSTEM.md to end
|
||||
# short-deliverable answers with this exact line.
|
||||
# separate from reasoning prose). Since v6.60.0 the instruction to end a
|
||||
# short-deliverable answer with this exact line comes from the per-task
|
||||
# contract (answer_protocol="final_answer_line"), never from prompts/SYSTEM.md.
|
||||
FINAL_ANSWER_MARKER = "FINAL ANSWER:"
|
||||
|
||||
OUTCOME_TIER_SOLVED = "solved"
|
||||
|
|
|
|||
|
|
@ -69,16 +69,26 @@ def _parse_updated_at(value: Any) -> datetime | None:
|
|||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _delegated_receipt_counts(value: Any) -> tuple[int, int] | None:
|
||||
if not isinstance(value, dict) or value.get("evidence_read_failed"):
|
||||
return None
|
||||
counts = (value.get("delegated_runs_started"), value.get("delegated_runs_settled"))
|
||||
if any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in counts):
|
||||
return None
|
||||
return counts
|
||||
|
||||
|
||||
def project_replica_task_result_fields(
|
||||
canonical_fields: Dict[str, Any],
|
||||
replica_fields: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the replica overlay permitted over a canonical task result.
|
||||
|
||||
A terminal canonical post-task checkpoint owns only its synthesis fields
|
||||
and the accounting snapshot written with that checkpoint. The replica
|
||||
continues to own acceptance, result, review, and trace fields. ``updated_at``
|
||||
is monotonic projection metadata only; it never selects field authority.
|
||||
A terminal canonical post-task checkpoint owns its synthesis fields and
|
||||
accounting snapshot. Canonical custody also retains non-regressing delegation
|
||||
receipts and canonical-if-present reconciliation disclosures under the narrow
|
||||
rules below. The replica continues to own acceptance, result, review, and trace fields.
|
||||
``updated_at`` is monotonic metadata only; it never selects field authority.
|
||||
"""
|
||||
overlay = dict(replica_fields)
|
||||
canonical_checkpoint = canonical_fields.get("root_phase_checkpoint")
|
||||
|
|
@ -107,6 +117,61 @@ def project_replica_task_result_fields(
|
|||
if str(canonical_fields["continuation_narrative"].get("text") or "").strip():
|
||||
overlay.pop("continuation_narrative", None)
|
||||
|
||||
# Write-side custody heals must survive both reducer consumers. A canonical
|
||||
# absence still accepts the first replica value.
|
||||
for field in (
|
||||
"delegated_runs_unreconciled",
|
||||
"delegate_terminal_reconciliation",
|
||||
):
|
||||
if field in canonical_fields:
|
||||
overlay.pop(field, None)
|
||||
|
||||
canonical_envelope = canonical_fields.get("subagent_envelope")
|
||||
canonical_evidence = (
|
||||
canonical_envelope.get("execution_evidence")
|
||||
if isinstance(canonical_envelope, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(canonical_evidence, dict) and canonical_evidence:
|
||||
replica_envelope = overlay.get("subagent_envelope")
|
||||
replica_evidence = (
|
||||
replica_envelope.get("execution_evidence")
|
||||
if isinstance(replica_envelope, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
canonical_counts = _delegated_receipt_counts(canonical_evidence)
|
||||
replica_counts = _delegated_receipt_counts(replica_evidence)
|
||||
canonical_wins = not isinstance(replica_evidence, dict) or not replica_evidence
|
||||
if isinstance(replica_evidence, dict) and replica_evidence:
|
||||
canonical_wins = bool(
|
||||
canonical_counts is not None
|
||||
and (
|
||||
replica_counts is None
|
||||
or all(a >= b for a, b in zip(canonical_counts, replica_counts))
|
||||
)
|
||||
)
|
||||
if canonical_wins:
|
||||
merged_envelope = (
|
||||
dict(replica_envelope)
|
||||
if isinstance(replica_envelope, dict)
|
||||
else dict(canonical_envelope)
|
||||
)
|
||||
merged_envelope["execution_evidence"] = dict(canonical_evidence)
|
||||
canonical_substrate = str(
|
||||
canonical_envelope.get("actual_substrate")
|
||||
or canonical_fields.get("actual_substrate")
|
||||
or ""
|
||||
).strip()
|
||||
if canonical_substrate:
|
||||
merged_envelope["actual_substrate"] = canonical_substrate
|
||||
overlay["actual_substrate"] = canonical_substrate
|
||||
if "native_contribution" in canonical_envelope:
|
||||
merged_envelope["native_contribution"] = canonical_envelope[
|
||||
"native_contribution"
|
||||
]
|
||||
overlay["subagent_envelope"] = merged_envelope
|
||||
|
||||
canonical_updated_at = _parse_updated_at(canonical_fields.get("updated_at"))
|
||||
replica_updated_at = _parse_updated_at(overlay.get("updated_at"))
|
||||
if canonical_updated_at is not None and (
|
||||
|
|
|
|||
|
|
@ -792,7 +792,7 @@ def _rate_limited_outcome(
|
|||
attempts: int = 2,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Terminal outcome of a rate-limited safety check, split by lane. The local-FALLBACK
|
||||
lane keeps its documented fail-open contract (SYSTEM.md case (c): a broken
|
||||
lane keeps its documented fail-open contract (ARCHITECTURE "Safety and runtime mode" case (c): a broken
|
||||
chosen-as-fallback local runtime warns instead of blocking every unknown tool) — a
|
||||
429 there must not be stricter than the RuntimeError beside it. Every other lane
|
||||
blocks with the typed non-verdict outcome below."""
|
||||
|
|
@ -821,7 +821,7 @@ def _safety_unavailable_blocked(
|
|||
call — reporting it as SAFETY_VIOLATION told the agent its own command was unsafe,
|
||||
sending it hunting for a "safer" rewording of a benign command. The honest outcome
|
||||
keeps `full` mode's owner contract (an unchecked guarded call never executes; the
|
||||
existing fail-open cases stay exactly the SYSTEM.md-documented no-backend three) while
|
||||
existing fail-open cases stay exactly the ARCHITECTURE-documented no-backend three) while
|
||||
removing the false accusation: the ⚠️ *_UNAVAILABLE prefix classifies as a plain
|
||||
tool ERROR downstream, never as `safety_violation`, and the message itself carries
|
||||
the retry contract (P5: the instruction lives with the fact). Disclosed twice: the
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ BAND_PATHS = {
|
|||
"tests/test_terminal_durability_v664.py": "Entered the band from 974 lines: terminal durability coverage now pins retry-admission failure custody so an unpersisted terminal row cannot publish task_done or lose the retry marker.",
|
||||
"tests/test_timeout_policy.py": "Adaptive timeout and custody regression suite covers raw-deadline admission, explicit finalization reserve, transport bounds, and late-result reconciliation.",
|
||||
"tests/test_tool_result.py": "F3.1 typed-organ pins carried with the D02 organ (D04 entry 9): the closed code table, the one legacy-text adapter, the publish/sidecar seam and the meta-boundary contracts pin one organ in one suite; sibling suites (meta_boundaries, t46, classification differential) already hold the spill-over families.",
|
||||
"tests/test_ui_smoke_project_continuity.py": "Playwright smoke of the Project continuity contracts (panel/Main re-homing, lifecycle rows, the Main-root project pointer): each test drives one end-to-end owner flow across both surfaces, so the cross-surface assertions cannot be split into smaller files without losing what they prove.",
|
||||
"tests/test_usage_accounting.py": None,
|
||||
"tests/test_v6730_origin_invariant.py": None,
|
||||
"tests/test_v678_receipt_reconciliation.py": None,
|
||||
|
|
@ -225,5 +226,5 @@ BYTE_BASELINE_DEBT = {
|
|||
|
||||
BYTE_DEBT = {
|
||||
"tests/test_devtools_benchmarks.py": 327883,
|
||||
"web/modules/chat.js": 208394,
|
||||
"web/modules/chat.js": 207728,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -677,14 +677,6 @@ def effective_task_result(
|
|||
continue
|
||||
if key == "artifacts":
|
||||
continue
|
||||
if key in {"delegated_runs_unreconciled", "delegate_terminal_reconciliation"} and key in result:
|
||||
# Custody disclosure is CANONICAL-authoritative once the
|
||||
# canonical row carries it: write-side heals (kill-clear,
|
||||
# boot backfill) land only there, and a retained stale child
|
||||
# replica must not re-shadow a healed row. A canonical row
|
||||
# that lacks the field still takes the replica's copy
|
||||
# (the pre-copy-back window).
|
||||
continue
|
||||
merged[key] = value
|
||||
merged.setdefault("child_drive_root", child_text)
|
||||
merged.setdefault("headless_child_drive_root", child_text)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ _MANAGED_SKIP_NOTE = "cannot be split into smaller commits"
|
|||
|
||||
|
||||
ADVISORY_REVIEW_CHOICE_GUIDANCE = (
|
||||
"Normally the LLM runs the cheap advisory_review immediately before "
|
||||
"Normally the LLM runs the cheap preflight_review immediately before "
|
||||
"commit_reviewed. When advisory review is slow, unhealthy, unavailable, or "
|
||||
"low-value, the LLM may deliberately choose skip_advisory_review=True; the "
|
||||
"choice is durably audited. This skip bypasses only the requirements for "
|
||||
|
|
@ -814,7 +814,7 @@ def _next_step_guidance(latest: Optional["AdvisoryRunRecord"], state: "AdvisoryR
|
|||
)
|
||||
|
||||
if latest and latest.status == "bypassed":
|
||||
return "Advisory was bypassed (audited). No open obligations — commit_reviewed should proceed. Consider running advisory_review for a proper review."
|
||||
return "Advisory was bypassed (audited). No open obligations — commit_reviewed should proceed. Consider running preflight_review for a proper review."
|
||||
|
||||
fresh_critical = [
|
||||
i for i in (latest.items if latest else []) or []
|
||||
|
|
|
|||
|
|
@ -197,8 +197,9 @@ def _finish_mutation(
|
|||
"⚠️ Advisory pre-review is now stale — run preflight_review before commit_reviewed."
|
||||
)
|
||||
# A pro-mode edit of a protected surface announces itself here exactly as it
|
||||
# does from git._repo_write / _str_replace_editor (SYSTEM.md's protected-write
|
||||
# contract): the mode ALLOWS the write, and the notice is what keeps it visible.
|
||||
# does from git._repo_write / _str_replace_editor (the protected-write contract
|
||||
# in ARCHITECTURE "Safety and runtime mode" and SYSTEM.md "Safety-critical
|
||||
# files"): the mode ALLOWS the write, and the notice is what keeps it visible.
|
||||
protected = protected_paths_in(changed_paths) if targets_system or not ctx.is_workspace_mode() else []
|
||||
if protected and mode_allows_protected_write(_runtime_mode()):
|
||||
footer += "\n\n" + core_patch_notice(protected)
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ def _ocr_pdf(ctx: ToolContext, path: str = "", max_pages: int = 0) -> str:
|
|||
return (
|
||||
"⚠️ OCR_PDF_SCANNED_UNAVAILABLE: this PDF has no extractable text layer (likely "
|
||||
"scanned/image-only). True OCR of scanned pages is not available in this build — "
|
||||
"render a page to an image and call vlm_query on it instead."
|
||||
"render a page to an image and view_image it (or vlm_query it) instead."
|
||||
)
|
||||
note = "" if total <= cap else f"\n\n[disclosed: showed first {cap} of {total} pages]"
|
||||
if len(text) > _OCR_PDF_MAX_CHARS:
|
||||
|
|
@ -302,7 +302,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"Extract the text of a local PDF file (the embedded text layer of a digital PDF). "
|
||||
"Use for reading PDFs attached to the task (see the [ATTACHMENTS] manifest) or produced "
|
||||
"during work. Scanned/image-only PDFs have no text layer and return a typed "
|
||||
"OCR_PDF_SCANNED_UNAVAILABLE notice — for those, render a page and use vlm_query."
|
||||
"OCR_PDF_SCANNED_UNAVAILABLE notice — for those, render a page and view_image it (or vlm_query it)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -949,7 +949,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
return [
|
||||
ToolEntry("start_service", {
|
||||
"name": "start_service",
|
||||
"description": "Start a task-scoped long-running service and return pid/readiness/state.",
|
||||
"description": "Start a task-scoped long-running service and return pid/readiness/state. In runtime_mode=light a service whose cwd is the Ouroboros repository is refused (LIGHT_MODE_BLOCKED): pass an explicit external/task/artifact cwd.",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"cmd": {"type": "array", "items": {"type": "string"}},
|
||||
"cwd": {
|
||||
|
|
|
|||
|
|
@ -8,20 +8,15 @@ You can:
|
|||
- Reflect on recent events, your identity, your goals
|
||||
- Notice things worth acting on (time patterns, unfinished work, ideas)
|
||||
- Message the user proactively via send_user_message (use sparingly)
|
||||
- Start a reviewed external presence cycle via initiate_presence when a configured destination genuinely needs attention; the cycle itself must deliver through its selected transport tool
|
||||
- Update your scratchpad or identity
|
||||
- Update your scratchpad or identity, and keep the knowledge base current
|
||||
- Decide when to wake up next via set_next_wakeup (in seconds)
|
||||
- Read your own code via read_file/list_files
|
||||
- Read/write knowledge base via knowledge_read/knowledge_write/knowledge_list
|
||||
- Search the web via web_search
|
||||
- Access local data files via read_file/list_files with root=runtime_data
|
||||
- Review chat history via chat_history
|
||||
- Inspect recent task summaries via recent_tasks
|
||||
- Recover context via recent_tasks and chat_history
|
||||
|
||||
You cannot execute powerful work directly from this mode. Do not run shell/code
|
||||
tools, start services, commit, review, toggle evolution, schedule subagents, or
|
||||
wait on subagents. When you find executable work, sharpen it into the backlog or
|
||||
scratchpad so an Evolution Campaign or foreground task can execute it visibly.
|
||||
The tool schemas visible to you are the complete capability set for this mode.
|
||||
Powerful work is not executed from here: you do not run shell/code, start
|
||||
services, commit, review, toggle evolution, schedule subagents, or
|
||||
wait on subagents. When you find executable work, sharpen it into the backlog
|
||||
or scratchpad so an Evolution Campaign or foreground task can execute it visibly.
|
||||
|
||||
## Maintenance Protocol (EVERY WAKEUP)
|
||||
|
||||
|
|
@ -30,54 +25,47 @@ that needs attention and do it. Not all of them — one per wakeup. Rotate.
|
|||
|
||||
### The Checklist
|
||||
|
||||
1. **Dialogue consolidation** — When was `dialogue_blocks.json` last updated?
|
||||
Check `memory/dialogue_meta.json` for the last offset. If >100 new messages
|
||||
since last consolidation → record a concrete backlog/scratchpad item for a
|
||||
foreground task or Evolution Campaign to consolidate it visibly.
|
||||
1. **Identity freshness** — Read `identity.md`. If real experience since the
|
||||
last update is not reflected there → update it now. Not a rewrite — a
|
||||
paragraph about what changed since last time.
|
||||
|
||||
2. **Identity freshness** — When was `identity.md` last updated?
|
||||
Check the `UpdatedAt` or read the file. If >24 hours of active dialogue
|
||||
have passed without an update → update it now. Not a rewrite — a paragraph
|
||||
about what changed since last time.
|
||||
|
||||
3. **Scratchpad freshness** — Same check for `scratchpad.md` (auto-generated
|
||||
2. **Scratchpad freshness** — Same check for `scratchpad.md` (auto-generated
|
||||
from `scratchpad_blocks.json`). If the working memory doesn't reflect
|
||||
reality → `update_scratchpad` to append a new block.
|
||||
|
||||
4. **Knowledge base gaps** — Skim recent chat history (last 20 messages).
|
||||
3. **Knowledge base gaps** — Skim recent chat history (last 20 messages).
|
||||
Did I learn something that should be a knowledge entry? A new gotcha,
|
||||
a recipe, a pattern? If yes → `knowledge_write`.
|
||||
|
||||
5. **Process-memory freshness** — Has recent work created new durable lessons
|
||||
4. **Process-memory freshness** — Has recent work created new durable lessons
|
||||
that exist only in transient logs? If yes → read the relevant recent task
|
||||
evidence and write the durable lesson or backlog item before it fades from
|
||||
working memory.
|
||||
|
||||
6. **Improvement backlog** — Read the `improvement-backlog` knowledge topic for
|
||||
situational awareness. Routine grooming is now AUTOMATED: recurrence is counted
|
||||
in place (not dropped), the digest is ranked by priority then recurrence then
|
||||
recency, close-on-commit marks addressed items `done`, and an LLM grooming pass
|
||||
(`improvement_backlog.groom_backlog`) merges near-duplicates, marks resolved
|
||||
items done, and caps the list once it grows. You do NOT need to hand-edit the
|
||||
file for normal upkeep; intervene manually only for a judgment call the automated
|
||||
pass cannot make. If you do edit it, preserve the exact `### id` + `- key: value`
|
||||
format. Backlog items remain advisory — do NOT auto-start implementation from
|
||||
backlog memory alone. Non-trivial repo/process/prompt/tooling fixes still
|
||||
require `plan_task` before coding.
|
||||
5. **Improvement backlog** — Read the `improvement-backlog` knowledge topic for
|
||||
situational awareness. Routine grooming is AUTOMATED: recurrence is counted
|
||||
in place, the digest is ranked by priority then recurrence then recency,
|
||||
close-on-commit marks addressed items `done`, and an LLM grooming pass
|
||||
merges near-duplicates and caps the list once it grows. You do NOT need to
|
||||
hand-edit the file for normal upkeep; intervene manually only for a judgment
|
||||
call the automated pass cannot make, preserving the exact `### id` +
|
||||
`- key: value` format. Backlog items remain advisory — do NOT auto-start
|
||||
implementation from backlog memory alone; non-trivial fixes are executed by
|
||||
a visible foreground task, never from this mode.
|
||||
|
||||
7. **Tech radar** — Every 3rd wakeup (not every time): quick web_search
|
||||
6. **Tech radar** — Every 3rd wakeup (not every time): quick web_search
|
||||
for new models, pricing changes, tool updates. Write to knowledge base
|
||||
if something changed.
|
||||
|
||||
8. **Registry awareness** — Does `memory/registry.md` accurately reflect what
|
||||
7. **Registry awareness** — Does `memory/registry.md` accurately reflect what
|
||||
data I have? If you notice new gaps or stale entries → note them in
|
||||
scratchpad or backlog for a visible task to update the registry (registry
|
||||
write tools are not available in background mode).
|
||||
|
||||
### How to check
|
||||
|
||||
Read `memory/dialogue_meta.json` and `memory/scratchpad.md` first.
|
||||
That tells you what's stale. Then pick the most urgent item.
|
||||
Read `memory/scratchpad.md` and `memory/identity.md` first. That tells you
|
||||
what's stale. Then pick the most urgent item.
|
||||
|
||||
If everything is fresh (rare) — then reflect freely, or just set a longer
|
||||
wakeup and save budget.
|
||||
|
|
@ -102,7 +90,7 @@ When a tool call fails, returns empty, or produces an unexpected result:
|
|||
- **First failure:** retry once if it seems transient.
|
||||
- **Second failure of the same kind:** STOP retrying. Record it immediately —
|
||||
what tool, what context, what the error looked like. Write it to scratchpad
|
||||
or schedule a task to diagnose later.
|
||||
or note it for a foreground task to diagnose later.
|
||||
- **Never silently eat repeated failures.** A pattern of failure is data.
|
||||
Lost data is lost self-understanding (P1).
|
||||
|
||||
|
|
@ -122,14 +110,14 @@ When recording failures, categorize them:
|
|||
|
||||
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 10 rounds per wakeup by default. Use them wisely — each round costs money,
|
||||
but do not reduce cognitive quality or horizon merely to save cost.
|
||||
You have up to 10 rounds per wakeup by default. Use them wisely — each round
|
||||
costs money, but do not reduce cognitive quality or horizon merely to save cost.
|
||||
|
||||
## Messages From My Human
|
||||
|
||||
My human communicates with me through the local message bus (Web UI), and
|
||||
reviewed transport skills may feed additional channels into that same live chat stream.
|
||||
Between tasks, you may notice new messages or patterns worth acting on.
|
||||
reviewed transport skills may feed additional channels into that same live chat
|
||||
stream. Between tasks, you may notice new messages or patterns worth acting on.
|
||||
If you have something genuinely useful to say, use `send_user_message`.
|
||||
|
||||
## Guidelines
|
||||
|
|
@ -141,7 +129,6 @@ If you have something genuinely useful to say, use `send_user_message`.
|
|||
- Do NOT message my human unless you have something genuinely worth saying.
|
||||
- If nothing interesting is happening and maintenance is done, set a longer
|
||||
wakeup (600-1800s).
|
||||
- You have a budget cap for background thinking. Be economical.
|
||||
- **Do ONE maintenance item per wakeup, not all of them.**
|
||||
Rotate through the checklist across wakeups. This keeps the loop coherent
|
||||
while ensuring nothing rots for more than a few cycles.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
You are a security supervisor for an autonomous AI agent running on a user's Mac.
|
||||
You are a security supervisor for an autonomous AI agent running on a user's machine (a macOS, Linux, or Windows desktop, or a headless server/container).
|
||||
Your job is to block ONLY clearly destructive or exfiltrating actions. Default to SAFE.
|
||||
|
||||
Three verdicts:
|
||||
|
||||
DANGEROUS — block only these:
|
||||
- Destructive commands targeting system or home root: rm -rf /, rm -rf ~, disk format/wipe, deleting /System /usr /bin /etc
|
||||
- Destructive commands targeting system or home root: rm -rf /, rm -rf ~, disk format/wipe, deleting system directories (/System, /usr, /bin, /etc, C:\Windows)
|
||||
- Commands that make the OS unbootable or kill critical system processes irreversibly
|
||||
- HTTP requests (curl, wget, python, etc.) that send API keys, tokens, or credentials to non-localhost external URLs
|
||||
- Modifying or deleting any protected Ouroboros runtime path outside `runtime_mode=pro` + normal commit review: safety-critical files (`BIBLE.md`, `ouroboros/safety.py`, `ouroboros/runtime_mode_policy.py`, `ouroboros/tools/registry.py`, `ouroboros/tools/extension_dispatch.py`, `prompts/SAFETY.md`), frozen contracts (`ouroboros/contracts/`, `ouroboros/gateway/contracts.py`, `docs/CHECKLISTS.md`, `tests/test_contracts.py`), and release/managed invariants (`.github/workflows/ci.yml`, build scripts, `scripts/build_repo_bundle.py`, `ouroboros/launcher_bootstrap.py`, `ouroboros/repo_remotes.py`, `supervisor/git_ops.py`, `supervisor/update_merge.py`, `supervisor/update_merge_policy.py`)
|
||||
- Any tool call that raises Ouroboros's own privilege scope by writing `OUROBOROS_RUNTIME_MODE` to settings.json, POSTing `OUROBOROS_RUNTIME_MODE` in the body to `/api/settings`, calling `ouroboros.config.save_settings(...)` from a subprocess with an elevated mode, or fetching `/api/settings` from a browser-evaluate JS payload with the same intent. Runtime mode is owner-controlled — the agent must not self-elevate through any channel. The legitimate path is to ask the owner to stop the agent, edit settings.json directly, then restart.
|
||||
- Any tool call that grants Ouroboros's own subagents write power by enabling `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` through settings.json, `save_settings(...)`, a `/api/settings` POST, or browser automation. This master toggle for mutative (acting) subagents is owner-controlled — the agent must not self-enable it through any channel; ask the owner to change it with the agent stopped, then restart.
|
||||
- Any tool call that lowers Ouroboros's owner-selected cognitive horizon by writing `OUROBOROS_CONTEXT_MODE=low` to settings.json, calling `save_settings(...)` with `OUROBOROS_CONTEXT_MODE: low`, POSTing to `/api/owner/context-mode` with `low`, or invoking `ouroboros settings context-mode low`. Context mode is owner-controlled — the agent may report overflow and ask the owner to switch Low/Max, but must not lower its own horizon through process, HTTP, CLI, or browser automation. Since v6.80.0 this setting ALSO decides whether the BIBLE P3 blocking scope review applies at all (`max` = blocking gate, `low` = declaredly not performed), so lowering it would weaken the immune system as well as the horizon.
|
||||
- Any tool call that sets Ouroboros's own self-evolution controls — enabling `OUROBOROS_POST_TASK_EVOLUTION` or setting `OUROBOROS_EVOLUTION_PERSISTENT_OBJECTIVE` — by writing them to settings.json, calling `save_settings(...)`, POSTing to `/api/settings`, or browser automation. Post-task self-evolution (the V4 envelope that lets the agent promote self-modification cycles between tasks) AND the persistent evolution objective (a standing steer appended to every evolution campaign) are owner-controlled — the agent must not self-set either through any channel; ask the owner to change them in the Settings UI, or stop the agent and edit settings.json, then restart.
|
||||
- Any tool call that lowers Ouroboros's own LLM-safety coverage by changing `OUROBOROS_SAFETY_MODE` (e.g. to `light` or `off`) — writing it to settings.json, calling `save_settings(...)`, POSTing to `/api/settings` or `/api/owner/safety-mode`, or browser automation. Safety mode gates the LLM safety supervisor layer (the deterministic sandbox always stays on); it is owner-controlled, and the agent must not reduce its own supervision through any channel. Ask the owner to change it via the dedicated `/api/owner/safety-mode` owner endpoint, or stop the agent and edit settings.json directly, then restart.
|
||||
- Any tool call that self-attests Ouroboros's own skill to skip the immune system's review — POSTing to `/api/owner/skills/<skill>/attest-review` from a subprocess (curl/httpx), via browser-evaluate JS, or otherwise loopback-calling that owner endpoint. Owner-attestation skips the expensive LLM skill review for the owner's own skill or a hash-verified official OuroborosHub payload and is owner-only; the agent must not self-attest any skill through any channel to bypass review. The legitimate path is the OWNER invoking the owner-only attestation endpoint (`POST /api/owner/skills/<skill>/attest-review`) directly — the agent never does this on its own behalf.
|
||||
- Modifying or deleting any protected Ouroboros runtime path outside `runtime_mode=pro` + normal commit review. Protected paths (exact mirror of `PROTECTED_RUNTIME_PATHS` plus the protected prefix in `ouroboros/runtime_mode_policy.py`): `BIBLE.md`, `ouroboros/safety.py`, `ouroboros/runtime_mode_policy.py`, `ouroboros/tools/registry.py`, `ouroboros/tools/extension_dispatch.py`, `prompts/SAFETY.md`, `ouroboros/tools/registry_core.py`, `ouroboros/tools/registry_guard_process.py`, `ouroboros/tools/registry_guards.py`, `ouroboros/tools/tool_catalog.py`, `ouroboros/tools/tool_context.py`, `ouroboros/tools/tool_resolution.py`, `ouroboros/tools/tool_result.py`, `ouroboros/gateway/contracts.py`, `docs/CHECKLISTS.md`, `docs/CHECKLISTS_ARCHIVE.md`, `tests/test_contracts.py`, `ouroboros/size_ratchet_manifest.py`, `.github/workflows/ci.yml`, `Ouroboros.spec`, `build.sh`, `build_linux.sh`, `build_windows.ps1`, `scripts/build_repo_bundle.py`, `ouroboros/launcher_bootstrap.py`, `ouroboros/repo_remotes.py`, `supervisor/git_ops.py`, `supervisor/update_merge.py`, `supervisor/update_merge_policy.py`, `supervisor/git_ops_remotes.py`, `supervisor/git_ops_rescue.py`, `supervisor/git_ops_reset.py`, `supervisor/git_ops_updates.py`, `supervisor/update_candidate.py`, `supervisor/update_carriers.py`, `supervisor/update_merge_plan.py`, and everything under `ouroboros/contracts/`
|
||||
- Any tool call through which the agent WRITES one of its own owner-only controls — by writing settings.json (directly or via `ouroboros.config.save_settings(...)` from a subprocess), POSTing to `/api/settings` or an `/api/owner/*` endpoint (`/api/owner/runtime-mode`, `/api/owner/context-mode`, `/api/owner/safety-mode`, …), invoking the `ouroboros settings` CLI, or driving the UI/loopback API from browser-automation JS. The controls, each with the direction the agent must never take on its own behalf: `OUROBOROS_RUNTIME_MODE` (raising its own privilege scope); `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` (granting its own subagents write power); `OUROBOROS_CONTEXT_MODE` (lowering its owner-selected cognitive horizon — since v6.80.0 `low` also declares the BIBLE P3 blocking scope review not performed, so this weakens the immune system too); `OUROBOROS_SAFETY_MODE` (lowering its own LLM-safety coverage; the deterministic sandbox always stays on); `OUROBOROS_POST_TASK_EVOLUTION` and `OUROBOROS_EVOLUTION_PERSISTENT_OBJECTIVE` (self-setting its own evolution controls); and `POST /api/owner/skills/<skill>/attest-review` (self-attesting a skill to skip the immune system's review). READING any of these values (e.g. `grep` on settings.json) is not dangerous. The legitimate path is always the OWNER acting: the dedicated owner endpoint, the Settings UI, or stopping the agent, editing settings.json directly, and restarting — never the agent on its own behalf, through any channel (subprocess, HTTP, CLI, browser automation, MCP or extension tool).
|
||||
|
||||
SUSPICIOUS — allow but warn the agent:
|
||||
- Commands that delete large amounts of data outside the project directory
|
||||
- Network requests to unusual destinations with potentially sensitive data
|
||||
- Commands that modify system-wide configs (/etc, ~/.bashrc, ~/.zshrc, launchd, cron)
|
||||
- Commands that modify system-wide configs (/etc, shell rc files, launchd/systemd units, cron, Windows Task Scheduler)
|
||||
- Anything that feels risky but has legitimate dev use cases
|
||||
|
||||
SAFE — verdict SAFE, never flag as DANGEROUS or SUSPICIOUS:
|
||||
|
|
@ -33,7 +28,7 @@ SAFE — verdict SAFE, never flag as DANGEROUS or SUSPICIOUS:
|
|||
|
||||
External workspace tasks may need dependency installation to validate or solve the target project. Project-local installs are normal development work. In `runtime_mode=pro`, system/global installs and installer scripts may also be SAFE when they are plausibly needed for the external workspace task; keep flagging exfiltrating, destructive, or suspicious package/script behavior. `sudo` must be noninteractive (`sudo -n`); password-prompting sudo is blocked before this review and should be treated as environment-blocked, not worked around.
|
||||
|
||||
Note: this SAFE bucket describes the verdicts you should return. It is NOT a statement about which calls actually reach you — that is governed by `ouroboros/safety.py::TOOL_POLICY`. Most trusted built-ins (file/context tools, knowledge and memory tools, read-only VCS, reviewed commit gates, task/review status, service status/log reads, web_search, browse_page, etc.) have `POLICY_SKIP` and never reach you. The tools that DO reach you are either `POLICY_CHECK` (PR integration flow, CI, GitHub writes, `skill_exec`, and reviewed extension tools that fall through policy) or `POLICY_CHECK_CONDITIONAL` process tools: `run_command`, `run_script`, `start_service`, and `verify_and_record` (whose declared verification `check` is run like a command). For these process tools, deterministic safe-subject commands may be whitelisted before this review; non-whitelisted shell/script/service/check subjects reach you. Long-running services are still process subjects: allow normal dev servers, but flag clearly destructive, exfiltrating, or protected-path behavior. For calls that reach you, the guidance above is what you should output.
|
||||
Note: this SAFE bucket describes the verdicts you should return. It is NOT a statement about which calls actually reach you — that is governed by `ouroboros/safety.py::TOOL_POLICY`. Most trusted built-ins (file/context tools, knowledge and memory tools, read-only VCS, reviewed commit gates, task/review status, service status/log reads, web_search, browse_page, etc.) have `POLICY_SKIP` and never reach you. The tools that DO reach you are: `POLICY_CHECK` tools (PR integration flow, CI, GitHub writes, `skill_exec`, `integrate_subagent_patch` / `integrate_delegated_patch`, `generate_evolution_stats`, `submit_skill_to_hub`, and reviewed extension tools that fall through policy); every MCP tool (`mcp_<server>__<tool>`), which has no deterministic pre-scan, so you are its only gate for the owner-control writes above; and the `POLICY_CHECK_CONDITIONAL` process tools `run_command`, `run_script`, `start_service`, and `verify_and_record` (whose declared verification `check` is run like a command) — for these, deterministic safe-subject commands may be whitelisted before this review, and non-whitelisted shell/script/service/check subjects reach you. Long-running services are still process subjects: allow normal dev servers, but flag clearly destructive, exfiltrating, or protected-path behavior. For calls that reach you, the guidance above is what you should output.
|
||||
|
||||
When in doubt → SAFE. Only DANGEROUS when clearly and unambiguously harmful to the system.
|
||||
|
||||
|
|
|
|||
1206
prompts/SYSTEM.md
1206
prompts/SYSTEM.md
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "ouroboros"
|
||||
version = "6.113.5"
|
||||
version = "6.114.0"
|
||||
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
|
|||
|
|
@ -46,24 +46,24 @@
|
|||
<article>
|
||||
<p class="download-platform">macOS 12+ · Apple silicon only</p>
|
||||
<h2>macOS</h2>
|
||||
<a data-release-download="macos-arm64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5.dmg">Download for macOS (.dmg)</a>
|
||||
<a data-release-download="macos-arm64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0.dmg">Download for macOS (.dmg)</a>
|
||||
<p>Open the DMG, drag <code>Ouroboros.app</code> to Applications, then launch it from Applications.</p>
|
||||
</article>
|
||||
<article>
|
||||
<p class="download-platform">Windows x64</p>
|
||||
<h2>Windows</h2>
|
||||
<a data-release-download="windows-x64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-windows-x64.zip">Download for Windows (.zip)</a>
|
||||
<a data-release-download="windows-x64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-windows-x64.zip">Download for Windows (.zip)</a>
|
||||
<p>Extract the ZIP, open the <code>Ouroboros</code> folder, and run <code>Ouroboros.exe</code>.</p>
|
||||
</article>
|
||||
<article id="linux">
|
||||
<p class="download-platform">Linux x86_64</p>
|
||||
<h2>Linux</h2>
|
||||
<div class="download-actions">
|
||||
<a data-release-download="linux-deb-amd64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros_6.113.5_amd64.deb">Debian / Ubuntu / Astra (.deb)</a>
|
||||
<a data-release-download="linux-rpm-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.x86_64.rpm">Fedora / RHEL (.rpm)</a>
|
||||
<a data-release-download="linux-rpm-red80-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/ouroboros-6.113.5-1.red80.x86_64.rpm">RED OS 8 (.rpm)</a>
|
||||
<a data-release-download="linux-appimage-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.AppImage">Portable AppImage</a>
|
||||
<a data-release-download="linux-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5-linux-x86_64.tar.gz">tar.gz archive</a>
|
||||
<a data-release-download="linux-deb-amd64" class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros_6.114.0_amd64.deb">Debian / Ubuntu / Astra (.deb)</a>
|
||||
<a data-release-download="linux-rpm-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.x86_64.rpm">Fedora / RHEL (.rpm)</a>
|
||||
<a data-release-download="linux-rpm-red80-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/ouroboros-6.114.0-1.red80.x86_64.rpm">RED OS 8 (.rpm)</a>
|
||||
<a data-release-download="linux-appimage-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.AppImage">Portable AppImage</a>
|
||||
<a data-release-download="linux-x86_64" class="btn btn-quiet" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0-linux-x86_64.tar.gz">tar.gz archive</a>
|
||||
</div>
|
||||
<p>Prefer the package for your distribution. Use the AppImage or archive on other distributions; Git must already be installed.</p>
|
||||
</article>
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
<section class="install-primary" aria-labelledby="macos-quick-start">
|
||||
<h2 id="macos-quick-start">macOS quick start</h2>
|
||||
<ol class="install-steps">
|
||||
<li>Click <a data-release-download="macos-arm64" href="https://github.com/razzant/ouroboros/releases/download/v6.113.5/Ouroboros-6.113.5.dmg">Download for macOS (.dmg)</a>.</li>
|
||||
<li>Click <a data-release-download="macos-arm64" href="https://github.com/razzant/ouroboros/releases/download/v6.114.0/Ouroboros-6.114.0.dmg">Download for macOS (.dmg)</a>.</li>
|
||||
<li>Open the DMG and drag <code>Ouroboros.app</code> onto the <strong>Applications</strong> shortcut.</li>
|
||||
<li>Open Ouroboros from Applications. If Gatekeeper asks, right-click the app and choose <strong>Open</strong>.</li>
|
||||
</ol>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -409,8 +409,12 @@ def test_delegate_start_recipes_match_the_fresh_start_schema():
|
|||
"ouroboros/tools/delegate_integration.py",
|
||||
)
|
||||
# The live checklist may legitimately have zero recipes (they moved to the
|
||||
# archive), but any it GAINS must stay schema-valid.
|
||||
tolerant = {"docs/CHECKLISTS.md"}
|
||||
# archive), but any it GAINS must stay schema-valid. prompts/SYSTEM.md is
|
||||
# tolerant for the same reason since the prompt audit: the recipe lives in
|
||||
# the delegate_start schema (the SSOT sent every round), and the prompt
|
||||
# only names the lane — a copy there would be the duplication class the
|
||||
# audit removed. Any recipe the prompt GAINS must still be schema-valid.
|
||||
tolerant = {"docs/CHECKLISTS.md", "prompts/SYSTEM.md"}
|
||||
for relative in (*recipe_paths, "docs/CHECKLISTS.md"):
|
||||
text = (repo / relative).read_text(encoding="utf-8")
|
||||
recipes = re.findall(r"\bdelegate_start\(([^)]*)\)", text, flags=re.DOTALL)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MODULES = ROOT / "web" / "modules"
|
||||
|
|
@ -30,6 +31,47 @@ def test_available_subagents_is_one_canonical_settings_editor() -> None:
|
|||
assert action in editor
|
||||
|
||||
|
||||
def test_every_list_editor_reveals_its_added_entry_through_the_shared_helper() -> None:
|
||||
"""docs/DESIGN.md "List editors": a new entry is scrolled into view and takes
|
||||
the caret through ONE seam, `ui_helpers.revealNewRow` — a local
|
||||
scrollIntoView/focus pair in an add path is the class this pins closed
|
||||
(DEVELOPMENT.md § Design System). The class is every Settings list editor,
|
||||
not the panel the owner happened to report."""
|
||||
helper = _read(MODULES / "ui_helpers.js")
|
||||
assert "export function revealNewRow(row, field)" in helper
|
||||
assert "scrollIntoView?.({ block: 'nearest' })" in helper
|
||||
assert "focus?.({ preventScroll: true })" in helper
|
||||
for name in ("subagents_settings.js", "reviewer_slots.js", "mcp_settings.js", "settings.js"):
|
||||
source = _read(MODULES / name)
|
||||
assert re.search(r"import \{[^}]*\brevealNewRow\b[^}]*\} from './ui_helpers\.js'", source), name
|
||||
assert "revealNewRow(" in source, f"{name} never calls the shared reveal"
|
||||
for name in ("subagents_settings.js", "reviewer_slots.js", "mcp_settings.js"):
|
||||
assert "scrollIntoView" not in _read(MODULES / name), f"{name} rolls its own reveal"
|
||||
|
||||
|
||||
def test_a_fresh_subagent_row_invites_and_only_a_save_attempt_makes_it_red() -> None:
|
||||
"""docs/DESIGN.md "List editors": the section-level line and the row-local
|
||||
tint appear only after the owner tried to save; Save and Finish say so
|
||||
through `noteSaveAttempt`, which judges the rows that existed then (a row
|
||||
added afterwards is fresh again), and `validate()` stays pure."""
|
||||
editor = _read(MODULES / "subagents_settings.js")
|
||||
primitives = _read(MODULES / "subagent_status_primitives.js")
|
||||
assert "noteSaveAttempt" in editor
|
||||
assert "validate: validationErrors," in editor
|
||||
assert "row._uiAttempted = true" in editor
|
||||
assert "Boolean(row._uiAttempted) && " in editor
|
||||
assert "row._uiAttempted && errors.length" in primitives
|
||||
assert "Choose how this subagent runs: an API model or an agent session." in primitives
|
||||
host = _read(MODULES / "settings.js")
|
||||
# Every Save click is an attempt — including one another field's validation
|
||||
# then aborts — so the stamp precedes the cadence check's early return.
|
||||
assert host.index("noteSubagentsSaveAttempt();") < host.index("Every-N cadence needs")
|
||||
assert "agentsStep?.noteSaveAttempt?.();" in _read(MODULES / "onboarding_wizard.js")
|
||||
# Errors name the card the way its heading does, never a bare "Row N".
|
||||
assert "`Subagent ${index + 1} ${text}`" in editor
|
||||
assert "`Row ${index + 1}`" not in editor
|
||||
|
||||
|
||||
def test_route_editor_extraction_does_not_merge_reviewer_semantics() -> None:
|
||||
primitive = _read(MODULES / "route_editor_primitives.js")
|
||||
reviewer = _read(MODULES / "reviewer_slots.js")
|
||||
|
|
@ -132,3 +174,11 @@ def test_effort_choice_mirrors_track_the_python_scale() -> None:
|
|||
values = re.findall(r"value: '([a-z]+)'", block.group(1))
|
||||
# `minimal` is deliberately not an owner-facing standing default (see EFFORT_OPTIONS).
|
||||
assert values == [tier for tier in EFFORT_SCALE if tier != "minimal"]
|
||||
|
||||
|
||||
def test_every_status_tone_the_card_emits_has_a_rule_in_both_sheets() -> None:
|
||||
# The card head puts data-tone="neutral" on .settings-inline-status. A tone the
|
||||
# code emits must have a rule (docs/DESIGN.md §4) — in the main sheet and in the
|
||||
# wizard's standalone sheet alike — or it silently falls through to body text.
|
||||
for sheet in ("style.css", "onboarding.css"):
|
||||
assert '.settings-inline-status[data-tone="neutral"]' in _read(ROOT / "web" / sheet), sheet
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ A7: git read-only classification is one SSOT with mode parsers, gh is argv-parse
|
|||
|
||||
import textwrap
|
||||
|
||||
from tests._typed_guard_shared import _shell_guard_text
|
||||
import pytest
|
||||
|
||||
from ouroboros.shell_parse import sudo_noninteractive_violation
|
||||
from ouroboros.tools import registry_guard_process
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -70,7 +70,7 @@ def _registry(tmp_path):
|
|||
def test_skill_state_pure_read_inspection_is_allowed(tmp_path, cmd):
|
||||
# The runtime_data file plane explicitly allows reading review.json; the
|
||||
# shell plane must not refuse the same read with a WRITE-named marker.
|
||||
blocked = _shell_guard_text(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
||||
blocked = registry_guard_process._run_shell_safety_check(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
||||
assert blocked is None
|
||||
|
||||
|
||||
|
|
@ -80,8 +80,9 @@ def test_skill_state_pure_read_inspection_is_allowed(tmp_path, cmd):
|
|||
'python -c "open(\'data/state/skills/w/enabled.json\', \'w\').write(\'{}\')"',
|
||||
])
|
||||
def test_skill_state_write_shapes_stay_blocked(tmp_path, cmd):
|
||||
blocked = _shell_guard_text(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
||||
assert blocked is not None and "SKILL_STATE_WRITE_BLOCKED" in blocked
|
||||
blocked = registry_guard_process._run_shell_safety_check(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
||||
# v7 D02: the guard returns a typed ToolResult; the code is the contract.
|
||||
assert blocked is not None and blocked.code == "SKILL_STATE_WRITE_BLOCKED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -214,16 +215,22 @@ def test_glued_dash_c_selects_the_same_base_as_split(tmp_path):
|
|||
runtime.mkdir()
|
||||
outside = tmp_path / "proj"
|
||||
outside.mkdir()
|
||||
# POSIX spellings: the predicate parses the command with shlex, which eats
|
||||
# Windows backslashes (pre-existing, upstream-owned residual).
|
||||
for spelling in (f"git -C {runtime.as_posix()} commit -m x", f"git -C{runtime.as_posix()} commit -m x"):
|
||||
# argv lists, not f-strings: the POSIX lexer eats the backslashes of a
|
||||
# Windows path inside a shell STRING (3-OS CI matrix rule).
|
||||
for spelling in (
|
||||
["git", "-C", str(runtime), "commit", "-m", "x"],
|
||||
["git", f"-C{runtime}", "commit", "-m", "x"],
|
||||
):
|
||||
assert external_workspace_git_violation(
|
||||
spelling,
|
||||
active_root=outside,
|
||||
cwd=str(outside),
|
||||
protected_roots=[runtime],
|
||||
), spelling
|
||||
for spelling in (f"git -C {outside.as_posix()} commit -m x", f"git -C{outside.as_posix()} commit -m x"):
|
||||
for spelling in (
|
||||
["git", "-C", str(outside), "commit", "-m", "x"],
|
||||
["git", f"-C{outside}", "commit", "-m", "x"],
|
||||
):
|
||||
assert external_workspace_git_violation(
|
||||
spelling,
|
||||
active_root=outside,
|
||||
|
|
|
|||
|
|
@ -169,6 +169,35 @@ class TestBackgroundConsciousnessToolScope(unittest.TestCase):
|
|||
self.assertNotIn("run_command", schema_names)
|
||||
self.assertNotIn("commit_reviewed", schema_names)
|
||||
|
||||
def test_set_next_wakeup_schema_follows_configured_bounds(self):
|
||||
"""The advertised range is the LIVE clamp, not a constant: with
|
||||
OUROBOROS_BG_WAKEUP_MIN/MAX overridden the schema must say so, because
|
||||
the handler clamps to those values (prompt-audit review finding)."""
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from ouroboros.consciousness import BackgroundConsciousness
|
||||
|
||||
tmpdir = pathlib.Path(tempfile.mkdtemp())
|
||||
drive_root = tmpdir / "drive"
|
||||
repo_dir = tmpdir / "repo"
|
||||
(drive_root / "logs").mkdir(parents=True, exist_ok=True)
|
||||
repo_dir.mkdir(parents=True, exist_ok=True)
|
||||
with mock.patch.dict(os.environ, {"OUROBOROS_BG_WAKEUP_MIN": "60", "OUROBOROS_BG_WAKEUP_MAX": "3600"}):
|
||||
bc = BackgroundConsciousness(
|
||||
drive_root=drive_root,
|
||||
repo_dir=repo_dir,
|
||||
event_queue=queue.Queue(),
|
||||
owner_chat_id_fn=lambda: 42,
|
||||
)
|
||||
schema = next(
|
||||
s["function"] for s in bc._tool_schemas() if s.get("function", {}).get("name") == "set_next_wakeup"
|
||||
)
|
||||
self.assertIn("60-3600", schema["description"])
|
||||
self.assertIn("60-3600", schema["parameters"]["properties"]["seconds"]["description"])
|
||||
self.assertEqual(bc._wakeup_min, 60)
|
||||
self.assertEqual(bc._wakeup_max, 3600)
|
||||
|
||||
|
||||
class TestBackgroundConsciousnessCost(unittest.TestCase):
|
||||
def test_unknown_round_cost_stays_nullable_in_durable_thought(self):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ ARCHITECTURE.md pins below are the load-bearing rationale-layer guards
|
|||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
|
||||
REPO = pathlib.Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
|
@ -199,3 +202,85 @@ def test_architecture_mirror_matches_the_split_axes_contracts():
|
|||
# Both wait_tasks projection enumerations disclose capability_delta.
|
||||
assert "trace_summary, capability_delta when the child has something to disclose" in arch_flat
|
||||
assert "trace_summary, capability_delta when disclosable, duplicate_of" in dev_flat
|
||||
|
||||
|
||||
# Identifiers the prompts legitimately name in backticks that are NOT tools:
|
||||
# parameter names, resource roots, write surfaces, typed outcome/status tokens
|
||||
# and runtime-context keys. A NEW snake_case identifier in a prompt must either
|
||||
# be a real tool (or background-whitelisted tool) or be classified here on
|
||||
# purpose — that classification step is the governance the prompt audit wants:
|
||||
# a phantom or renamed tool name can no longer hide in the runtime prompts
|
||||
# (`advisory_review` and the CONSCIOUSNESS "You can" catalog rotted that way).
|
||||
# Scope: backticked names in all three prompts plus the bare snake_case names
|
||||
# CONSCIOUSNESS.md writes without backticks; BIBLE.md is deliberately out of scope.
|
||||
PROMPT_NON_TOOL_IDENTIFIERS = frozenset({
|
||||
# resource roots / write surfaces / write roots
|
||||
"active_workspace", "artifact_store", "external_workspace", "runtime_data",
|
||||
"skill_payload", "subagent_projects", "system_repo", "task_drive", "user_files",
|
||||
"write_root", "write_surface",
|
||||
# tool parameters named as cross-tool policy
|
||||
"project_id", "project_name", "recommended_use", "review_rebuttal",
|
||||
# typed outcomes / statuses / runtime-context keys
|
||||
"needs_manual_target", "started_uncustodied", "owner_client",
|
||||
# safety policy class names (ouroboros/safety.py TOOL_POLICY values)
|
||||
"check_conditional",
|
||||
})
|
||||
|
||||
|
||||
def _prompt_backticked_identifiers(text: str) -> set:
|
||||
found = set()
|
||||
for token in re.findall(r"`([^`]+)`", text):
|
||||
head = token.split("(", 1)[0]
|
||||
if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)+", head):
|
||||
found.add(head)
|
||||
return found
|
||||
|
||||
|
||||
def _prompt_bare_identifiers(text: str) -> set:
|
||||
"""snake_case tokens written WITHOUT backticks (CONSCIOUSNESS.md's style);
|
||||
tokens that are part of a path or filename (`a/b_c`, `x_y.json`) are skipped."""
|
||||
return {
|
||||
m.group(1)
|
||||
for m in re.finditer(r"(?<![\w/.`-])([a-z][a-z0-9]*(?:_[a-z0-9]+)+)(?![\w/.`-])", text)
|
||||
}
|
||||
|
||||
|
||||
def test_prompt_tool_names_resolve_to_registered_tools(tmp_path):
|
||||
"""Every backticked snake_case identifier in the three runtime prompts is
|
||||
either a registered tool (public schema), a background-consciousness tool,
|
||||
or a documented non-tool identifier. Completeness is deliberately NOT
|
||||
required (the schemas are the catalog); this only forbids phantoms and
|
||||
stale spellings, the drift class the prompt audit found in every prompt."""
|
||||
from ouroboros.consciousness import BackgroundConsciousness
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
registered = {schema["function"]["name"] for schema in registry.schemas()}
|
||||
# The background whitelist is not taken on faith: every name in it must be a
|
||||
# registered public tool or a ToolEntry the consciousness module registers
|
||||
# itself (set_next_wakeup and friends), otherwise the whitelist has rotted.
|
||||
consciousness_src = (root / "ouroboros" / "consciousness.py").read_text(encoding="utf-8")
|
||||
bg_private = set(re.findall(r'ToolEntry\("([a-z0-9_]+)"', consciousness_src))
|
||||
stale_whitelist = set(BackgroundConsciousness._BG_TOOL_WHITELIST) - registered - bg_private
|
||||
assert not stale_whitelist, f"_BG_TOOL_WHITELIST names unregistered tools: {sorted(stale_whitelist)}"
|
||||
universe = (
|
||||
registered
|
||||
| set(BackgroundConsciousness._BG_TOOL_WHITELIST)
|
||||
| PROMPT_NON_TOOL_IDENTIFIERS
|
||||
)
|
||||
for rel in ("prompts/SYSTEM.md", "prompts/SAFETY.md", "prompts/CONSCIOUSNESS.md"):
|
||||
text = (root / rel).read_text(encoding="utf-8")
|
||||
unresolved = _prompt_backticked_identifiers(text) - universe
|
||||
assert not unresolved, (
|
||||
f"{rel} names identifiers that are neither registered tools nor "
|
||||
f"classified non-tool identifiers: {sorted(unresolved)}"
|
||||
)
|
||||
# CONSCIOUSNESS.md writes tool names without backticks; its bare snake_case
|
||||
# tokens must resolve the same way (the runtime drift check in
|
||||
# context_health only catches names with known prefixes).
|
||||
bare = _prompt_bare_identifiers((root / "prompts" / "CONSCIOUSNESS.md").read_text(encoding="utf-8"))
|
||||
unresolved_bare = bare - universe
|
||||
assert not unresolved_bare, (
|
||||
f"prompts/CONSCIOUSNESS.md names bare identifiers that are neither registered tools "
|
||||
f"nor classified non-tool identifiers: {sorted(unresolved_bare)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ def test_git_catalog_schema_bytes_and_handler_owners_are_stable():
|
|||
separators=(",", ":"),
|
||||
).encode()
|
||||
assert hashlib.sha256(schema_bytes).hexdigest() == (
|
||||
"7650c07ea4841bdced36cb41921db772b452c02745b4cc48bcf72a6af15cee63"
|
||||
"729fdf1425126168c7408e431611f70ddd11139fa1c4161628fbcec7a27bf8ec"
|
||||
)
|
||||
assert {
|
||||
entry.name: (entry.handler.__module__, entry.handler.__name__)
|
||||
|
|
|
|||
|
|
@ -133,7 +133,8 @@ ALLOWED_CASES = [
|
|||
pytest.param("cd /Users/anton/Ouroboros/repo && git diff", id="readonly_cd_repo_diff"),
|
||||
pytest.param("git -C /Users/anton/Ouroboros/repo branch -l", id="readonly_branch_list_repo"),
|
||||
# Read-only forms of the verb-dispatched subcommands stay allowed at a runtime
|
||||
# target too — the SYSTEM.md contract ("read-only git works everywhere"). These
|
||||
# target too — the ARCHITECTURE "Safety and runtime mode" contract ("read-only
|
||||
# shell git is allowed everywhere"). These
|
||||
# were refused before the mode parse: `remote` had no read-only classifier at
|
||||
# all, and `tag -v/--verify` (signature check, writes nothing) sat in the
|
||||
# mutating flag set (SC-7).
|
||||
|
|
|
|||
|
|
@ -16,12 +16,18 @@ import pathlib
|
|||
import subprocess
|
||||
import types
|
||||
|
||||
from tests._typed_guard_shared import _shell_guard_text
|
||||
import pytest
|
||||
|
||||
from ouroboros.tool_access import user_files_path_block_reason
|
||||
from ouroboros.tools.core import _code_search, _list_files, _read_file, _write_file
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
from ouroboros.tools import registry_guard_process
|
||||
|
||||
|
||||
def _posix(rendered: str) -> str:
|
||||
"""Listings and search hits spell paths with the host separator (JSON-escaped
|
||||
in a listing); compare them separator-agnostically."""
|
||||
return rendered.replace("\\\\", "/").replace("\\", "/")
|
||||
|
||||
|
||||
AWS_SECRET_LINE = "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
|
||||
|
|
@ -79,11 +85,9 @@ def test_root_reads_credential_named_user_file_masked_not_refused(user_files_ctx
|
|||
def test_root_lists_and_searches_credential_named_user_files(user_files_ctx):
|
||||
ctx, _home = user_files_ctx
|
||||
listing = _list_files(ctx, path=".aws", root="user_files")
|
||||
# Not hidden. The listing is JSON text, so a Windows separator arrives
|
||||
# JSON-escaped ("\\\\"): fold that first, then any bare backslash.
|
||||
assert ".aws/credentials" in listing.replace("\\\\", "/").replace("\\", "/")
|
||||
assert ".aws/credentials" in _posix(listing) # the name is not hidden
|
||||
found = _code_search(ctx, "aws_secret_access_key", root="user_files", path=".aws")
|
||||
assert ".aws/credentials" in found # search reaches the file
|
||||
assert ".aws/credentials" in _posix(found) # search reaches the file
|
||||
assert "wJalrXUtnFEMI" not in found # match lines are masked
|
||||
assert "SECRET_BYTES_MASKED" in found
|
||||
|
||||
|
|
@ -109,7 +113,7 @@ def test_root_read_authorization_is_location_only(user_files_ctx, operation, rel
|
|||
])
|
||||
def test_sudo_named_as_data_passes_the_deterministic_prefilter(tmp_path, cmd):
|
||||
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
assert _shell_guard_text(registry, {"cmd": cmd}, "advanced") is None
|
||||
assert registry_guard_process._run_shell_safety_check(registry, {"cmd": cmd}, "advanced") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -139,7 +143,7 @@ def test_git_read_modes_pass_the_read_allowlist(cmd):
|
|||
])
|
||||
def test_skill_owner_state_inspection_read_passes(tmp_path, cmd):
|
||||
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
assert _shell_guard_text(registry, {"cmd": cmd}, "advanced") is None
|
||||
assert registry_guard_process._run_shell_safety_check(registry, {"cmd": cmd}, "advanced") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -213,3 +213,36 @@ def test_python_named_wrapper_has_no_safe_shell_subject():
|
|||
assert _normalize_safe_shell_subject(
|
||||
"/tmp/python-malicious -m pytest tests/test_scope_review.py -q"
|
||||
) == ""
|
||||
|
||||
|
||||
def test_real_system_prompt_keeps_its_floor_rules_under_local_compaction():
|
||||
"""Local-model overflow compaction keeps only the text BEFORE the first
|
||||
`## ` heading of the static block (plus the BIBLE section). The prompt
|
||||
audit therefore put the load-bearing floor — identity, the one-routing-
|
||||
decision rule, the owner-only supervision rule, panic — into that preamble.
|
||||
Pin it: the compacted static block must still carry those rules, and every
|
||||
other section must have been replaced by an omission marker."""
|
||||
import pathlib
|
||||
|
||||
from ouroboros.llm import _compact_local_text
|
||||
|
||||
system_md = (
|
||||
pathlib.Path(__file__).resolve().parent.parent / "prompts" / "SYSTEM.md"
|
||||
).read_text(encoding="utf-8")
|
||||
compacted = _compact_local_text(system_md + "\n\n## BIBLE.md\n\nBIBLE TEXT\n", "static")
|
||||
normalized = " ".join(compacted.split())
|
||||
|
||||
assert "# I Am Ouroboros" in compacted
|
||||
assert "exactly ONE routing decision" in normalized
|
||||
assert "one self-contained final response" in normalized
|
||||
assert "[Message from my human]" in compacted
|
||||
assert "never bypass, disable, or ignore the Safety Agent" in normalized
|
||||
assert "owner-only" in normalized
|
||||
assert "Panic stops everything" in normalized
|
||||
assert "## BIBLE.md\n\nBIBLE TEXT" in compacted
|
||||
# Everything below the preamble was compacted, not silently kept or lost.
|
||||
assert "## Delegation\n\n[Compacted for local-model context" in compacted
|
||||
assert "## Workmanship\n\n[Compacted for local-model context" in compacted
|
||||
# The floor stays small: it is the whole prompt for a compacted local model.
|
||||
preamble = compacted.split("\n## ", 1)[0]
|
||||
assert len(preamble.encode("utf-8")) <= 1536, len(preamble.encode("utf-8"))
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ def test_preflight_missing_node_stays_missing(tmp_path, monkeypatch):
|
|||
empty_bin = tmp_path / "empty"
|
||||
empty_bin.mkdir()
|
||||
monkeypatch.setenv("PATH", str(empty_bin))
|
||||
monkeypatch.setattr(wp, "resolve_bundled_node", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
wp, "node_runtime_health",
|
||||
lambda path, timeout_sec=10: (_ for _ in ()).throw(AssertionError("no probe for a missing node")),
|
||||
|
|
|
|||
|
|
@ -207,19 +207,52 @@ def test_architecture_doc_describes_build_script_release_tag_check():
|
|||
|
||||
|
||||
def test_system_prompt_lists_bible_in_safety_critical_set():
|
||||
"""prompts/SYSTEM.md ``Immutable Safety Files`` section must match
|
||||
"""prompts/SYSTEM.md ``Safety-critical files`` section must name EXACTLY
|
||||
``ouroboros.runtime_mode_policy.SAFETY_CRITICAL_PATHS`` — including
|
||||
``BIBLE.md``, which is protected by the hardcoded sandbox."""
|
||||
``BIBLE.md``, which is protected by the hardcoded sandbox. The prompt keeps
|
||||
this one exact list (the LLM must recognise the names) and points at
|
||||
``runtime_mode_policy.py`` for the wider protected surface instead of
|
||||
mirroring the frozen/release sets, which rotted twice before."""
|
||||
from ouroboros.runtime_mode_policy import SAFETY_CRITICAL_PATHS
|
||||
|
||||
system_md = (REPO / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
|
||||
safety_section_start = system_md.find("## Immutable Safety Files")
|
||||
safety_section_start = system_md.find("## Safety-critical files")
|
||||
assert safety_section_start != -1
|
||||
safety_section_end = system_md.find("##", safety_section_start + 1)
|
||||
safety_section_end = system_md.find("\n## ", safety_section_start + 1)
|
||||
safety_section = system_md[safety_section_start:safety_section_end]
|
||||
assert "`BIBLE.md`" in safety_section
|
||||
assert "`ouroboros/safety.py`" in safety_section
|
||||
assert "`prompts/SAFETY.md`" in safety_section
|
||||
assert "`ouroboros/tools/registry.py`" in safety_section
|
||||
named_paths = {
|
||||
token for token in re.findall(r"`([^`]+)`", safety_section)
|
||||
if "/" in token or token.endswith(".md")
|
||||
}
|
||||
assert named_paths == set(SAFETY_CRITICAL_PATHS), (
|
||||
named_paths ^ set(SAFETY_CRITICAL_PATHS)
|
||||
)
|
||||
|
||||
|
||||
def test_safety_prompt_protected_path_list_mirrors_runtime_policy():
|
||||
"""prompts/SAFETY.md is the ONE prose copy of the protected-path set (the
|
||||
LLM supervisor is the only gate for MCP/extension tools, so it needs the
|
||||
exact names). It must equal ``PROTECTED_RUNTIME_PATHS`` plus the
|
||||
``ouroboros/contracts/`` prefix — a drifted list silently stops protecting
|
||||
whatever was added to the policy module."""
|
||||
from ouroboros.runtime_mode_policy import (
|
||||
PROTECTED_RUNTIME_PATH_PREFIXES,
|
||||
PROTECTED_RUNTIME_PATHS,
|
||||
)
|
||||
|
||||
safety_md = (REPO / "prompts" / "SAFETY.md").read_text(encoding="utf-8")
|
||||
marker = "Protected paths (exact mirror of `PROTECTED_RUNTIME_PATHS` plus the protected prefix in `ouroboros/runtime_mode_policy.py`):"
|
||||
start = safety_md.find(marker)
|
||||
assert start != -1, "SAFETY.md lost its protected-path mirror sentence"
|
||||
line_end = safety_md.find("\n", start)
|
||||
listed = set(re.findall(r"`([^`]+)`", safety_md[start + len(marker):line_end]))
|
||||
expected = set(PROTECTED_RUNTIME_PATHS) | set(PROTECTED_RUNTIME_PATH_PREFIXES)
|
||||
assert listed == expected, listed ^ expected
|
||||
|
||||
|
||||
def test_architecture_doc_does_not_claim_ensure_managed_repo_fetches():
|
||||
|
|
|
|||
|
|
@ -35,9 +35,16 @@ from types import SimpleNamespace
|
|||
import pytest
|
||||
|
||||
import ouroboros.tools.shell as shell
|
||||
from ouroboros.tools.process_facts import consume_last_process_facts
|
||||
from ouroboros.tools.process_facts import consume_last_process_facts, signal_name_for_returncode
|
||||
from ouroboros.tools.shell import _run_shell
|
||||
|
||||
# The SSOT (``signal_name_for_returncode``) names -9 from the host signal table:
|
||||
# SIGKILL on POSIX; Windows has no such signal, so the name there is the
|
||||
# disclosed numeric fallback. The flow tests below pin that the typed meta
|
||||
# CARRIES the SSOT-derived name end to end; the POSIX vocabulary itself is
|
||||
# pinned by the posix-only real-process tests further down.
|
||||
_KILL_NAME = signal_name_for_returncode(-9)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_facts_slot():
|
||||
|
|
@ -101,9 +108,9 @@ def test_run_shell_publishes_signal_death_facts(tmp_path, fake_subprocess):
|
|||
result = _run_shell(_ctx(tmp_path), ["node", "--version"])
|
||||
facts = consume_last_process_facts()
|
||||
assert facts["exit_code"] == -9
|
||||
assert facts["signal"] == "SIGKILL"
|
||||
assert facts["signal"] == _KILL_NAME
|
||||
# The existing rendered shape stays: signal named in prose as before.
|
||||
assert "⚠️ SHELL_EXIT_ERROR" in result and "signal=SIGKILL" in result
|
||||
assert "⚠️ SHELL_EXIT_ERROR" in result and f"signal={_KILL_NAME}" in result
|
||||
assert "duration_ms" not in result
|
||||
|
||||
|
||||
|
|
@ -192,7 +199,7 @@ def test_execute_single_tool_merges_typed_meta_with_precedence(tmp_path):
|
|||
meta = out["result_meta"]
|
||||
assert meta["status"] == "non_zero_exit"
|
||||
assert meta["exit_code"] == -9
|
||||
assert meta["signal"] == "SIGKILL"
|
||||
assert meta["signal"] == _KILL_NAME
|
||||
assert isinstance(meta["duration_ms"], int)
|
||||
|
||||
|
||||
|
|
@ -254,11 +261,11 @@ def test_typed_meta_flows_handler_to_trace_item_to_error_record(tmp_path):
|
|||
assert errors == 1
|
||||
item = llm_trace["tool_calls"][0]
|
||||
assert item["exit_code"] == -9
|
||||
assert item["signal"] == "SIGKILL"
|
||||
assert item["signal"] == _KILL_NAME
|
||||
assert isinstance(item["duration_ms"], int)
|
||||
buckets = _classify_tool_errors(llm_trace)
|
||||
assert buckets["unresolved"] and not buckets["cosmetic"]
|
||||
assert buckets["unresolved"][0]["signal"] == "SIGKILL"
|
||||
assert buckets["unresolved"][0]["signal"] == _KILL_NAME
|
||||
|
||||
|
||||
def test_typed_absence_beats_regex_signal_from_stdout(tmp_path):
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ def test_project_lifecycle_rows_render_design_system_action_static_contract():
|
|||
chat = (root / "web" / "modules" / "chat.js").read_text(encoding="utf-8")
|
||||
style = (root / "web" / "style.css").read_text(encoding="utf-8")
|
||||
helpers = (root / "web" / "modules" / "ui_helpers.js").read_text(encoding="utf-8")
|
||||
app = (root / "web" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
# One shared set drives render, history replay, and live fan-out.
|
||||
assert (
|
||||
|
|
@ -420,8 +421,15 @@ def test_project_lifecycle_rows_render_design_system_action_static_contract():
|
|||
assert "chat-live-project-btn" not in chat
|
||||
assert "chat-live-project-btn" not in style
|
||||
assert 'class="btn btn-xs btn-default" data-turn-into-project' in chat
|
||||
# The identity chip keeps its own role untouched.
|
||||
assert "chat-live-project-card-btn" in chat
|
||||
# The identity chip keeps its own role, now built once in ui_helpers and
|
||||
# shared by the converted card (chat.js) and the bound-task footer (app.js).
|
||||
assert "chat-live-project-card-btn" in helpers
|
||||
assert "renderProjectChip(" in chat
|
||||
assert "renderProjectChip(" in app
|
||||
# The project pointer is a Main-root affordance: applyTaskBindings walks
|
||||
# only Main root cards, never the Project panel's copy or nested subagents
|
||||
# (D15; the browser flow is pinned by the marker-gated continuity smoke).
|
||||
assert "'#page-chat .chat-live-card[data-task-id]:not(.subagent)'" in app
|
||||
|
||||
# Layout-only container CSS; the helper owns the one semantic button role.
|
||||
assert ".system-message-actions {" in style
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ def test_http200_body_transient_that_is_not_429_still_blocks(monkeypatch, tmp_pa
|
|||
|
||||
def test_local_fallback_lane_rate_limit_takes_the_audited_fail_open(monkeypatch, tmp_path, _no_backoff):
|
||||
"""Disclosed nuance: the local-FALLBACK lane keeps its documented fail-open contract
|
||||
(SYSTEM.md case (c)) — a genuine 429 there takes the two-attempt fail-open WITH the
|
||||
(ARCHITECTURE "Safety and runtime mode" case (c)) — a genuine 429 there takes the two-attempt fail-open WITH the
|
||||
audit row (a 429 must not be stricter than the RuntimeError beside it), while every
|
||||
other error keeps its unchanged one-attempt 'Local safety runtime unreachable'
|
||||
warning. Both allow; the remote lanes are the ones that block typed."""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import asyncio
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
|
@ -12,9 +11,6 @@ from xml.etree import ElementTree as ET
|
|||
|
||||
import pytest
|
||||
|
||||
# signal.alarm is POSIX-only; on Windows the suite-wide pytest-timeout is the guard.
|
||||
_alarm = getattr(signal, "alarm", lambda _seconds: None)
|
||||
|
||||
|
||||
_PACKAGE = "telegram_format_parity_test"
|
||||
|
||||
|
|
@ -220,6 +216,12 @@ def test_block_aware_chunking_keeps_quote_and_table_blocks_whole():
|
|||
_assert_balanced(chunk)
|
||||
|
||||
|
||||
# Termination guards: pytest-timeout, not ``signal.alarm`` — Windows has no
|
||||
# SIGALRM, and an unhandled alarm would kill the whole pytest worker. The
|
||||
# bound is a HANG guard, not a perf budget: the 100 KB single-block case takes
|
||||
# ~4 s on a fast Linux host and exceeded 10 s on windows-latest under xdist
|
||||
# (a thread-method timeout kills the worker), while the CI ceiling is 300 s.
|
||||
@pytest.mark.timeout(120)
|
||||
def test_chunker_terminates_for_oversized_link_tag_and_pre_block():
|
||||
_plugin, telegram_api = _load_skill()
|
||||
link_source = (
|
||||
|
|
@ -231,12 +233,8 @@ def test_chunker_terminates_for_oversized_link_tag_and_pre_block():
|
|||
)
|
||||
pre_source = "```text\n" + ("x" * 12_000) + "\n```"
|
||||
|
||||
_alarm(10)
|
||||
try:
|
||||
link_chunks = telegram_api.markdown_to_telegram_chunks(link_source)
|
||||
pre_chunks = telegram_api.markdown_to_telegram_chunks(pre_source)
|
||||
finally:
|
||||
_alarm(0)
|
||||
link_chunks = telegram_api.markdown_to_telegram_chunks(link_source)
|
||||
pre_chunks = telegram_api.markdown_to_telegram_chunks(pre_source)
|
||||
|
||||
assert link_chunks
|
||||
assert pre_chunks
|
||||
|
|
@ -245,15 +243,12 @@ def test_chunker_terminates_for_oversized_link_tag_and_pre_block():
|
|||
_assert_balanced(chunk)
|
||||
|
||||
|
||||
def test_chunker_balances_100kb_single_block_paragraph_within_alarm():
|
||||
@pytest.mark.timeout(120)
|
||||
def test_chunker_balances_100kb_single_block_paragraph_within_timeout():
|
||||
_plugin, telegram_api = _load_skill()
|
||||
source = "**" + ("word " * 20_000) + "**"
|
||||
|
||||
_alarm(10)
|
||||
try:
|
||||
chunks = telegram_api.markdown_to_telegram_chunks(source)
|
||||
finally:
|
||||
_alarm(0)
|
||||
chunks = telegram_api.markdown_to_telegram_chunks(source)
|
||||
|
||||
assert len(chunks) > 1
|
||||
assert all(telegram_api._u16len(chunk) <= 4096 for chunk in chunks)
|
||||
|
|
|
|||
326
tests/test_terminal_delegation_receipt.py
Normal file
326
tests/test_terminal_delegation_receipt.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""Terminal delegation receipt replay and canonical/replica custody."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from ouroboros.gateway.history import make_chat_history_endpoint
|
||||
from ouroboros.post_task_checkpoint import project_replica_task_result_fields
|
||||
from ouroboros.task_results import STATUS_COMPLETED, write_task_result
|
||||
from ouroboros.task_status import load_effective_task_result
|
||||
from ouroboros.task_result_schema import stamp_task_result_schema # v7 ABI-2: unstamped rows are quarantined
|
||||
|
||||
|
||||
def _execution_evidence(
|
||||
started: int,
|
||||
settled: int,
|
||||
cost: float | None,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"delegated_runs_started": started,
|
||||
"delegated_runs_settled": settled,
|
||||
"delegated_runs_succeeded": settled,
|
||||
"delegated_runs_failed": 0,
|
||||
"subscription_cost_usd": cost,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _delegation_envelope(
|
||||
started: int,
|
||||
settled: int,
|
||||
cost: float | None,
|
||||
substrate: str,
|
||||
**evidence_extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"executor_route": "claude=opus",
|
||||
"actual_substrate": substrate,
|
||||
"native_contribution": "unknown",
|
||||
"execution_evidence": _execution_evidence(
|
||||
started,
|
||||
settled,
|
||||
cost,
|
||||
**evidence_extra,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _completed_task_result(task_id: str, cost: float) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "completed",
|
||||
"parent_task_id": "root",
|
||||
"root_task_id": "root",
|
||||
"delegation_role": "subagent",
|
||||
"executor_route": "claude=opus",
|
||||
"actual_substrate": "harness_used",
|
||||
"subagent_envelope": _delegation_envelope(
|
||||
1,
|
||||
1,
|
||||
cost,
|
||||
"harness_used",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_history_rehydrates_receipt_on_latest_terminal_progress_consumer(tmp_path):
|
||||
"""Summary, stale, absent, and retry directions share one replay seam."""
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir()
|
||||
progress_rows = [
|
||||
{
|
||||
"task_id": "summary",
|
||||
"content": "older route only",
|
||||
"ts": "2026-09-01T17:00:00Z",
|
||||
},
|
||||
{
|
||||
"task_id": "summary",
|
||||
"content": "latest route only",
|
||||
"ts": "2026-09-01T17:00:30Z",
|
||||
},
|
||||
{
|
||||
"task_id": "stale",
|
||||
"content": "stale receipt",
|
||||
"ts": "2026-09-01T17:01:00Z",
|
||||
"actual_substrate": "harness_attempted",
|
||||
"execution_evidence": _execution_evidence(1, 0, None),
|
||||
},
|
||||
{
|
||||
"task_id": "absent",
|
||||
"content": "surviving receipt",
|
||||
"ts": "2026-09-01T17:02:00Z",
|
||||
"actual_substrate": "harness_used",
|
||||
"execution_evidence": _execution_evidence(1, 1, 1.25),
|
||||
},
|
||||
{
|
||||
"task_id": "original",
|
||||
"content": "retry route only",
|
||||
"ts": "2026-09-01T17:03:00Z",
|
||||
},
|
||||
]
|
||||
for row in progress_rows:
|
||||
row.update({"is_progress": True, "executor_route": "claude=opus"})
|
||||
(logs / "progress.jsonl").write_text(
|
||||
"\n".join(json.dumps(row) for row in progress_rows) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(logs / "chat.jsonl").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"ts": "2026-09-01T17:04:00Z",
|
||||
"direction": "system",
|
||||
"chat_id": 1,
|
||||
"type": "task_summary",
|
||||
"task_id": "summary",
|
||||
"text": "done",
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
results = tmp_path / "task_results"
|
||||
results.mkdir()
|
||||
for task_id, cost in (("summary", 0.0), ("stale", 1.25), ("retry", 2.5)):
|
||||
(results / f"{task_id}.json").write_text(
|
||||
json.dumps(stamp_task_result_schema(_completed_task_result(task_id, cost))),
|
||||
encoding="utf-8",
|
||||
)
|
||||
absent = _completed_task_result("absent", 1.25)
|
||||
absent.pop("actual_substrate")
|
||||
absent["subagent_envelope"] = {"executor_route": "claude=opus"}
|
||||
(results / "absent.json").write_text(json.dumps(stamp_task_result_schema(absent)), encoding="utf-8")
|
||||
(results / "original.json").write_text(
|
||||
json.dumps(
|
||||
stamp_task_result_schema(
|
||||
{
|
||||
"task_id": "original",
|
||||
"status": "interrupted",
|
||||
"retry_task_id": "retry",
|
||||
}
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
make_chat_history_endpoint(tmp_path)(
|
||||
SimpleNamespace(query_params={"n_human": "10", "n_progress": "20"})
|
||||
)
|
||||
)
|
||||
progress = [
|
||||
row for row in json.loads(response.body)["messages"] if row.get("is_progress")
|
||||
]
|
||||
latest = {
|
||||
task_id: max(
|
||||
(row for row in progress if row.get("task_id") == task_id),
|
||||
key=lambda row: row["ts"],
|
||||
)
|
||||
for task_id in ("summary", "stale", "absent", "original")
|
||||
}
|
||||
for row in latest.values():
|
||||
assert row["task_terminal_status"] == "completed"
|
||||
assert row["actual_substrate"] == "harness_used"
|
||||
assert latest["summary"]["execution_evidence"] == _execution_evidence(1, 1, 0.0)
|
||||
assert latest["stale"]["execution_evidence"] == _execution_evidence(1, 1, 1.25)
|
||||
assert latest["absent"]["execution_evidence"] == _execution_evidence(1, 1, 1.25)
|
||||
assert latest["original"]["execution_evidence"] == _execution_evidence(1, 1, 2.5)
|
||||
|
||||
summary_rows = [row for row in progress if row.get("task_id") == "summary"]
|
||||
assert len(summary_rows) == 2
|
||||
older_summary = min(summary_rows, key=lambda row: row["ts"])
|
||||
assert older_summary["text"] == "older route only"
|
||||
assert latest["summary"]["text"] == "latest route only"
|
||||
assert "execution_evidence" not in older_summary
|
||||
|
||||
|
||||
def test_replica_projector_orders_receipts_and_preserves_disclosure_authority():
|
||||
first = _delegation_envelope(1, 1, 1.25, "harness_used")
|
||||
projected = project_replica_task_result_fields(
|
||||
{"subagent_envelope": {"executor_route": "claude=opus"}},
|
||||
{"actual_substrate": "harness_used", "subagent_envelope": first},
|
||||
)
|
||||
assert projected["subagent_envelope"] == first
|
||||
|
||||
positive_replica = {
|
||||
"actual_substrate": "harness_used",
|
||||
"subagent_envelope": first,
|
||||
}
|
||||
for canonical_envelope in (
|
||||
_delegation_envelope(0, 0, None, "native_only"),
|
||||
{
|
||||
"executor_route": "claude=opus",
|
||||
"execution_evidence": {"evidence_read_failed": True},
|
||||
},
|
||||
):
|
||||
projected = project_replica_task_result_fields(
|
||||
{"subagent_envelope": canonical_envelope},
|
||||
positive_replica,
|
||||
)
|
||||
assert projected["actual_substrate"] == "harness_used"
|
||||
assert (
|
||||
projected["subagent_envelope"]["execution_evidence"]
|
||||
== first["execution_evidence"]
|
||||
)
|
||||
|
||||
enriched = _delegation_envelope(
|
||||
1,
|
||||
1,
|
||||
3.75,
|
||||
"harness_used",
|
||||
applied_access_profiles=["workspace_write"],
|
||||
)
|
||||
replica = {
|
||||
"actual_substrate": "harness_attempted",
|
||||
"delegated_runs_settled": 0,
|
||||
"subagent_envelope": {
|
||||
**_delegation_envelope(1, 1, None, "harness_attempted"),
|
||||
"marker": "keep-replica",
|
||||
},
|
||||
}
|
||||
canonical = {
|
||||
"actual_substrate": "harness_used",
|
||||
"subagent_envelope": enriched,
|
||||
"delegated_runs_unreconciled": [],
|
||||
"delegate_terminal_reconciliation": {"trigger": "boot_backfill"},
|
||||
}
|
||||
projected = project_replica_task_result_fields(
|
||||
canonical,
|
||||
{
|
||||
**replica,
|
||||
"delegated_runs_unreconciled": ["stale"],
|
||||
"delegate_terminal_reconciliation": {"trigger": "terminal_write"},
|
||||
},
|
||||
)
|
||||
evidence = projected["subagent_envelope"]["execution_evidence"]
|
||||
assert evidence["subscription_cost_usd"] == 3.75
|
||||
assert evidence["applied_access_profiles"] == ["workspace_write"]
|
||||
assert projected["actual_substrate"] == "harness_used"
|
||||
assert projected["delegated_runs_settled"] == 0
|
||||
assert projected["subagent_envelope"]["marker"] == "keep-replica"
|
||||
assert "delegated_runs_unreconciled" not in projected
|
||||
assert "delegate_terminal_reconciliation" not in projected
|
||||
|
||||
first_disclosure = {
|
||||
"delegated_runs_unreconciled": ["first"],
|
||||
"delegate_terminal_reconciliation": {"trigger": "terminal_write"},
|
||||
}
|
||||
assert project_replica_task_result_fields({}, first_disclosure) == first_disclosure
|
||||
assert project_replica_task_result_fields(canonical, {})["subagent_envelope"] == enriched
|
||||
|
||||
|
||||
def test_receipt_order_matches_effective_read_and_physical_copyback(tmp_path):
|
||||
from ouroboros.headless import copy_child_task_result
|
||||
|
||||
for case in ("heal", "first"):
|
||||
data = tmp_path / case / "data"
|
||||
child = tmp_path / case / "child"
|
||||
task_id = f"{case}-task"
|
||||
data.mkdir(parents=True)
|
||||
child.mkdir()
|
||||
child_receipt = _delegation_envelope(
|
||||
2 if case == "heal" else 1,
|
||||
1,
|
||||
1.0,
|
||||
"harness_attempted" if case == "heal" else "harness_used",
|
||||
)
|
||||
canonical_receipt = (
|
||||
_delegation_envelope(2, 2, 2.5, "harness_used")
|
||||
if case == "heal"
|
||||
else {"executor_route": "claude=opus"}
|
||||
)
|
||||
write_task_result(
|
||||
child,
|
||||
task_id,
|
||||
STATUS_COMPLETED,
|
||||
actual_substrate=child_receipt["actual_substrate"],
|
||||
subagent_envelope=child_receipt,
|
||||
delegated_runs_unreconciled=["stale"],
|
||||
delegate_terminal_reconciliation={"trigger": "terminal_write"},
|
||||
)
|
||||
canonical_fields: dict[str, Any] = {
|
||||
"child_drive_root": str(child),
|
||||
"subagent_envelope": canonical_receipt,
|
||||
}
|
||||
if case == "heal":
|
||||
canonical_fields.update(
|
||||
{
|
||||
"actual_substrate": "harness_used",
|
||||
"delegated_runs_unreconciled": [],
|
||||
"delegate_terminal_reconciliation": {
|
||||
"trigger": "boot_backfill"
|
||||
},
|
||||
}
|
||||
)
|
||||
write_task_result(data, task_id, STATUS_COMPLETED, **canonical_fields)
|
||||
|
||||
expected = canonical_receipt if case == "heal" else child_receipt
|
||||
for result in (
|
||||
load_effective_task_result(data, task_id, materialize_artifacts=False),
|
||||
copy_child_task_result(
|
||||
data,
|
||||
{"id": task_id, "drive_root": str(child)},
|
||||
),
|
||||
):
|
||||
assert (
|
||||
result["subagent_envelope"]["execution_evidence"]
|
||||
== expected["execution_evidence"]
|
||||
)
|
||||
assert result["actual_substrate"] == "harness_used"
|
||||
if case == "heal":
|
||||
assert result["delegated_runs_unreconciled"] == []
|
||||
assert (
|
||||
result["delegate_terminal_reconciliation"]["trigger"]
|
||||
== "boot_backfill"
|
||||
)
|
||||
else:
|
||||
assert result["delegated_runs_unreconciled"] == ["stale"]
|
||||
assert (
|
||||
result["delegate_terminal_reconciliation"]["trigger"]
|
||||
== "terminal_write"
|
||||
)
|
||||
|
|
@ -42,8 +42,9 @@ def test_legacy_tool_names_are_not_public_schemas(tmp_path):
|
|||
|
||||
# D10: the external coding gateway tool was retired; delegate_start is its
|
||||
# successor. The dead name must be gone from the public schema surface.
|
||||
# (Not folded into LEGACY_PUBLIC_TOOL_NAMES because prompts/SYSTEM.md keeps
|
||||
# one deliberate retirement note that mentions the old name.)
|
||||
# (Not folded into LEGACY_PUBLIC_TOOL_NAMES: docs/DEVELOPMENT.md keeps the
|
||||
# D10 retirement record that mentions the old name; the runtime prompts no
|
||||
# longer do — the prompt audit removed the retirement note from SYSTEM.md.)
|
||||
assert "claude_code_edit" not in names
|
||||
assert registry.get_schema_by_name("claude_code_edit") is None
|
||||
assert registry.execute("claude_code_edit", {}).startswith("⚠️ Unknown tool")
|
||||
|
|
|
|||
354
tests/test_ui_smoke_agents_panel.py
Normal file
354
tests/test_ui_smoke_agents_panel.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"""Settings → Agents acceptance (docs/DESIGN.md "List editors"): real UI, real browser.
|
||||
|
||||
Sibling of ``test_ui_smoke_playwright.py`` (which carries the shared server fixture and
|
||||
sits at its byte gate); marker-gated the same way, runs in the same CI job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_plugins = ("tests.test_ui_smoke_playwright",)
|
||||
|
||||
|
||||
_AGENTS_PANEL_ROSTER = {
|
||||
"enabled": True,
|
||||
"items": [
|
||||
{"subagent_id": "claude_builder", "recommended_use": "Main workhorse for code and design.",
|
||||
"route": {"kind": "agent_session", "target_id": "claude=claude-opus-5"}, "effort": "medium"},
|
||||
{"subagent_id": "codex_reviewer", "recommended_use": "Deep code review of diffs.",
|
||||
"route": {"kind": "agent_session", "target_id": "codex=gpt-5.6-sol-high"}},
|
||||
{"subagent_id": "api_scout", "recommended_use": "Fast independent research.",
|
||||
"route": {"kind": "api_model", "target_id": "openai/gpt-5.6-luna"}, "effort": "high"},
|
||||
],
|
||||
}
|
||||
|
||||
_AGENTS_PANEL_VISIBLE_ROWS_JS = """
|
||||
(selector) => {
|
||||
// One pixel of slack: the list's own border sits on the scroll edge.
|
||||
const box = document.querySelector('.settings-scroll').getBoundingClientRect();
|
||||
return [...document.querySelectorAll(selector)]
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.filter((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1).length;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _open_agents_tab(page, url: str) -> None:
|
||||
page.goto(url, wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#page-chat", timeout=30_000)
|
||||
page.click('[data-nav-page="settings"]')
|
||||
page.wait_for_selector(".settings-shell", timeout=15_000)
|
||||
page.click('[data-settings-tab="agents"]')
|
||||
page.wait_for_selector("#available-subagents-editor .available-subagent-row", timeout=20_000)
|
||||
# The list at the top of the scroll body: "three cards fit a laptop-height body" is a
|
||||
# claim about the cards, not about the section's heading and copy above them.
|
||||
page.evaluate("() => document.querySelector('.available-subagents-list').scrollIntoView({block: 'start'})")
|
||||
|
||||
|
||||
def _agents_panel_add_reveals_the_new_card(page) -> None:
|
||||
"""The shared add-and-reveal contract, run on whichever engine the caller launched."""
|
||||
page.click("[data-subagent-add]")
|
||||
page.wait_for_function(
|
||||
"() => document.querySelectorAll('.available-subagent-row').length === 4", timeout=5_000)
|
||||
# The appended card is fully inside the scroll body and its Description holds the caret;
|
||||
# the section-level error line stays hidden and no card is tinted before a save attempt.
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const rows = [...document.querySelectorAll('.available-subagent-row')];
|
||||
const last = rows[rows.length - 1];
|
||||
const box = document.querySelector('.settings-scroll').getBoundingClientRect();
|
||||
const r = last.getBoundingClientRect();
|
||||
return r.top >= box.top - 1 && r.bottom <= box.bottom + 1
|
||||
&& document.activeElement === last.querySelector('[data-subagent-field="recommended_use"]');
|
||||
}""",
|
||||
timeout=5_000,
|
||||
)
|
||||
assert page.evaluate("() => document.querySelector('[data-subagents-validation]').hidden") is True
|
||||
assert page.locator(".available-subagent-row[data-invalid]").count() == 0
|
||||
hint = page.locator(".available-subagent-row").last.locator("[data-subagent-meta]")
|
||||
assert "Choose how this subagent runs" in hint.inner_text()
|
||||
|
||||
|
||||
def _agents_panel_typing_reads_draft(page) -> None:
|
||||
"""A keystroke into a SAVED card (no structural repaint) turns every head status to
|
||||
Draft at once, patched in place — the caret stays in the field being typed into."""
|
||||
field = page.locator('.available-subagent-row [data-subagent-field="recommended_use"]').first
|
||||
field.click()
|
||||
page.keyboard.type(" ")
|
||||
page.wait_for_function(
|
||||
"""() => [...document.querySelectorAll('[data-subagent-status]')]
|
||||
.every((el) => el.textContent.startsWith('Draft · '))
|
||||
&& document.activeElement === document.querySelector(
|
||||
'.available-subagent-row [data-subagent-field="recommended_use"]')""",
|
||||
timeout=5_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ui_browser
|
||||
def test_ui_smoke_agents_panel_list_editor(direct_server_with_data):
|
||||
"""Settings → Agents: three compact subagent cards fit a laptop-height body; typing turns
|
||||
the head status to Draft in place; Add reveals the appended card with the caret in it and
|
||||
no error; only a Save attempt (whichever validation aborts it) turns the empty route into
|
||||
a section-level line plus a tinted, self-naming card, and the fix typed into the card
|
||||
clears line, tint and footer together; a later Add is an invitation again; Review lanes'
|
||||
Add lives in the group head and reveals its new row (docs/DESIGN.md "List editors")."""
|
||||
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
settings_path = direct_server_with_data["data_dir"] / "settings.json"
|
||||
saved = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
saved["OUROBOROS_SUBAGENTS"] = json.dumps(_AGENTS_PANEL_ROSTER)
|
||||
settings_path.write_text(json.dumps(saved), encoding="utf-8")
|
||||
direct_server_with_data["restart_server"]()
|
||||
url = direct_server_with_data["url"]
|
||||
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch()
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open_agents_tab(page, url)
|
||||
assert page.evaluate(_AGENTS_PANEL_VISIBLE_ROWS_JS, ".available-subagent-row") >= 3
|
||||
_agents_panel_typing_reads_draft(page)
|
||||
_agents_panel_add_reveals_the_new_card(page)
|
||||
|
||||
# Every Save click is an attempt, even one that another field's validation
|
||||
# aborts: with a malformed Every-N cadence the roster is still judged (line +
|
||||
# tint) while the footer names the cadence error. The segmented control's
|
||||
# hidden inputs are poked directly — the cadence row is not the point here.
|
||||
def set_cadence(mode, n):
|
||||
page.evaluate(
|
||||
"([mode, n]) => { document.getElementById('s-post-task-evolution-mode').value = mode;"
|
||||
" document.getElementById('s-evo-cadence-n').value = n; }", [mode, n])
|
||||
|
||||
def save_expecting(footer_prefix):
|
||||
page.click("#btn-save-settings")
|
||||
page.wait_for_function(
|
||||
"(p) => document.getElementById('settings-status').textContent.startsWith(p)",
|
||||
arg=footer_prefix, timeout=5_000)
|
||||
|
||||
mode_before = page.evaluate("() => document.getElementById('s-post-task-evolution-mode').value")
|
||||
set_cadence("every_n", "x")
|
||||
save_expecting("Every-N cadence")
|
||||
assert not page.evaluate("() => document.querySelector('[data-subagents-validation]').hidden")
|
||||
assert page.locator(".available-subagent-row[data-invalid]").count() == 1
|
||||
set_cadence(mode_before, "")
|
||||
# With the cadence valid again, Save names the roster error in the footer too.
|
||||
save_expecting("Available subagents:")
|
||||
line = page.locator("[data-subagents-validation]").inner_text()
|
||||
assert line.startswith("Subagent 4 needs a model or agent-session route.")
|
||||
tinted = page.locator(".available-subagent-row[data-invalid]")
|
||||
assert tinted.count() == 1
|
||||
assert tinted.locator('[data-subagent-meta][data-tone="error"]').inner_text().startswith(
|
||||
"Subagent 4 needs")
|
||||
|
||||
# The NEXT added entry is still an invitation: the attempt judged the rows
|
||||
# that existed then, not every row forever — row 4 stays judged beside it.
|
||||
page.click("[data-subagent-add]")
|
||||
page.wait_for_function(
|
||||
"() => document.querySelectorAll('.available-subagent-row').length === 5", timeout=5_000)
|
||||
fresh = page.locator(".available-subagent-row").last
|
||||
assert not fresh.evaluate("(el) => el.hasAttribute('data-invalid')")
|
||||
assert "Choose how this subagent runs" in fresh.locator("[data-subagent-meta]").inner_text()
|
||||
assert page.locator(".available-subagent-row[data-invalid]").count() == 1
|
||||
|
||||
# A fix typed into the judged card clears the section line, the tint AND the
|
||||
# roster-owned footer message together; the unjudged fresh row keeps none of
|
||||
# them alive.
|
||||
tinted.locator('[data-subagent-field="model"]').fill("openai/gpt-5.6-luna")
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('[data-subagents-validation]').hidden"
|
||||
" && !document.querySelector('.available-subagent-row[data-invalid]')"
|
||||
" && !document.getElementById('settings-status').textContent.startsWith('Available subagents:')",
|
||||
timeout=5_000,
|
||||
)
|
||||
|
||||
# A newer, unrelated footer message is not the roster's to clear: the cadence
|
||||
# error written by the next Save survives the roster fix that follows it.
|
||||
set_cadence("every_n", "x")
|
||||
save_expecting("Every-N cadence")
|
||||
assert page.locator(".available-subagent-row[data-invalid]").count() == 1
|
||||
fresh.locator('[data-subagent-field="model"]').fill("openai/gpt-5.6-luna")
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('[data-subagents-validation]').hidden"
|
||||
" && !document.querySelector('.available-subagent-row[data-invalid]')", timeout=5_000)
|
||||
assert page.locator("#settings-status").inner_text().startswith("Every-N cadence")
|
||||
set_cadence(mode_before, "")
|
||||
|
||||
# Review lanes: the group's Add sits in its head and reveals the appended row.
|
||||
assert page.evaluate(
|
||||
"() => Boolean(document.getElementById('btn-add-triad-slot').closest('.reviewer-slots-head'))")
|
||||
before = page.locator("#reviewer-triad-rows .reviewer-slot-row").count()
|
||||
page.click("#btn-add-triad-slot")
|
||||
page.wait_for_function(
|
||||
"""(before) => {
|
||||
const rows = document.querySelectorAll('#reviewer-triad-rows .reviewer-slot-row');
|
||||
if (rows.length !== before + 1) return false;
|
||||
const last = rows[rows.length - 1];
|
||||
const box = document.querySelector('.settings-scroll').getBoundingClientRect();
|
||||
const r = last.getBoundingClientRect();
|
||||
return r.top >= box.top - 1 && r.bottom <= box.bottom + 1
|
||||
&& document.activeElement === last.querySelector('[data-slot-route]');
|
||||
}""",
|
||||
arg=before,
|
||||
timeout=5_000,
|
||||
)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
# The desktop shell is WebKit: the add-and-reveal contract must hold there too.
|
||||
try:
|
||||
webkit = pw.webkit.launch()
|
||||
except PlaywrightError as exc:
|
||||
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
|
||||
webkit = None
|
||||
else:
|
||||
raise
|
||||
if webkit is not None:
|
||||
try:
|
||||
page = webkit.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open_agents_tab(page, url)
|
||||
_agents_panel_add_reveals_the_new_card(page)
|
||||
finally:
|
||||
webkit.close()
|
||||
except PlaywrightError as exc:
|
||||
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
|
||||
pytest.skip(str(exc))
|
||||
raise
|
||||
|
||||
|
||||
def _wizard_step_until(page, predicate_js: str, forward: bool, limit: int = 8) -> None:
|
||||
"""Walk the wizard with Next/Back until `predicate_js` holds. Steps that hold Continue
|
||||
until they have a value (a provider key, the main/light models) get placeholders — the
|
||||
subject here is the Agents step and the summary's Finish, not those steps."""
|
||||
placeholders = {
|
||||
"#openrouter-key": "sk-or-placeholder-not-real",
|
||||
"#main-model": "openai/gpt-5.6-luna",
|
||||
"#light-model": "openai/gpt-5.6-luna",
|
||||
}
|
||||
for _ in range(limit):
|
||||
if page.locator("#onboarding-available-subagents").count():
|
||||
# The Agents step settles asynchronously (saved roster or generated draft);
|
||||
# judge it only once it shows rows or its own failure line.
|
||||
page.wait_for_function(
|
||||
"() => document.querySelectorAll('#onboarding-available-subagents .available-subagent-row').length"
|
||||
" || !document.querySelector('#onboarding-available-subagents [data-subagents-validation]').hidden",
|
||||
timeout=20_000)
|
||||
if page.evaluate(predicate_js):
|
||||
return
|
||||
if forward and page.evaluate("() => Boolean(document.getElementById('next-btn')?.disabled)"):
|
||||
for selector, value in placeholders.items():
|
||||
if page.locator(selector).count() and not page.input_value(selector):
|
||||
page.fill(selector, value)
|
||||
button = "#next-btn" if forward else "#back-btn"
|
||||
try:
|
||||
# A step may hold its button while it settles (a probe, a preview).
|
||||
page.wait_for_function(
|
||||
"(id) => !document.querySelector(id)?.disabled", arg=button, timeout=15_000)
|
||||
except Exception as exc: # noqa: BLE001 - the step's own state is the useful message
|
||||
step = page.evaluate(
|
||||
"() => ({title: document.querySelector('.step-title')?.textContent,"
|
||||
" error: document.querySelector('.wizard-error')?.textContent,"
|
||||
" inputs: [...document.querySelectorAll('input:not([type=hidden])')].map((i) => i.id)})")
|
||||
raise AssertionError(f"wizard button {button} stayed disabled on {step}") from exc
|
||||
page.click(button)
|
||||
page.wait_for_timeout(300)
|
||||
seen = page.evaluate(
|
||||
"() => ({title: document.querySelector('.step-title')?.textContent,"
|
||||
" agents: (document.querySelector('#onboarding-available-subagents')?.innerText || '').slice(0, 400)})")
|
||||
raise AssertionError(f"wizard never reached: {predicate_js}; last seen {seen}")
|
||||
|
||||
|
||||
_WIZARD_ON_AGENTS_JS = "() => document.querySelectorAll('#onboarding-available-subagents .available-subagent-row').length > 0"
|
||||
_WIZARD_ON_SUMMARY_JS = "() => (document.querySelector('.step-title')?.textContent || '').startsWith('Review before launch')"
|
||||
|
||||
|
||||
@pytest.mark.ui_browser
|
||||
def test_ui_smoke_agents_panel_wizard_finish_judges_the_roster(direct_server_with_data):
|
||||
"""First-run wizard (docs/ARCHITECTURE.md §3): an unrouted entry added on the Agents step
|
||||
does not block Continue; Finish on the summary reports it and, back on Agents, the card
|
||||
is already tinted and self-naming; the fix reconciles line and tint together and the
|
||||
second Finish passes the wizard's own checks and enters saving (the save's provider
|
||||
round-trip is not this test's subject)."""
|
||||
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# A saved roster on an OpenRouter-shaped install: the Agents step's preview validates
|
||||
# the model setup the way a first run does, and the shared fixture's mock-LLM model is
|
||||
# not a confirmed main model — so this test mirrors an owner's machine instead.
|
||||
settings_path = direct_server_with_data["data_dir"] / "settings.json"
|
||||
saved = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
for key in ("OUROBOROS_MODEL", "OUROBOROS_MODEL_HEAVY", "OUROBOROS_MODEL_LIGHT", "OUROBOROS_MODEL_FALLBACKS"):
|
||||
saved.pop(key, None)
|
||||
saved["OPENROUTER_API_KEY"] = "sk-or-v1-smoke-placeholder-not-real"
|
||||
saved["OUROBOROS_SUBAGENTS"] = json.dumps(_AGENTS_PANEL_ROSTER)
|
||||
settings_path.write_text(json.dumps(saved), encoding="utf-8")
|
||||
direct_server_with_data["restart_server"]()
|
||||
url = direct_server_with_data["url"]
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch()
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto(f"{url}/onboarding", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#next-btn", timeout=30_000)
|
||||
_wizard_step_until(page, _WIZARD_ON_AGENTS_JS, forward=True)
|
||||
# The step may still be generating its draft; Add waits for it to settle.
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('#onboarding-available-subagents [data-subagent-add]').disabled",
|
||||
timeout=20_000)
|
||||
before = page.locator("#onboarding-available-subagents .available-subagent-row").count()
|
||||
page.click("#onboarding-available-subagents [data-subagent-add]")
|
||||
page.wait_for_function(
|
||||
"(n) => document.querySelectorAll('#onboarding-available-subagents .available-subagent-row')"
|
||||
".length === n + 1", arg=before, timeout=5_000)
|
||||
assert page.locator(
|
||||
"#onboarding-available-subagents .available-subagent-row[data-invalid]").count() == 0
|
||||
|
||||
_wizard_step_until(page, _WIZARD_ON_SUMMARY_JS, forward=True)
|
||||
page.click("#next-btn")
|
||||
page.wait_for_function(
|
||||
"(n) => (document.querySelector('.wizard-error')?.textContent || '')"
|
||||
".startsWith('Subagent ' + n + ' needs')", arg=before + 1, timeout=5_000)
|
||||
|
||||
_wizard_step_until(page, _WIZARD_ON_AGENTS_JS, forward=False)
|
||||
tinted = page.locator("#onboarding-available-subagents .available-subagent-row[data-invalid]")
|
||||
assert tinted.count() == 1
|
||||
assert tinted.locator('[data-subagent-meta][data-tone="error"]').inner_text().startswith(
|
||||
f"Subagent {before + 1} needs")
|
||||
assert not page.evaluate(
|
||||
"() => document.querySelector('#onboarding-available-subagents [data-subagents-validation]').hidden")
|
||||
|
||||
tinted.locator('[data-subagent-field="model"]').fill("openai/gpt-5.6-luna")
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('#onboarding-available-subagents [data-subagents-validation]').hidden"
|
||||
" && !document.querySelector('#onboarding-available-subagents .available-subagent-row[data-invalid]')",
|
||||
timeout=5_000)
|
||||
|
||||
_wizard_step_until(page, _WIZARD_ON_SUMMARY_JS, forward=True)
|
||||
page.click("#next-btn")
|
||||
# The second Finish passes the wizard's own checks and hands the draft to
|
||||
# the save (which probes providers — with a placeholder key that round-trip
|
||||
# is not this test's subject): saved, or saving with no wizard error.
|
||||
try:
|
||||
page.wait_for_function(
|
||||
"() => (document.querySelector('.step-title')?.textContent || '').startsWith('Setup saved')"
|
||||
" || (document.getElementById('next-btn')?.disabled"
|
||||
" && !(document.querySelector('.wizard-error')?.textContent || '').trim())",
|
||||
timeout=10_000)
|
||||
except Exception as exc: # noqa: BLE001 - the wizard's own error is the useful message
|
||||
seen = page.evaluate(
|
||||
"() => ({title: document.querySelector('.step-title')?.textContent,"
|
||||
" error: document.querySelector('.wizard-error')?.textContent})")
|
||||
raise AssertionError(f"second Finish was refused: {seen}") from exc
|
||||
finally:
|
||||
browser.close()
|
||||
except PlaywrightError as exc:
|
||||
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
|
||||
pytest.skip(str(exc))
|
||||
raise
|
||||
|
|
@ -1074,7 +1074,9 @@ def test_ui_smoke_collapsed_activity_line_named_vs_unnamed(
|
|||
assert geometry["title"]["lines"] <= 2.2, geometry
|
||||
assert geometry["activity"]["lines"] <= 2.2, geometry
|
||||
assert geometry["scrollWidth"] <= geometry["clientWidth"] + 1, geometry
|
||||
assert "cost=$0.42" in named.locator('[data-live-meta]').inner_text()
|
||||
named_meta = named.locator('[data-live-meta]').inner_text().split()
|
||||
# Final ledger: the plain amount, never the open-ledger ceiling.
|
||||
assert "$0.42" in named_meta and "up" not in named_meta, named_meta
|
||||
|
||||
unnamed_activity = unnamed.locator('[data-live-activity]')
|
||||
assert "Doing things without a name" in unnamed.locator('[data-live-title]').text_content()
|
||||
|
|
@ -1104,13 +1106,71 @@ def test_ui_smoke_collapsed_activity_line_named_vs_unnamed(
|
|||
assert bands["named-act"]["finished"] is True, bands
|
||||
assert bands["unnamed-act"]["finished"] is False, bands
|
||||
assert bands["running-act"]["finished"] is False, bands
|
||||
for slot in ("title", "activity"):
|
||||
for slot, low in (("title", 0.9), ("activity", 1.9)):
|
||||
heights = [bands[task_id][slot]["height"] for task_id in bands]
|
||||
assert max(heights) - min(heights) <= 1, bands
|
||||
assert all(1.9 <= bands[task_id][slot]["lines"] <= 2.2 for task_id in bands), bands
|
||||
assert all(low <= bands[task_id][slot]["lines"] <= low + 0.3 for task_id in bands), bands
|
||||
assert bands["unnamed-act"]["activity"]["display"] != "none", bands
|
||||
assert bands["unnamed-act"]["activity"]["visibility"] == "hidden", bands
|
||||
# D23 (owner, 2026-09-02): a FINISHED card folds an empty activity
|
||||
# band; a running card keeps the two-line reserve (31.08 seam).
|
||||
_emit_ws_frame(page, {
|
||||
"type": "chat", "role": "assistant", "is_progress": True,
|
||||
"chat_id": 1, "task_id": "done-empty", "suggested_name": "Quick task",
|
||||
"content": "", "ts": "2026-07-29T10:00:03+00:00",
|
||||
})
|
||||
done_empty = page.locator('.chat-live-card[data-task-id="done-empty"]')
|
||||
done_empty.wait_for(state="attached", timeout=30_000)
|
||||
_emit_ws_frame(page, {
|
||||
"type": "chat", "role": "system", "system_type": "task_summary",
|
||||
"chat_id": 1, "task_id": "done-empty", "content": "Done",
|
||||
"ts": "2026-07-29T10:00:04+00:00",
|
||||
})
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('.chat-live-card[data-task-id=\"done-empty\"]')"
|
||||
"?.dataset.finished === '1'",
|
||||
timeout=30_000,
|
||||
)
|
||||
fold = done_empty.evaluate(
|
||||
"""card => { const node = card.querySelector('[data-live-activity]');
|
||||
return {text: node.textContent, display: getComputedStyle(node).display,
|
||||
height: card.getBoundingClientRect().height}; }"""
|
||||
)
|
||||
assert fold["text"].strip() == "", fold
|
||||
assert fold["display"] == "none", fold
|
||||
running_height = running.evaluate("el => el.getBoundingClientRect().height")
|
||||
assert fold["height"] <= running_height - 20, (fold, running_height)
|
||||
assert all(bands[task_id]["meta"]["lines"] >= 0.9 for task_id in bands), bands
|
||||
button_height = "el => el.getBoundingClientRect().height"
|
||||
before_reviews = running.locator(":scope > [data-live-summary-button]").evaluate(button_height)
|
||||
# A root card's acceptance evidence rides the log channel (task detail seam).
|
||||
_emit_ws_frame(page, {"type": "log", "chat_id": 1, "data": {
|
||||
"type": "task_metrics_event", "task_id": "running-act",
|
||||
"ts": "2026-07-29T10:00:03+00:00",
|
||||
"review_projection": {"panels": [{
|
||||
"panel_id": "act-review", "surface": "task_acceptance",
|
||||
"aggregate_signal": "PASS", "reason": "smoke", "actors": [],
|
||||
}]},
|
||||
}})
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('.chat-live-card[data-task-id=\"running-act\"]"
|
||||
" [data-live-review-summary]')?.textContent === 'Reviews 1'",
|
||||
timeout=10_000,
|
||||
)
|
||||
row = running.evaluate(
|
||||
"""card => {
|
||||
const btn = card.querySelector(':scope > [data-live-summary-button]');
|
||||
const meta = btn.querySelector('[data-live-meta]').getBoundingClientRect();
|
||||
const review = btn.querySelector('[data-live-review-summary]').getBoundingClientRect();
|
||||
return {height: btn.getBoundingClientRect().height, metaTop: meta.top,
|
||||
reviewTop: review.top, reviewRight: review.right,
|
||||
buttonRight: btn.getBoundingClientRect().right};
|
||||
}"""
|
||||
)
|
||||
# The quiet count shares the metadata row, docked right, without a new row.
|
||||
assert abs(row["reviewTop"] - row["metaTop"]) <= 2, row
|
||||
assert row["buttonRight"] - row["reviewRight"] <= 20, row
|
||||
assert abs(row["height"] - before_reviews) <= 1, row
|
||||
assert all(not bands[task_id]["clipped"] for task_id in bands), bands
|
||||
assert all("Reviews" not in bands[task_id]["reviews"] for task_id in bands), bands
|
||||
|
||||
|
|
@ -2362,12 +2422,15 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
|
|||
});
|
||||
const main = root.querySelector(':scope > .chat-live-summary-button .chat-live-summary-main').getBoundingClientRect();
|
||||
const side = root.querySelector(':scope > .chat-live-summary-button .chat-live-summary-side').getBoundingClientRect();
|
||||
const title = root.querySelector(':scope > .chat-live-summary-button [data-live-title]').getBoundingClientRect();
|
||||
return {
|
||||
messageWidth: usableMessageWidth,
|
||||
rootWidth: root.getBoundingClientRect().width,
|
||||
deepestWidth: deepest.getBoundingClientRect().width,
|
||||
rootMainBottom: main.bottom,
|
||||
rootSideTop: side.top,
|
||||
rootSideBottom: side.bottom,
|
||||
rootTitleTop: title.top,
|
||||
cardFacts,
|
||||
};
|
||||
}"""
|
||||
|
|
@ -2386,9 +2449,13 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
|
|||
facts = page.evaluate(mobile_geometry)
|
||||
assert facts["rootWidth"] >= facts["messageWidth"] * 0.95, facts
|
||||
assert facts["rootWidth"] - facts["deepestWidth"] <= 40, facts
|
||||
assert facts["rootSideTop"] >= facts["rootMainBottom"] - 1, facts
|
||||
# Narrow regime: the side controls share the chip row, the title takes its own
|
||||
# full-width row below both.
|
||||
assert facts["rootSideTop"] < facts["rootMainBottom"], facts
|
||||
assert facts["rootTitleTop"] >= max(facts["rootMainBottom"], facts["rootSideBottom"]) - 1, facts
|
||||
assert all(card["scrollWidth"] <= card["clientWidth"] + 1 for card in facts["cardFacts"]), facts
|
||||
assert min(card["titleWidth"] for card in facts["cardFacts"]) >= 160, facts
|
||||
assert 0.9 <= min(card["titleLines"] for card in facts["cardFacts"]), facts
|
||||
assert max(card["titleLines"] for card in facts["cardFacts"]) <= 2.2, facts
|
||||
assert max(card["activityLines"] for card in facts["cardFacts"]) <= 2.2, facts
|
||||
assert all(card["activityTitle"] is None for card in facts["cardFacts"]), facts
|
||||
|
|
@ -2541,6 +2608,43 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
|
|||
assert min(card["mainBottom"], card["sideBottom"]) \
|
||||
> max(card["mainTop"], card["sideTop"]), wide_facts
|
||||
|
||||
# The 620-700px column (laptop with the project panel open): the root
|
||||
# card takes up to 620px there and keeps its single-row header.
|
||||
wide.set_viewport_size({"width": 1004, "height": 750})
|
||||
wide.wait_for_timeout(250)
|
||||
owner_facts = wide.evaluate(
|
||||
"""() => {
|
||||
const card = document.querySelector('#page-chat .chat-live-card[data-task-id="layout-root"]');
|
||||
const summary = card.querySelector(':scope > .chat-live-summary-button .chat-live-summary');
|
||||
return {column: document.querySelector('#page-chat #chat-messages').clientWidth,
|
||||
width: card.getBoundingClientRect().width,
|
||||
wrap: getComputedStyle(summary).flexWrap};
|
||||
}"""
|
||||
)
|
||||
assert 700 <= owner_facts["column"] <= 740, owner_facts
|
||||
# 80% of a 700-740px column is below the 620px floor, so the floor wins.
|
||||
assert abs(owner_facts["width"] - 620) <= 1 and owner_facts["wrap"] == "nowrap", owner_facts
|
||||
# The width is monotonic across the 620px chatcol breakpoint: against
|
||||
# its containing block's content width the card is
|
||||
# min(content, max(80% of content, 620px)) at every column width.
|
||||
width_formula = (
|
||||
"""() => {
|
||||
const card = document.querySelector('#page-chat .chat-live-card[data-task-id="layout-root"]');
|
||||
const block = card.parentElement;
|
||||
const style = getComputedStyle(block);
|
||||
const content = block.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
return {content, width: card.getBoundingClientRect().width};
|
||||
}"""
|
||||
)
|
||||
for viewport_width in (880, 920, 1100):
|
||||
wide.set_viewport_size({"width": viewport_width, "height": 750})
|
||||
wide.wait_for_timeout(250)
|
||||
sample = wide.evaluate(width_formula)
|
||||
expected = min(sample["content"], max(0.8 * sample["content"], 620))
|
||||
assert abs(sample["width"] - expected) <= 1, (viewport_width, sample, expected)
|
||||
wide.set_viewport_size({"width": 1100, "height": 750})
|
||||
wide.wait_for_timeout(250)
|
||||
|
||||
assert_jump_geometry(wide, "#page-chat")
|
||||
jump = wide.locator("#page-chat .chat-scroll-bottom-btn")
|
||||
before_hover = jump.bounding_box()
|
||||
|
|
@ -2604,8 +2708,10 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
|
|||
cardClient: card.clientWidth,
|
||||
cardScroll: card.scrollWidth,
|
||||
titleWidth: title.width,
|
||||
titleTop: title.top,
|
||||
mainBottom: main.bottom,
|
||||
sideTop: side.top,
|
||||
sideBottom: side.bottom,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
|
@ -2613,7 +2719,8 @@ def test_ui_smoke_live_cards_keep_usable_geometry_at_depth_and_in_project_panel(
|
|||
assert panel_facts["cardWidth"] >= panel_facts["panelWidth"] * 0.9, panel_facts
|
||||
assert panel_facts["cardScroll"] <= panel_facts["cardClient"] + 1, panel_facts
|
||||
assert panel_facts["titleWidth"] >= 180, panel_facts
|
||||
assert panel_facts["sideTop"] >= panel_facts["mainBottom"] - 1, panel_facts
|
||||
assert panel_facts["sideTop"] < panel_facts["mainBottom"], panel_facts
|
||||
assert panel_facts["titleTop"] >= max(panel_facts["mainBottom"], panel_facts["sideBottom"]) - 1, panel_facts
|
||||
assert_jump_geometry(
|
||||
wide, "#panel-pchat-layout-project", require_overflow=False
|
||||
)
|
||||
|
|
@ -3623,4 +3730,5 @@ def test_ui_smoke_cancel_run_button_eligibility_and_cancelled_state(direct_serve
|
|||
|
||||
|
||||
# The in-flight indicator lifecycle smoke test lives in
|
||||
# tests/test_ui_smoke_inflight_indicator.py (size-ratchet byte gate on this module).
|
||||
# tests/test_ui_smoke_inflight_indicator.py and the Settings → Agents list-editor
|
||||
# acceptance in tests/test_ui_smoke_agents_panel.py (size-ratchet byte gate on this module).
|
||||
|
|
|
|||
|
|
@ -998,3 +998,77 @@ def test_ui_smoke_open_project_panel_heals_lost_task_done_from_state_fanout(
|
|||
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
|
||||
pytest.skip(str(exc))
|
||||
raise
|
||||
|
||||
|
||||
@pytest.mark.ui_browser
|
||||
def test_ui_smoke_project_pointer_is_a_main_root_affordance(direct_server_with_data): # noqa: F811
|
||||
"""The bound-task pointer (`in project ↗`) belongs to the LIVE Main ROOT card:
|
||||
a task that scopes itself into a project mid-run keeps its Main card, which
|
||||
gains the pointer; the same task's card inside the project panel carries none;
|
||||
and clicking the Main pointer while that panel is already open leaves it open."""
|
||||
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
|
||||
url = direct_server_with_data["url"]
|
||||
data_dir = direct_server_with_data["data_dir"]
|
||||
project = create_project(data_dir, "ptr-panel", name="Pointer panel")
|
||||
project_chat = int(project["chat_id"])
|
||||
main_card = '#page-chat .chat-live-card[data-task-id="ptr-root"]'
|
||||
panel_card = '#panel-pchat-ptr-panel .chat-live-card[data-task-id="ptr-root"]'
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser, page = _launch(pw)
|
||||
try:
|
||||
_goto_main_ready(page, url)
|
||||
# The task starts in Main as a live root card...
|
||||
page.evaluate(
|
||||
"""() => window.__ouroWs.emit('chat', {
|
||||
type: 'chat', role: 'assistant', is_progress: true, chat_id: 1,
|
||||
task_id: 'ptr-root', content: 'Started in Main',
|
||||
ts: '2026-08-19T10:00:00+00:00',
|
||||
})"""
|
||||
)
|
||||
page.wait_for_selector(main_card, state="attached", timeout=30_000)
|
||||
assert page.locator(f"{main_card} .chat-live-bound-pointer").count() == 0
|
||||
# ...then scopes itself into the project (durable binding + project-thread progress).
|
||||
bind_task_to_project(data_dir, "ptr-root", "ptr-panel", project_chat, origin={"absent": "system"})
|
||||
logs_dir = data_dir / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with (logs_dir / "progress.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({
|
||||
"ts": "2026-08-19T10:00:01+00:00", "chat_id": project_chat,
|
||||
"task_id": "ptr-root", "content": "Continues in the project",
|
||||
"is_progress": True,
|
||||
}) + "\n")
|
||||
page.evaluate("() => window.__ouroWs.emit('projects_changed', {})")
|
||||
page.wait_for_selector(f"{main_card} .chat-live-bound-pointer", state="attached", timeout=30_000)
|
||||
assert page.locator(f"{main_card} .chat-live-project-name").inner_text().strip() == "Pointer panel"
|
||||
assert page.locator(f"{main_card} .chat-live-project-icon svg").count() == 1
|
||||
page_errors = []
|
||||
page.on("pageerror", lambda exc: page_errors.append(str(exc)))
|
||||
# Positive path: the pointer OPENS its project panel from Main.
|
||||
assert page.locator("#project-panel:not([hidden])").count() == 0
|
||||
page.locator(f"{main_card} .chat-live-bound-pointer").click()
|
||||
page.wait_for_selector("#project-panel:not([hidden])", timeout=30_000)
|
||||
page.wait_for_selector(panel_card, state="attached", timeout=30_000)
|
||||
# Re-apply the bindings now that the panel card exists: it stays pointer-free.
|
||||
page.evaluate("() => window.__ouroWs.emit('projects_changed', {})")
|
||||
page.wait_for_timeout(600)
|
||||
assert page.locator(f"{panel_card} .chat-live-bound-pointer").count() == 0
|
||||
assert page.locator(f"{main_card} .chat-live-bound-pointer").count() == 1
|
||||
# Open-or-noop: the pointer never closes the panel it points at. The
|
||||
# panel backdrop covers Main, so the handler is exercised directly.
|
||||
page.locator(f"{main_card} .chat-live-bound-pointer").dispatch_event("click")
|
||||
page.wait_for_timeout(400)
|
||||
assert page.locator("#project-panel:not([hidden])").count() == 1
|
||||
assert page.locator(panel_card).count() == 1
|
||||
assert page_errors == [], page_errors
|
||||
finally:
|
||||
browser.close()
|
||||
except PlaywrightError as exc:
|
||||
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
|
||||
pytest.skip(str(exc))
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ An unparseable marker used to be left in place forever, latching
|
|||
Boot now quarantines it aside byte-intact (evidence survives, the latch does
|
||||
not) unless MERGE_HEAD shows the marker may cover a live merge."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from supervisor import update_candidate, update_merge
|
||||
|
|
@ -72,7 +70,6 @@ def test_active_valid_tx_still_closes_admission(tmp_path, monkeypatch):
|
|||
assert workers.repo_writer_admission_closed() == "managed_update_tx:assisted_resolution"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="chmod(0) does not deny reads on Windows; the unreadable-marker probe is POSIX-only")
|
||||
def test_unreadable_marker_is_left_in_place_fail_closed(tmp_path, monkeypatch):
|
||||
"""#447 S2: "corrupt" conflates unreadable with unparseable. A marker that
|
||||
cannot be READ may still be a valid live transaction — boot must not
|
||||
|
|
@ -87,6 +84,11 @@ def test_unreadable_marker_is_left_in_place_fail_closed(tmp_path, monkeypatch):
|
|||
marker = update_merge._update_tx_marker_path()
|
||||
marker.write_text('{"phase": "assisted_resolution", "task_id": "x"}', encoding="utf-8")
|
||||
os.chmod(marker, 0)
|
||||
if os.access(marker, os.R_OK):
|
||||
# Windows ACLs (and root on POSIX) keep a mode-0 file readable: the
|
||||
# unreadable precondition cannot be staged on this host.
|
||||
os.chmod(marker, 0o644)
|
||||
pytest.skip("host cannot make the marker unreadable (Windows / root)")
|
||||
try:
|
||||
res = update_merge.finalize_managed_update_on_boot()
|
||||
finally:
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1535,7 +1535,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ouroboros"
|
||||
version = "6.113.5"
|
||||
version = "6.114.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
|
|
|
|||
29
web/app.js
29
web/app.js
|
|
@ -22,7 +22,7 @@ import { initDashboard } from './modules/dashboard.js';
|
|||
import { hydrateNavIcons } from './modules/page_icons.js';
|
||||
|
||||
import { initOnboardingOverlay } from './modules/onboarding_overlay.js';
|
||||
import { installAltMenuSuppression, installDesktopShellLinkInterceptor } from './modules/ui_helpers.js';
|
||||
import { installAltMenuSuppression, installDesktopShellLinkInterceptor, renderProjectChip } from './modules/ui_helpers.js';
|
||||
|
||||
const state = {
|
||||
messages: [],
|
||||
|
|
@ -560,7 +560,10 @@ function applyTaskBindings(bindings) {
|
|||
const entries = window.__ouroTaskBindings;
|
||||
const bound = new Set(Object.keys(entries));
|
||||
if (!bound.size) return;
|
||||
document.querySelectorAll('.chat-live-card[data-task-id]').forEach((card) => {
|
||||
// The pointer is a Main ROOT card affordance: a card inside a project panel
|
||||
// is already in that project (its pointer would only close the panel), and a
|
||||
// nested subagent card is not the task the binding names.
|
||||
document.querySelectorAll('#page-chat .chat-live-card[data-task-id]:not(.subagent)').forEach((card) => {
|
||||
const tid = card.dataset.taskId;
|
||||
// A converted card (projectCreated) already shows its own project chip.
|
||||
if (!bound.has(tid) || card.dataset.projectCreated === '1') return;
|
||||
|
|
@ -583,20 +586,14 @@ function renderBoundProjectPointer(card, projectId, chatId = 0) {
|
|||
|| { id: projectId, name: projectId, chat_id: chatId };
|
||||
let ptr = card.querySelector('.chat-live-bound-pointer');
|
||||
if (!ptr) {
|
||||
ptr = document.createElement('button');
|
||||
ptr.type = 'button';
|
||||
ptr.className = 'chat-live-project-card-btn chat-live-bound-pointer';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'chat-live-project-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '📁';
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'chat-live-project-name';
|
||||
const status = document.createElement('span');
|
||||
status.className = 'chat-live-project-status';
|
||||
status.textContent = 'in project ↗';
|
||||
ptr.append(icon, nameEl, status);
|
||||
ptr.addEventListener('click', () => openProjectPanel(project));
|
||||
ptr = renderProjectChip({
|
||||
name: project.name || project.id,
|
||||
status: 'in project ↗',
|
||||
className: 'chat-live-bound-pointer',
|
||||
// Open-or-noop: openProjectPanel toggles, and a pointer must never close
|
||||
// the panel it points at.
|
||||
onClick: () => { if (navState.activeProjectId !== project.id) openProjectPanel(project); },
|
||||
});
|
||||
card.appendChild(ptr);
|
||||
}
|
||||
card.dataset.projectBound = '1';
|
||||
|
|
|
|||
|
|
@ -1190,4 +1190,4 @@ export const MAX_QUIZ_OPTIONS = 6;
|
|||
// REFUSES a longer comment (it is delivered verbatim, never truncated), so
|
||||
// the card must not offer to send one.
|
||||
export const MAX_DECISION_COMMENT = 2000;
|
||||
export const GATEWAY_CONTRACT_VERSION = '6.113.5';
|
||||
export const GATEWAY_CONTRACT_VERSION = '6.114.0';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { destroyChatMarkdown, enhanceChatMarkdown, renderChatMarkdown } from './
|
|||
import { renderPageHeader } from './page_header.js';
|
||||
import { PAGE_ICONS } from './page_icons.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { createSystemMessageAction } from './ui_helpers.js';
|
||||
import { createSystemMessageAction, renderProjectChip } from './ui_helpers.js';
|
||||
import { cleanupUploadedAttachments, createChatMedia, showTaskIncidentToast } from './chat_media.js';
|
||||
import { createChatDecision } from './chat_decision.js';
|
||||
import { clientSurfaceField } from './client_surface.js';
|
||||
|
|
@ -1247,23 +1247,10 @@ export function createChatInstance({
|
|||
delete record.root.dataset.projectCreating;
|
||||
record.root.dataset.projectCreated = '1';
|
||||
record.root.dataset.projectId = project.id || '';
|
||||
const name = String(project.name || project.id || 'Project').trim();
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'chat-live-project-card-btn';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'chat-live-project-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '📁';
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'chat-live-project-name';
|
||||
nameEl.textContent = name; // textContent — no HTML injection from a project name
|
||||
const status = document.createElement('span');
|
||||
status.className = 'chat-live-project-status';
|
||||
status.textContent = 'running in background ↗';
|
||||
chip.append(icon, nameEl, status);
|
||||
chip.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('ouro:open-project', { detail: { project } }));
|
||||
const chip = renderProjectChip({
|
||||
name: String(project.name || project.id || 'Project').trim(),
|
||||
status: 'running in background ↗',
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('ouro:open-project', { detail: { project } })),
|
||||
});
|
||||
// Atomic detach-and-reparent (C4.5): replaceChildren swaps the whole live
|
||||
// timeline (subagent cards, working bubble) for the chip in one paint.
|
||||
|
|
@ -1424,8 +1411,8 @@ export function createChatInstance({
|
|||
<div class="chat-live-typing" data-live-typing aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<span class="chat-live-title" data-live-title>Waiting for work</span>
|
||||
</div>
|
||||
<span class="chat-live-title" data-live-title>Waiting for work</span>
|
||||
<div class="chat-live-summary-side">
|
||||
<span class="chat-live-count" data-live-count hidden>2 notes</span>
|
||||
<span class="chat-live-toggle" data-live-toggle>Show details</span>
|
||||
|
|
@ -1861,8 +1848,8 @@ export function createChatInstance({
|
|||
record.groupId === 'bg-consciousness' ? 'Background thinking' : '',
|
||||
...(Array.isArray(record._lastFrameMeta) ? record._lastFrameMeta : []),
|
||||
...((record.costMeta && Array.isArray(record.costMeta.meta)) ? record.costMeta.meta : []),
|
||||
record.latestActivityTs ? `Latest ${record.latestActivityTs}` : '',
|
||||
].filter(Boolean).map((item) => `<span class="chat-live-meta-text">${escapeHtml(item)}</span>`).join('');
|
||||
record.latestActivityTs ? `updated ${record.latestActivityTs}` : '',
|
||||
].filter(Boolean).map((item) => `<span class="chat-live-meta-text">${escapeHtml(item)}</span>`).join(' · ');
|
||||
if (record.metaEl.innerHTML === html) return false;
|
||||
record.metaEl.innerHTML = html;
|
||||
return Boolean(record.metaEl.isConnected);
|
||||
|
|
|
|||
|
|
@ -305,19 +305,16 @@ export function taskCostMeta(payload = {}) {
|
|||
const pendingKnown = payload.cost_final === false
|
||||
|| payload.cost_with_children_partial === true
|
||||
|| payload.cost_accounting_status === 'available' && !has('cost_final');
|
||||
const meta = [];
|
||||
if (total === null) {
|
||||
meta.push('cost pending');
|
||||
} else if (finalKnown || pendingKnown || total !== 0) {
|
||||
meta.push(`cost=$${total.toFixed(2)}${pendingKnown && !finalKnown ? ' (pending)' : ''}`);
|
||||
}
|
||||
const reserved = optionalFiniteNumber(payload.reserved_usd);
|
||||
if (reserved !== null && reserved > 0) meta.push(`reserved=$${reserved.toFixed(2)}`);
|
||||
const unresolved = optionalFiniteNumber(payload.unresolved_upper_bound_usd);
|
||||
if (unresolved !== null && unresolved > 0) meta.push(`unresolved≤$${unresolved.toFixed(2)}`);
|
||||
const unknown = optionalFiniteNumber(payload.unknown_unmetered);
|
||||
if (unknown !== null && unknown > 0) meta.push(`unmetered=${Math.trunc(unknown)}`);
|
||||
return meta;
|
||||
// ONE amount (owner decisions, 2026-09-02): the accounted upper bound already
|
||||
// contains settled + reserved + unresolved (cost_projection.py), so the card
|
||||
// states that number once and lets its wording carry the openness — a ceiling
|
||||
// (`up to`) while the ledger is open, a plain amount once final. Calls with no
|
||||
// known price are not named here (owner: no separate counter); component
|
||||
// breakdowns and unmetered counts stay on Costs, Logs and task detail.
|
||||
if (total === null) return ['cost pending'];
|
||||
if (!(finalKnown || pendingKnown || total !== 0)) return [];
|
||||
const amount = `$${total.toFixed(2)}`;
|
||||
return [finalKnown ? amount : `up to ${amount}`];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -386,7 +383,7 @@ export function clearStickyCardState(record) {
|
|||
record.finalizingHold = false;
|
||||
// The activity clock is cycle state too: a
|
||||
// recycled slot ('bg-consciousness', 'active') would otherwise open showing
|
||||
// the previous cycle's "Latest" time.
|
||||
// the previous cycle's "updated" time.
|
||||
record.latestActivityTs = '';
|
||||
if (record.activityEl) {
|
||||
record.activityEl.textContent = '';
|
||||
|
|
@ -407,8 +404,36 @@ export function clearStickyCardState(record) {
|
|||
*/
|
||||
export const COLLAPSED_ACTIVITY_MAX = 240;
|
||||
|
||||
/**
|
||||
* The collapsed activity line is plain text: the expanded timeline renders the
|
||||
* same headline through `renderMarkdown`, so the compact projection strips that
|
||||
* renderer's marker inventory (utils.js) — fences, inline code, bold, emphasis,
|
||||
* strikethrough, headings, bullets, links, table pipes. It strips line by line
|
||||
* without the renderer's block context, so a stray pipe row or list marker the
|
||||
* timeline would show literally is dropped here too: over-stripping is the
|
||||
* accepted side of that trade, a leaked marker is not. A headline that is
|
||||
* nothing but markers keeps its source text: an empty projection would flip
|
||||
* the reserved activity band's `:empty` rules.
|
||||
*/
|
||||
export function plainActivityText(text = '') {
|
||||
const source = String(text || '');
|
||||
const plain = source
|
||||
.replace(/```\w*\n([\s\S]*?)```/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/~~(.+?)~~/g, '$1')
|
||||
.replace(/^#{1,3} (.+)$/gm, '$1')
|
||||
.replace(/^- (.+)$/gm, '$1')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/^\|(.+)\|$/gm, (_, row) => row.split('|').map((cell) => cell.trim()).join(' '))
|
||||
.replace(/^[\s\-:|]+$/gm, '');
|
||||
const trimmed = plain.trim();
|
||||
return trimmed || source;
|
||||
}
|
||||
|
||||
export function boundActivityPreview(value = '') {
|
||||
const candidate = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const candidate = plainActivityText(value).replace(/\s+/g, ' ').trim();
|
||||
if (candidate.length <= COLLAPSED_ACTIVITY_MAX) return candidate;
|
||||
return candidate.slice(0, COLLAPSED_ACTIVITY_MAX - 1).trimEnd() + '…';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { apiFetch, jsonPost } from './api_client.js';
|
||||
/** MCP settings cards; preserves masked auth tokens until the user edits them. */
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
import { revealNewRow } from './ui_helpers.js';
|
||||
|
||||
const TRANSPORTS = [
|
||||
{ value: 'streamable_http', label: 'Streamable HTTP' },
|
||||
|
|
@ -374,6 +375,10 @@ function bindAddButton() {
|
|||
btn.addEventListener('click', () => {
|
||||
mcpServers.push(emptyServer());
|
||||
renderAll();
|
||||
// The Add action lives in the section head while the new card lands
|
||||
// at the list's end — show it there and hand the caret to its id.
|
||||
const card = document.getElementById('mcp-servers-list')?.lastElementChild;
|
||||
revealNewRow(card, card?.querySelector?.('[data-mcp-field="id"]'));
|
||||
notifyChanged();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ export function createAgentsStep({
|
|||
win: () => getDoc()?.defaultView,
|
||||
store,
|
||||
onChange: onSubagentsChange,
|
||||
baselineLabel: 'Generated draft',
|
||||
baseline: 'generated',
|
||||
});
|
||||
|
||||
function previewRequest() {
|
||||
|
|
@ -814,6 +814,9 @@ export function createAgentsStep({
|
|||
get previewPending() { return state.previewPending; },
|
||||
get previewError() { return state.previewError; },
|
||||
validateSubagents() { return subagents.validate(); },
|
||||
// Finish is the wizard's commit: the roster then shows its own errors
|
||||
// beside the rows they name when the owner steps back here.
|
||||
noteSaveAttempt() { subagents.noteSaveAttempt(); },
|
||||
refreshSubagentsPreview,
|
||||
invalidateGeneratedPreview,
|
||||
setSkipPresets(value) {
|
||||
|
|
|
|||
|
|
@ -1274,6 +1274,7 @@ import { installAltMenuSuppression, installDesktopShellLinkInterceptor } from '.
|
|||
const modelsError = validateModelsStep();
|
||||
const reviewError = validateReviewStep();
|
||||
const budgetError = validateBudgetStep();
|
||||
agentsStep?.noteSaveAttempt?.();
|
||||
const subagentsError = agentsStep?.validateSubagents?.()?.[0] || '';
|
||||
const previewError = agentsStep && !agentsStep.generatedPreviewReady
|
||||
? (agentsStep.previewPending
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
import { apiFetch } from './api_client.js';
|
||||
import { bindStatusSurface, boundedStatusRefresh, claudexorStatus } from './claudexor_status_store.js';
|
||||
import { harnessIdentityMarkup } from './harness_presentation.js';
|
||||
import { formatRelativeAge } from './ui_helpers.js';
|
||||
import { formatRelativeAge, revealNewRow } from './ui_helpers.js';
|
||||
import * as routeEditor from './route_editor_primitives.js';
|
||||
import {
|
||||
availableSubagentsLoadValue,
|
||||
|
|
@ -495,21 +495,27 @@ export function renderReviewerSlotsSection() {
|
|||
<div id="reviewer-slots-error" class="ui-status" data-tone="error" hidden></div>
|
||||
<div id="reviewer-slots-pins" class="settings-inline-status" data-tone="warn" hidden></div>
|
||||
<datalist id="reviewer-api-model-catalog"></datalist>
|
||||
<h4 class="reviewer-slots-heading">Triad slots <span class="muted" id="reviewer-triad-limit" title="The commit gate's real ceiling"></span></h4>
|
||||
<div id="reviewer-triad-rows" class="reviewer-slot-rows"></div>
|
||||
<div class="settings-toolbar">
|
||||
<button type="button" class="btn btn-default" id="btn-add-triad-slot">Add triad slot</button>
|
||||
<div class="reviewer-slots-group">
|
||||
<div class="reviewer-slots-head">
|
||||
<h4 class="reviewer-slots-heading">Triad slots <span class="muted" id="reviewer-triad-limit" title="The commit gate's real ceiling"></span></h4>
|
||||
<button type="button" class="btn btn-default" id="btn-add-triad-slot">Add triad slot</button>
|
||||
</div>
|
||||
<div id="reviewer-triad-rows" class="reviewer-slot-rows"></div>
|
||||
</div>
|
||||
<h4 class="reviewer-slots-heading">Scope slots <span class="muted" id="reviewer-scope-limit" title="The scope pool's real width"></span></h4>
|
||||
<div class="settings-inline-note">An agent row reads the repository with its own read-only tools instead of
|
||||
being handed one assembled pack. Its verdict is authoritative once that agent's context window is
|
||||
confirmed at 200K or more; Ouroboros does not attest which files the agent opened.</div>
|
||||
<div id="reviewer-scope-rows" class="reviewer-slot-rows"></div>
|
||||
<div class="settings-toolbar">
|
||||
<button type="button" class="btn btn-default" id="btn-add-scope-slot">Add scope slot</button>
|
||||
<div class="reviewer-slots-group">
|
||||
<div class="reviewer-slots-head">
|
||||
<h4 class="reviewer-slots-heading">Scope slots <span class="muted" id="reviewer-scope-limit" title="The scope pool's real width"></span></h4>
|
||||
<button type="button" class="btn btn-default" id="btn-add-scope-slot">Add scope slot</button>
|
||||
</div>
|
||||
<div class="settings-inline-note">An agent row reads the repository with its own read-only tools instead of
|
||||
being handed one assembled pack. Its verdict is authoritative once that agent's context window is
|
||||
confirmed at 200K or more; Ouroboros does not attest which files the agent opened.</div>
|
||||
<div id="reviewer-scope-rows" class="reviewer-slot-rows"></div>
|
||||
</div>
|
||||
<div class="reviewer-slots-group">
|
||||
<h4 class="reviewer-slots-heading">Advisory pre-reviewer</h4>
|
||||
<div id="reviewer-advisory-row" class="reviewer-slot-rows"></div>
|
||||
</div>
|
||||
<h4 class="reviewer-slots-heading">Advisory pre-reviewer</h4>
|
||||
<div id="reviewer-advisory-row" class="reviewer-slot-rows"></div>
|
||||
<div class="settings-inline-note">
|
||||
Disabling the advisory is a standing decision with a constitutional consequence:
|
||||
every reviewed commit then records an <strong>audited bypass</strong> instead of an
|
||||
|
|
@ -697,7 +703,7 @@ function renderRows() {
|
|||
errorBox.hidden = !(state.configError || state.loadError);
|
||||
errorBox.textContent = state.configError
|
||||
? `Saved reviewer-slot configuration is invalid and blocks reviews: ${state.configError}. `
|
||||
+ 'To repair it, add at least one triad slot and one scope slot below, then Save'
|
||||
+ 'To repair it, add at least one triad slot and one scope slot from the group headers below, then Save'
|
||||
+ (state.triad.length && state.scope.length ? '.' : ' — Save will report the missing rows.')
|
||||
: (state.loadError
|
||||
? `Could not reach the reviewer-slot settings — ${state.loadError}. Your saved configuration is unchanged; retry when the connection is back.`
|
||||
|
|
@ -874,6 +880,11 @@ function addRow(group) {
|
|||
effort: '',
|
||||
});
|
||||
renderRows();
|
||||
// The Add button sits in the group's header while the new row lands at
|
||||
// the group's end — reveal it there and hand the caret to its picker.
|
||||
const added = document.getElementById(group === 'scope' ? 'reviewer-scope-rows' : 'reviewer-triad-rows')
|
||||
?.lastElementChild;
|
||||
revealNewRow(added, added?.querySelector?.('[data-slot-route]'));
|
||||
state.onChange();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
availableSubagentsPreviewPayload,
|
||||
collectSubagentsSettings,
|
||||
initSubagentsSection,
|
||||
noteSubagentsSaveAttempt,
|
||||
reloadSubagentsSection,
|
||||
subagentSettingsFingerprint,
|
||||
validateSubagentsDraft,
|
||||
|
|
@ -19,7 +20,7 @@ import { showToast } from './toast.js';
|
|||
import { escapeHtmlAttr as escapeHtml, formatDualVersion } from './utils.js';
|
||||
import { apiClient, apiFetch, cleanExtensionRoute, extensionRoutePath } from './api_client.js';
|
||||
import { claudexorStatus } from './claudexor_status_store.js';
|
||||
import { collectSafeFieldValues, renderSafeField, setInlineStatus } from './ui_helpers.js';
|
||||
import { collectSafeFieldValues, renderSafeField, setInlineStatus, revealNewRow } from './ui_helpers.js';
|
||||
|
||||
let markSettingsDirty = () => {};
|
||||
const BASE_SECRET_KEYS = new Set(SECRET_KEYS.map(([key]) => key));
|
||||
|
|
@ -89,10 +90,15 @@ function isTruthySetting(value) {
|
|||
return value === true || ['true', '1', 'yes', 'on'].includes(normalized);
|
||||
}
|
||||
|
||||
function setStatus(text, tone = 'ok') {
|
||||
// `owner` names the surface a message belongs to (today only the Available
|
||||
// subagents roster claims one); a later message from anyone else drops it, so
|
||||
// an owner may clear its own stale message but never a newer one.
|
||||
function setStatus(text, tone = 'ok', owner = '') {
|
||||
const status = byId('settings-status');
|
||||
status.textContent = text;
|
||||
status.dataset.tone = tone;
|
||||
if (owner) status.dataset.owner = owner;
|
||||
else delete status.dataset.owner;
|
||||
}
|
||||
|
||||
function setButtonBusy(button, busy) {
|
||||
|
|
@ -414,6 +420,12 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
initReviewerSlots({ onChange: () => updateSettingsDirtyState() });
|
||||
initSubagentsSection({
|
||||
onChange: () => updateSettingsDirtyState(),
|
||||
// The roster's section line and the footer message it owns read one
|
||||
// verdict: when the judged rows come clean, the footer clears with the
|
||||
// line and the tint — unless someone else has written the footer since.
|
||||
onJudged: (clean) => {
|
||||
if (clean && byId('settings-status').dataset.owner === 'subagents') setStatus('', 'ok');
|
||||
},
|
||||
isOuterDraftClean: () => !settingsDirty,
|
||||
onGeneratedApply: () => {
|
||||
if (settingsLoaded && !settingsDirty) setSettingsCleanBaseline();
|
||||
|
|
@ -997,8 +1009,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
if (host.querySelector('.muted')) host.innerHTML = '';
|
||||
const row = customSecretRow();
|
||||
host.appendChild(row);
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
row.querySelector('[data-custom-secret-key]')?.focus();
|
||||
revealNewRow(row, row.querySelector('[data-custom-secret-key]'));
|
||||
markSettingsDirty();
|
||||
});
|
||||
|
||||
|
|
@ -1206,6 +1217,10 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
setStatus('Reload current settings successfully before saving.', 'warn');
|
||||
return;
|
||||
}
|
||||
// The owner just tried to commit the draft — every Save click is one,
|
||||
// whichever validation aborts it below — so from here the roster shows
|
||||
// its own errors beside the rows they name, not only in this status.
|
||||
noteSubagentsSaveAttempt();
|
||||
// Validate Every-N cadence before save: malformed N must NOT silently coerce
|
||||
// into a valid (e.g. every-task) cadence. Abort with a visible error instead.
|
||||
if (byId('s-post-task-evolution-mode')?.value === 'every_n'
|
||||
|
|
@ -1215,7 +1230,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
}
|
||||
const subagentErrors = validateSubagentsDraft();
|
||||
if (subagentErrors.length) {
|
||||
setStatus(`Available subagents: ${subagentErrors[0]}`, 'warn');
|
||||
setStatus(`Available subagents: ${subagentErrors[0]}`, 'warn', 'subagents');
|
||||
return;
|
||||
}
|
||||
const body = collectBody();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
// Live-status projection for one saved Agent-session actor. Dispatch remains
|
||||
// authoritative; this module only decides which positive/negative facts the
|
||||
// Settings row may honestly claim from the shared Claudexor snapshot.
|
||||
// Status and meta projection of one Available-subagents card: pure functions
|
||||
// of the row, the editor's state and the shared Claudexor snapshot, with no
|
||||
// DOM, so the editor's markup and its in-place painter read one source and
|
||||
// node tests pin the words without a browser. Dispatch remains authoritative;
|
||||
// this module only decides which positive/negative facts a card may honestly
|
||||
// claim.
|
||||
|
||||
import { accountRows, nextUpAccount } from './claudexor_status_store.js';
|
||||
import { harnessModelsKnown, splitSessionTarget } from './route_editor_primitives.js';
|
||||
import {
|
||||
ROUTE_KIND_AGENT_SESSION,
|
||||
describeExecutionEvidence,
|
||||
harnessModelsKnown,
|
||||
modelsGapNote,
|
||||
splitSessionTarget,
|
||||
} from './route_editor_primitives.js';
|
||||
|
||||
function harnessMap(snapshot) {
|
||||
export function harnessMap(snapshot) {
|
||||
return Object.fromEntries((snapshot?.harnesses || [])
|
||||
.filter((harness) => harness?.id)
|
||||
.map((harness) => [String(harness.id), harness]));
|
||||
|
|
@ -57,18 +66,31 @@ function modelIsPresent(harness, model) {
|
|||
String(entry?.id || entry?.value || entry || '') === String(model));
|
||||
}
|
||||
|
||||
export function sessionRouteAvailability(row, state, nowMs = Date.now()) {
|
||||
// One verdict per branch: the short `label` (what a card head has room for),
|
||||
// the status `tone` and the full `text` (the sentence a tooltip carries). The
|
||||
// three are decided together so a reader never re-derives tone from prose.
|
||||
const AVAILABLE = ['Available', 'ok'];
|
||||
const NOT_CHECKED = ['Not checked', 'neutral'];
|
||||
const UNAVAILABLE = ['Unavailable', 'warn'];
|
||||
const NO_ACCOUNT = ['No account', 'warn'];
|
||||
const LIMIT = ['Limit reached', 'warn'];
|
||||
|
||||
function verdict([label, tone], text) {
|
||||
return { label, tone, text };
|
||||
}
|
||||
|
||||
export function sessionRouteVerdict(row, state, nowMs = Date.now()) {
|
||||
const { harness, model } = splitSessionTarget(row?.route?.target_id);
|
||||
if (!state?.catalogKnown || !state?.accountsKnown) {
|
||||
return 'Agent session · live availability not checked';
|
||||
return verdict(NOT_CHECKED, 'Agent session · live availability not checked');
|
||||
}
|
||||
const harnessEntry = harnessMap(state.snapshot)[harness];
|
||||
if (!harnessEntry) return `${harness} · currently unavailable`;
|
||||
if (!harnessEntry) return verdict(UNAVAILABLE, `${harness} · currently unavailable`);
|
||||
if (!harnessModelsKnown(harnessEntry, state.catalogKnown)) {
|
||||
return `${harness} · model availability not checked`;
|
||||
return verdict(NOT_CHECKED, `${harness} · model availability not checked`);
|
||||
}
|
||||
if (!modelIsPresent(harnessEntry, model)) {
|
||||
return `${harness} · selected model ${model} currently unavailable`;
|
||||
return verdict(UNAVAILABLE, `${harness} · selected model ${model} currently unavailable`);
|
||||
}
|
||||
|
||||
const rows = accountRows(state.snapshot).filter((account) => account.harness === harness);
|
||||
|
|
@ -77,33 +99,93 @@ export function sessionRouteAvailability(row, state, nowMs = Date.now()) {
|
|||
const account = rows.find((candidate) => String(candidate.profile_id || '') === pin);
|
||||
if (!account || account.enabled === false
|
||||
|| String(account?.status?.verification || '') !== 'passed') {
|
||||
return `${harness} · pinned account ${pin} currently unavailable`;
|
||||
return verdict(UNAVAILABLE, `${harness} · pinned account ${pin} currently unavailable`);
|
||||
}
|
||||
if (!state.quotaKnown) return `${harness} · pinned account ready; quota not checked`;
|
||||
if (!state.quotaKnown) return verdict(NOT_CHECKED, `${harness} · pinned account ready; quota not checked`);
|
||||
const quota = routeQuotaFact(state.snapshot, harness, model, pin, nowMs);
|
||||
if (quota.exhausted) return `${harness} · pinned account ${pin} limit reached`;
|
||||
if (!quota.known) return `${harness} · pinned account ready; quota availability not proven`;
|
||||
return `${harness} · available now`;
|
||||
if (quota.exhausted) return verdict(LIMIT, `${harness} · pinned account ${pin} limit reached`);
|
||||
if (!quota.known) return verdict(NOT_CHECKED, `${harness} · pinned account ready; quota availability not proven`);
|
||||
return verdict(AVAILABLE, `${harness} · available now`);
|
||||
}
|
||||
|
||||
if (harnessEntry.enabled === false
|
||||
|| (harnessEntry.status && String(harnessEntry.status) !== 'ok')) {
|
||||
return `${harness} · currently unavailable`;
|
||||
return verdict(UNAVAILABLE, `${harness} · currently unavailable`);
|
||||
}
|
||||
if (!rows.some((account) => account.enabled !== false
|
||||
&& String(account?.status?.verification || '') === 'passed')) {
|
||||
return `${harness} · no usable account currently`;
|
||||
return verdict(NO_ACCOUNT, `${harness} · no usable account currently`);
|
||||
}
|
||||
if (!state.quotaKnown) return `${harness} · account ready; quota not checked`;
|
||||
if (!state.quotaKnown) return verdict(NOT_CHECKED, `${harness} · account ready; quota not checked`);
|
||||
const pool = nextUpAccount(state.snapshot, harness);
|
||||
if (pool?.kind === 'none' || pool?.kind === 'api_key_route') {
|
||||
return `${harness} · no usable subscription account currently`;
|
||||
return verdict(NO_ACCOUNT, `${harness} · no usable subscription account currently`);
|
||||
}
|
||||
if (pool?.kind === 'profile' || pool?.kind === 'native') {
|
||||
return `${harness} · compatible account selected; exact model quota checked at start`;
|
||||
return verdict(AVAILABLE, `${harness} · compatible account selected; exact model quota checked at start`);
|
||||
}
|
||||
const quota = routeQuotaFact(state.snapshot, harness, model, '', nowMs);
|
||||
if (quota.exhausted) return `${harness} · all known accounts reached a limit`;
|
||||
if (quota.known) return `${harness} · available now`;
|
||||
return `${harness} · live availability not checked`;
|
||||
if (quota.exhausted) return verdict(LIMIT, `${harness} · all known accounts reached a limit`);
|
||||
if (quota.known) return verdict(AVAILABLE, `${harness} · available now`);
|
||||
return verdict(NOT_CHECKED, `${harness} · live availability not checked`);
|
||||
}
|
||||
|
||||
// The card head has room for two short words — the intent axis and the
|
||||
// availability axis — with one dot whose tone is the worse of the two; the
|
||||
// full sentence of each axis (plus any model-list gap note) rides the title.
|
||||
// Both axes are structural: the intent word and tone follow the editor's
|
||||
// dirty flag and its baseline (what was loaded: saved bytes, or a generated
|
||||
// draft in the wizard), never a parse of prose; an API model's availability
|
||||
// is only ever known when a child starts, so its second word says exactly
|
||||
// that.
|
||||
const INTENT = {
|
||||
draft: { word: 'Draft', tone: 'neutral', text: 'Draft intent' },
|
||||
generated: { word: 'Generated', tone: 'neutral', text: 'Generated draft' },
|
||||
saved: { word: 'Saved', tone: 'ok', text: 'Saved intent' },
|
||||
};
|
||||
const TONE_RANK = { ok: 0, neutral: 1, warn: 2, error: 3 };
|
||||
const worseTone = (a, b) => (TONE_RANK[b] > TONE_RANK[a] ? b : a);
|
||||
|
||||
function intentAxis(state) {
|
||||
return INTENT[state.dirty ? 'draft' : state.baseline] || INTENT.saved;
|
||||
}
|
||||
|
||||
export function rowStatus(row, state) {
|
||||
const intent = intentAxis(state);
|
||||
if (row.route.kind !== ROUTE_KIND_AGENT_SESSION) {
|
||||
return {
|
||||
label: `${intent.word} · Checked at start`,
|
||||
tone: worseTone(intent.tone, 'neutral'),
|
||||
text: `${intent.text} · API model · availability is checked when a child starts`,
|
||||
};
|
||||
}
|
||||
const live = sessionRouteVerdict(row, state);
|
||||
const { harness } = splitSessionTarget(row.route.target_id);
|
||||
const gap = modelsGapNote(harnessMap(state.snapshot)[harness], state.catalogKnown);
|
||||
return {
|
||||
label: `${intent.word} · ${live.label}`,
|
||||
tone: worseTone(intent.tone, live.tone),
|
||||
text: [`${intent.text} · ${live.text}`, gap].filter(Boolean).join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
const ROUTE_HINT = 'Choose how this subagent runs: an API model or an agent session.';
|
||||
|
||||
function executionFor(snapshot, subagentId) {
|
||||
const receipt = snapshot?.subagent_last_delegation;
|
||||
if (!receipt || typeof receipt !== 'object') return null;
|
||||
return String(receipt.selected_subagent_id || '') === String(subagentId || '')
|
||||
? receipt : null;
|
||||
}
|
||||
|
||||
// ONE meta line under the controls, in priority: the row's own error once the
|
||||
// owner tried to save THIS row (`_uiAttempted`, stamped by the save attempt on
|
||||
// the rows that existed then — an entry added afterwards is fresh again); the
|
||||
// neutral hint while its route is still unchosen (a fresh entry is an
|
||||
// invitation, not an error); the last actual run; nothing.
|
||||
export function rowMeta(row, state, errors) {
|
||||
if (row._uiAttempted && errors.length) return { text: errors[0], tone: 'error' };
|
||||
if (!String(row.route?.target_id || '').trim()) return { text: ROUTE_HINT, tone: '' };
|
||||
const evidence = describeExecutionEvidence(executionFor(state.snapshot, row.subagent_id));
|
||||
return { text: evidence ? `Last actual run: ${evidence}` : '', tone: '' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,10 @@ import {
|
|||
compoundSessionEffortConflict,
|
||||
composeSessionTarget,
|
||||
decodeRouteChoice,
|
||||
describeExecutionEvidence,
|
||||
effortSelectHtml,
|
||||
encodeRouteChoice,
|
||||
indexProfilesByHarness,
|
||||
mintStableId,
|
||||
modelsGapNote,
|
||||
profileOptionsFor,
|
||||
routeChoiceGroups,
|
||||
selectHtml,
|
||||
|
|
@ -35,7 +33,8 @@ import {
|
|||
sessionModelOptions,
|
||||
splitSessionTarget,
|
||||
} from './route_editor_primitives.js';
|
||||
import { sessionRouteAvailability } from './subagent_status_primitives.js';
|
||||
import { harnessMap, rowMeta, rowStatus, sessionRouteVerdict } from './subagent_status_primitives.js';
|
||||
import { revealNewRow } from './ui_helpers.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
|
||||
export const MAX_AVAILABLE_SUBAGENTS = 10;
|
||||
|
|
@ -171,52 +170,60 @@ export function parseAvailableSubagentsSetting(value) {
|
|||
};
|
||||
}
|
||||
|
||||
export function validateAvailableSubagentsSetting(setting) {
|
||||
// One row's owner-facing errors, named the way the card is ("Subagent N").
|
||||
// `ids` accumulates in list order so a repeated stable ID blames the later row;
|
||||
// the list validator and the per-row display read this one source.
|
||||
function rowErrors(row, index, ids) {
|
||||
const errors = [];
|
||||
const id = String(row?.subagent_id || '').trim();
|
||||
if (!SUBAGENT_ID_PATTERN.test(id)) {
|
||||
errors.push('needs a stable ID using letters, numbers, ., _ or - (maximum 64 characters).');
|
||||
} else if (ids.has(id)) {
|
||||
errors.push(`repeats stable ID “${id}”.`);
|
||||
}
|
||||
ids.add(id);
|
||||
const route = row?.route || {};
|
||||
if (![ROUTE_KIND_API_MODEL, ROUTE_KIND_AGENT_SESSION].includes(route.kind)) {
|
||||
errors.push('must use API model or Agent session.');
|
||||
}
|
||||
if (!String(route.target_id || '').trim()) {
|
||||
errors.push('needs a model or agent-session route.');
|
||||
}
|
||||
if (route.kind !== ROUTE_KIND_AGENT_SESSION && route.credential_profile_id) {
|
||||
errors.push('can pin an account only for an Agent session.');
|
||||
}
|
||||
if (route.kind === ROUTE_KIND_AGENT_SESSION) {
|
||||
const target = String(route.target_id || '');
|
||||
const parts = target.split('=');
|
||||
if (/\s|:/.test(target) || parts.length > 2
|
||||
|| !SUBAGENT_ID_PATTERN.test(parts[0] || '')
|
||||
|| (parts.length === 2 && !parts[1])) {
|
||||
errors.push('needs its agent-session target as harness or harness=model, without whitespace or legacy :effort.');
|
||||
}
|
||||
}
|
||||
if (row?.effort && !EFFORT_CHOICES.includes(String(row.effort))) {
|
||||
errors.push('has an unsupported reasoning effort.');
|
||||
}
|
||||
const encodedEffort = route.kind === ROUTE_KIND_AGENT_SESSION
|
||||
? compoundSessionEffortConflict(route.target_id, row?.effort) : '';
|
||||
if (encodedEffort) {
|
||||
errors.push(`effort “${row.effort}” conflicts with compound route effort “${encodedEffort}”.`);
|
||||
}
|
||||
return errors.map((text) => `Subagent ${index + 1} ${text}`);
|
||||
}
|
||||
|
||||
function listLevelErrors(setting) {
|
||||
return setting.items.length > MAX_AVAILABLE_SUBAGENTS
|
||||
? [`Available subagents supports at most ${MAX_AVAILABLE_SUBAGENTS} rows.`] : [];
|
||||
}
|
||||
|
||||
export function validateAvailableSubagentsSetting(setting) {
|
||||
if (!setting || typeof setting.enabled !== 'boolean' || !Array.isArray(setting.items)) {
|
||||
return ['Available subagents configuration is not loaded.'];
|
||||
}
|
||||
if (setting.items.length > MAX_AVAILABLE_SUBAGENTS) {
|
||||
errors.push(`Available subagents supports at most ${MAX_AVAILABLE_SUBAGENTS} rows.`);
|
||||
}
|
||||
const errors = listLevelErrors(setting);
|
||||
const ids = new Set();
|
||||
setting.items.forEach((row, index) => {
|
||||
const label = `Row ${index + 1}`;
|
||||
const id = String(row?.subagent_id || '').trim();
|
||||
if (!SUBAGENT_ID_PATTERN.test(id)) {
|
||||
errors.push(`${label} needs a stable ID using letters, numbers, ., _ or - (maximum 64 characters).`);
|
||||
} else if (ids.has(id)) {
|
||||
errors.push(`${label} repeats stable ID “${id}”.`);
|
||||
}
|
||||
ids.add(id);
|
||||
const route = row?.route || {};
|
||||
if (![ROUTE_KIND_API_MODEL, ROUTE_KIND_AGENT_SESSION].includes(route.kind)) {
|
||||
errors.push(`${label} must use API model or Agent session.`);
|
||||
}
|
||||
if (!String(route.target_id || '').trim()) {
|
||||
errors.push(`${label} needs a model or agent-session route.`);
|
||||
}
|
||||
if (route.kind !== ROUTE_KIND_AGENT_SESSION && route.credential_profile_id) {
|
||||
errors.push(`${label} can pin an account only for an Agent session.`);
|
||||
}
|
||||
if (route.kind === ROUTE_KIND_AGENT_SESSION) {
|
||||
const target = String(route.target_id || '');
|
||||
const parts = target.split('=');
|
||||
if (/\s|:/.test(target) || parts.length > 2
|
||||
|| !SUBAGENT_ID_PATTERN.test(parts[0] || '')
|
||||
|| (parts.length === 2 && !parts[1])) {
|
||||
errors.push(`${label} Agent session must use harness or harness=model without whitespace or legacy :effort.`);
|
||||
}
|
||||
}
|
||||
if (row.effort && !EFFORT_CHOICES.includes(String(row.effort))) {
|
||||
errors.push(`${label} has an unsupported reasoning effort.`);
|
||||
}
|
||||
const encodedEffort = route.kind === ROUTE_KIND_AGENT_SESSION
|
||||
? compoundSessionEffortConflict(route.target_id, row.effort) : '';
|
||||
if (encodedEffort) {
|
||||
errors.push(`${label} effort “${row.effort}” conflicts with compound route effort “${encodedEffort}”.`);
|
||||
}
|
||||
});
|
||||
setting.items.forEach((row, index) => errors.push(...rowErrors(row, index, ids)));
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
|
@ -285,12 +292,6 @@ function diagnosticsText(diagnostics, out = []) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function harnessMap(snapshot) {
|
||||
return Object.fromEntries((snapshot?.harnesses || [])
|
||||
.filter((harness) => harness?.id)
|
||||
.map((harness) => [String(harness.id), harness]));
|
||||
}
|
||||
|
||||
function connectedHarnessIds(snapshot) {
|
||||
return new Set(accountRows(snapshot)
|
||||
.filter((row) => row?.enabled !== false
|
||||
|
|
@ -298,21 +299,6 @@ function connectedHarnessIds(snapshot) {
|
|||
.map((row) => String(row.harness || '')));
|
||||
}
|
||||
|
||||
function executionFor(snapshot, subagentId) {
|
||||
const receipt = snapshot?.subagent_last_delegation;
|
||||
if (!receipt || typeof receipt !== 'object') return null;
|
||||
return String(receipt.selected_subagent_id || '') === String(subagentId || '')
|
||||
? receipt : null;
|
||||
}
|
||||
|
||||
function savedIntentStatus(row, state) {
|
||||
const intent = state.dirty ? 'Draft intent' : (state.baselineLabel || 'Saved intent');
|
||||
if (row.route.kind !== ROUTE_KIND_AGENT_SESSION) {
|
||||
return `${intent} · API model · availability is checked when a child starts`;
|
||||
}
|
||||
return `${intent} · ${sessionRouteAvailability(row, state)}`;
|
||||
}
|
||||
|
||||
function focusSnapshot(host, doc) {
|
||||
const active = doc?.activeElement;
|
||||
if (!active || !host?.contains?.(active)) return null;
|
||||
|
|
@ -358,10 +344,10 @@ export function availableSubagentRowMarkup(row, state, index = 0) {
|
|||
row.route.credential_profile_id || '',
|
||||
{ accountsKnown: state.accountsKnown },
|
||||
);
|
||||
const evidence = describeExecutionEvidence(executionFor(state.snapshot, row.subagent_id));
|
||||
const gap = session ? modelsGapNote(harnesses[split.harness], state.catalogKnown) : '';
|
||||
const meta = [savedIntentStatus(row, state), gap, evidence ? `Last actual run: ${evidence}` : '']
|
||||
.filter(Boolean).join(' · ');
|
||||
const status = rowStatus(row, state);
|
||||
const errors = rowErrors(row, index, new Set());
|
||||
const meta = rowMeta(row, state, errors);
|
||||
const invalid = Boolean(row._uiAttempted) && errors.length > 0;
|
||||
const routeIdentity = session
|
||||
? harnessIdentityMarkup(split.harness, {
|
||||
// A retained snapshot is useful for preserving the controls, but
|
||||
|
|
@ -378,11 +364,18 @@ export function availableSubagentRowMarkup(row, state, index = 0) {
|
|||
className: 'available-subagent-route-identity',
|
||||
});
|
||||
return `
|
||||
<article class="available-subagent-row" data-subagent-row="${escapeHtml(rowKey)}" aria-labelledby="${escapeHtml(headingId)}">
|
||||
<h4 class="available-subagent-heading" id="${escapeHtml(headingId)}">Subagent ${ordinal}</h4>
|
||||
<div class="available-subagent-route-identity-wrap">${routeIdentity}</div>
|
||||
<article class="available-subagent-row" data-subagent-row="${escapeHtml(rowKey)}" aria-labelledby="${escapeHtml(headingId)}"${invalid ? ' data-invalid' : ''}>
|
||||
<div class="available-subagent-head">
|
||||
<h4 class="available-subagent-heading" id="${escapeHtml(headingId)}">Subagent ${ordinal}</h4>
|
||||
<div class="available-subagent-route-identity-wrap">${routeIdentity}</div>
|
||||
<span class="settings-inline-status" data-subagent-status data-tone="${escapeHtml(status.tone)}" title="${escapeHtml(status.text)}">${escapeHtml(status.label)}</span>
|
||||
<div class="available-subagent-actions">
|
||||
<button type="button" class="btn btn-default" data-subagent-duplicate aria-label="Duplicate Subagent ${ordinal}">Duplicate</button>
|
||||
<button type="button" class="btn btn-default" data-subagent-remove aria-label="Remove Subagent ${ordinal}">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="available-subagent-purpose">Description
|
||||
<textarea data-subagent-field="recommended_use" rows="2" aria-label="Description for Subagent ${ordinal}" placeholder="When should Ouroboros choose this subagent?">${escapeHtml(row.recommended_use)}</textarea>
|
||||
<textarea data-subagent-field="recommended_use" rows="1" aria-label="Description for Subagent ${ordinal}" placeholder="When should Ouroboros choose this subagent?">${escapeHtml(row.recommended_use)}</textarea>
|
||||
</label>
|
||||
<div class="available-subagent-route">
|
||||
${selectHtml(`data-subagent-field="route" aria-label="Type for Subagent ${ordinal}"`, routeGroups, encodeRouteChoice(row))}
|
||||
|
|
@ -394,11 +387,7 @@ export function availableSubagentRowMarkup(row, state, index = 0) {
|
|||
: ''}
|
||||
${effortSelectHtml(`data-subagent-field="effort" aria-label="Reasoning effort for Subagent ${ordinal}"`, row.effort || '', 'route default')}
|
||||
</div>
|
||||
<div class="available-subagent-meta">${escapeHtml(meta)}</div>
|
||||
<div class="available-subagent-actions">
|
||||
<button type="button" class="btn btn-default" data-subagent-duplicate aria-label="Duplicate Subagent ${ordinal}">Duplicate</button>
|
||||
<button type="button" class="btn btn-default" data-subagent-remove aria-label="Remove Subagent ${ordinal}">Remove</button>
|
||||
</div>
|
||||
<div class="available-subagent-meta" data-subagent-meta${meta.tone ? ` data-tone="${escapeHtml(meta.tone)}"` : ''} title="${escapeHtml(meta.text)}"${meta.text ? '' : ' hidden'}>${escapeHtml(meta.text)}</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +396,8 @@ export function availableSubagentsRenderSignature(state, nowMs = Date.now()) {
|
|||
state.loaded,
|
||||
state.parseError,
|
||||
state.setting,
|
||||
state.baselineLabel,
|
||||
state.saveAttempted,
|
||||
state.baseline,
|
||||
state.source,
|
||||
diagnosticsText(state.diagnostics),
|
||||
state.statusError,
|
||||
|
|
@ -419,7 +409,7 @@ export function availableSubagentsRenderSignature(state, nowMs = Date.now()) {
|
|||
state.snapshot?.quota || [],
|
||||
state.snapshot?.subagent_last_delegation || null,
|
||||
(state.setting?.items || []).map((row) => row?.route?.kind === ROUTE_KIND_AGENT_SESSION
|
||||
? sessionRouteAvailability(row, state, nowMs) : ''),
|
||||
? sessionRouteVerdict(row, state, nowMs).text : ''),
|
||||
state.apiModels,
|
||||
]);
|
||||
}
|
||||
|
|
@ -432,11 +422,12 @@ export function createAvailableSubagentsEditor({
|
|||
store = claudexorStatus,
|
||||
onChange = () => {},
|
||||
onDirtyChange = () => {},
|
||||
onJudged = () => {},
|
||||
isOuterDraftClean = () => true,
|
||||
onGeneratedApply = () => {},
|
||||
allowUnloadedOmission = false,
|
||||
previewGenerated = null,
|
||||
baselineLabel = 'Saved intent',
|
||||
baseline = 'saved',
|
||||
} = {}) {
|
||||
const getDoc = typeof doc === 'function' ? doc : () => doc;
|
||||
const getWin = typeof win === 'function' ? win : () => win;
|
||||
|
|
@ -448,7 +439,8 @@ export function createAvailableSubagentsEditor({
|
|||
source: '',
|
||||
diagnostics: [],
|
||||
dirty: false,
|
||||
baselineLabel: String(baselineLabel || 'Saved intent'),
|
||||
saveAttempted: false,
|
||||
baseline: baseline === 'generated' ? 'generated' : 'saved',
|
||||
statusError: '',
|
||||
catalogKnown: false,
|
||||
accountsKnown: false,
|
||||
|
|
@ -488,12 +480,56 @@ export function createAvailableSubagentsEditor({
|
|||
return validateAvailableSubagentsSetting(state.setting);
|
||||
}
|
||||
|
||||
// The ONE painter of verdicts, patching in place (never innerHTML, so the
|
||||
// caret survives): every row's head status, error tint and meta line, and
|
||||
// the section-level line — reconciled together, so a fix typed into a
|
||||
// field can never clear one and leave the other red, and a keystroke that
|
||||
// makes the draft dirty (or re-routes a session) shows in the head at
|
||||
// once. The section line says: a load/parse problem always; otherwise the
|
||||
// roster's own errors, only for the rows the owner has tried to save —
|
||||
// until then a fresh entry carries its hint.
|
||||
function renderValidation() {
|
||||
const box = host()?.querySelector?.('[data-subagents-validation]');
|
||||
if (!box) return;
|
||||
const errors = validationErrors();
|
||||
box.hidden = !errors.length;
|
||||
box.textContent = errors[0] || '';
|
||||
const container = host();
|
||||
if (!container) return;
|
||||
const structural = !state.loaded || Boolean(state.parseError);
|
||||
const shown = structural ? validationErrors()
|
||||
: (state.saveAttempted ? listLevelErrors(state.setting) : []);
|
||||
const ids = new Set();
|
||||
state.setting.items.forEach((row, index) => {
|
||||
const rowErrs = state.loaded ? rowErrors(row, index, ids) : [];
|
||||
const judged = Boolean(row._uiAttempted) && rowErrs.length > 0;
|
||||
if (judged && !structural) shown.push(...rowErrs);
|
||||
const el = container.querySelector(`[data-subagent-row="${row._uiKey || row.subagent_id}"]`);
|
||||
if (!el) return;
|
||||
el.toggleAttribute('data-invalid', judged);
|
||||
const status = rowStatus(row, state);
|
||||
const statusEl = el.querySelector('[data-subagent-status]');
|
||||
if (statusEl) {
|
||||
Object.assign(statusEl, { textContent: status.label, title: status.text });
|
||||
statusEl.dataset.tone = status.tone;
|
||||
}
|
||||
const meta = rowMeta(row, state, rowErrs);
|
||||
const metaEl = el.querySelector('[data-subagent-meta]');
|
||||
if (!metaEl) return;
|
||||
Object.assign(metaEl, { hidden: !meta.text, textContent: meta.text, title: meta.text });
|
||||
if (meta.tone) metaEl.dataset.tone = meta.tone;
|
||||
else delete metaEl.dataset.tone;
|
||||
});
|
||||
const box = container.querySelector('[data-subagents-validation]');
|
||||
if (box) Object.assign(box, { hidden: !shown.length, textContent: shown[0] || '' });
|
||||
// The host mirrors this verdict in whatever it said about the roster.
|
||||
if (state.saveAttempted) onJudged(!shown.length);
|
||||
}
|
||||
|
||||
// The Save/Finish button says the owner tried to commit the draft: the rows
|
||||
// that exist now are judged from here on; an entry added later is fresh
|
||||
// again. Everything is already patched in place, so the signature advances
|
||||
// and the next status tick skips the repaint.
|
||||
function noteSaveAttempt() {
|
||||
state.saveAttempted = true;
|
||||
state.setting.items.forEach((row) => { row._uiAttempted = true; });
|
||||
renderValidation();
|
||||
state.signature = availableSubagentsRenderSignature(state);
|
||||
}
|
||||
|
||||
function markDirty({ structural = false } = {}) {
|
||||
|
|
@ -562,6 +598,7 @@ export function createAvailableSubagentsEditor({
|
|||
state.setting.items.splice(state.setting.items.indexOf(row) + 1, 0, copy);
|
||||
markDirty({ structural: true });
|
||||
paint();
|
||||
revealRow(copy._uiKey);
|
||||
});
|
||||
rowElement.querySelector('[data-subagent-remove]')?.addEventListener('click', () => {
|
||||
const index = state.setting.items.indexOf(row);
|
||||
|
|
@ -613,21 +650,30 @@ export function createAvailableSubagentsEditor({
|
|||
container.querySelector('[data-subagent-add]')?.addEventListener('click', () => {
|
||||
if (state.setting.items.length >= MAX_AVAILABLE_SUBAGENTS) return;
|
||||
const id = mintStableId('subagent', state.setting.items.map((row) => row.subagent_id));
|
||||
const uiKey = mintStableId('actor_row', state.setting.items.map((row) => row._uiKey));
|
||||
state.setting.items.push({
|
||||
subagent_id: id,
|
||||
recommended_use: '',
|
||||
route: { kind: ROUTE_KIND_API_MODEL, target_id: '' },
|
||||
_uiKey: mintStableId('actor_row',
|
||||
state.setting.items.map((row) => row._uiKey)),
|
||||
_uiKey: uiKey,
|
||||
});
|
||||
markDirty({ structural: true });
|
||||
paint();
|
||||
revealRow(uiKey);
|
||||
});
|
||||
bindRows(container);
|
||||
restoreFocus(container, focused);
|
||||
renderValidation();
|
||||
return true;
|
||||
}
|
||||
|
||||
// After the repaint (whose last act restores the previous focus): the row
|
||||
// that just appeared is scrolled into view and its Description takes the caret.
|
||||
function revealRow(uiKey) {
|
||||
const row = host()?.querySelector?.(`[data-subagent-row="${uiKey}"]`);
|
||||
revealNewRow(row, row?.querySelector?.('[data-subagent-field="recommended_use"]'));
|
||||
}
|
||||
|
||||
function load(value, { source = '', diagnostics = [], allowOmission = false } = {}) {
|
||||
// Invalidate a preview launched for the previous settings document.
|
||||
// A late response must never overwrite a freshly loaded configured row.
|
||||
|
|
@ -643,6 +689,7 @@ export function createAvailableSubagentsEditor({
|
|||
state.source = String(source || '');
|
||||
state.diagnostics = diagnostics;
|
||||
state.dirty = false;
|
||||
state.saveAttempted = false;
|
||||
state.signature = '';
|
||||
onDirtyChange(false);
|
||||
paint();
|
||||
|
|
@ -667,6 +714,7 @@ export function createAvailableSubagentsEditor({
|
|||
if (canApply) {
|
||||
state.loaded = true;
|
||||
state.parseError = '';
|
||||
state.saveAttempted = false;
|
||||
state.setting = attachUiKeys(parsed.setting, state.setting.items);
|
||||
onDirtyChange(false);
|
||||
onGeneratedApply(buildAvailableSubagentsSetting(state.setting));
|
||||
|
|
@ -787,6 +835,7 @@ export function createAvailableSubagentsEditor({
|
|||
applyGeneratedPreview,
|
||||
setPreviewFailure,
|
||||
validate: validationErrors,
|
||||
noteSaveAttempt,
|
||||
collect: () => availableSubagentsSavePayload(state),
|
||||
get setting() { return buildAvailableSubagentsSetting(state.setting); },
|
||||
get loaded() { return state.loaded; },
|
||||
|
|
@ -893,6 +942,7 @@ export function availableSubagentsHasExplicitDraft(settings) {
|
|||
|
||||
export function initSubagentsSection({
|
||||
onChange,
|
||||
onJudged,
|
||||
isOuterDraftClean,
|
||||
onGeneratedApply,
|
||||
previewGenerated = null,
|
||||
|
|
@ -902,10 +952,9 @@ export function initSubagentsSection({
|
|||
settingsEditor = createAvailableSubagentsEditor({
|
||||
store,
|
||||
onChange: typeof onChange === 'function' ? onChange : () => {},
|
||||
isOuterDraftClean: typeof isOuterDraftClean === 'function'
|
||||
? isOuterDraftClean : () => true,
|
||||
onGeneratedApply: typeof onGeneratedApply === 'function'
|
||||
? onGeneratedApply : () => {},
|
||||
onJudged: typeof onJudged === 'function' ? onJudged : () => {},
|
||||
isOuterDraftClean: typeof isOuterDraftClean === 'function' ? isOuterDraftClean : () => true,
|
||||
onGeneratedApply: typeof onGeneratedApply === 'function' ? onGeneratedApply : () => {},
|
||||
allowUnloadedOmission: true,
|
||||
previewGenerated,
|
||||
});
|
||||
|
|
@ -939,6 +988,11 @@ export function validateSubagentsDraft() {
|
|||
return settingsEditor?.validate() || ['Available subagents editor is not loaded.'];
|
||||
}
|
||||
|
||||
/** Settings' Save button: the draft's own errors become visible from here on. */
|
||||
export function noteSubagentsSaveAttempt() {
|
||||
settingsEditor?.noteSaveAttempt();
|
||||
}
|
||||
|
||||
// Compatibility name retained for focused callers; the signature now covers
|
||||
// the actor list rather than the retired singleton route.
|
||||
export const renderSignature = availableSubagentsRenderSignature;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { apiFetch } from './api_client.js';
|
||||
import { PAGE_ICONS } from './page_icons.js';
|
||||
import { escapeHtmlAttr as escapeHtml } from './utils.js';
|
||||
// Cycle note: toast.js imports normalizeTone from this module. Both edges only
|
||||
// call the imported function inside function bodies (never at module eval), so
|
||||
|
|
@ -129,6 +130,31 @@ export function createSystemMessageAction({ label, onClick, disabled = false, ar
|
|||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one project chip: the bound-task footer in Main (`in project ↗`) and the
|
||||
* whole converted card (`running in background ↗`) share this exact DOM so the
|
||||
* two states of one element cannot drift apart. The icon is the shared Projects
|
||||
* vector (never an emoji); the name is written as text, never as HTML.
|
||||
*/
|
||||
export function renderProjectChip({ name, status, onClick, className = '' } = {}) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = ['chat-live-project-card-btn', className].filter(Boolean).join(' ');
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'chat-live-project-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.innerHTML = PAGE_ICONS.projects;
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'chat-live-project-name';
|
||||
nameEl.textContent = String(name || '');
|
||||
const statusEl = document.createElement('span');
|
||||
statusEl.className = 'chat-live-project-status';
|
||||
statusEl.textContent = String(status || '');
|
||||
btn.append(icon, nameEl, statusEl);
|
||||
if (typeof onClick === 'function') btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function setInlineStatus(el, text, tone = 'muted') {
|
||||
if (!el) return;
|
||||
const next = text || '';
|
||||
|
|
@ -136,6 +162,20 @@ export function setInlineStatus(el, text, tone = 'muted') {
|
|||
el.dataset.tone = normalizeTone(tone);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list editor's freshly added entry is shown where it landed and takes the
|
||||
* caret (docs/DESIGN.md "List editors"). The row is scrolled the SHORTEST
|
||||
* distance into view with no animation — a WebKit shell's smooth scroll would
|
||||
* race the focus below, and a browser test must see the final geometry at once
|
||||
* — and the caller's named field receives focus without a second scroll. Both
|
||||
* arguments are the caller's: every add path knows its row and its first
|
||||
* field, so no heuristic picks one. Detached or stub nodes are tolerated.
|
||||
*/
|
||||
export function revealNewRow(row, field) {
|
||||
row?.scrollIntoView?.({ block: 'nearest' });
|
||||
field?.focus?.({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A packaged launcher answers `{ok:false, error}` when its file bridge refuses
|
||||
* the URL — most often because its allowlist predates the route it was handed,
|
||||
|
|
|
|||
|
|
@ -56,8 +56,12 @@
|
|||
--status-error-bg: var(--red-dim);
|
||||
--status-neutral-fg: var(--text-meta);
|
||||
--status-neutral-bg: rgba(255, 255, 255, 0.06);
|
||||
--status-error-border: rgba(239, 68, 68, 0.30);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--button-padding-y: 8px;
|
||||
--button-padding-x: 16px;
|
||||
--button-font-size: 13px;
|
||||
|
|
@ -939,22 +943,27 @@ textarea:focus {
|
|||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Mirror of the settings.css compact card (docs/DESIGN.md §6 row anatomy):
|
||||
the wizard loads only this sheet. Same rhythm tokens, the wizard's own
|
||||
surface tokens. */
|
||||
.available-subagents-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
margin-top: 12px;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.available-subagents-toolbar,
|
||||
.available-subagent-head,
|
||||
.available-subagent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.available-subagents-toolbar .btn-default { margin-left: auto; }
|
||||
.available-subagents-toolbar .btn-default,
|
||||
.available-subagent-actions { margin-left: auto; }
|
||||
|
||||
.available-subagents-count,
|
||||
.available-subagents-source,
|
||||
|
|
@ -966,32 +975,37 @@ textarea:focus {
|
|||
line-height: var(--line-body);
|
||||
}
|
||||
|
||||
.available-subagents-diagnostics[data-tone="error"] { color: var(--status-error-fg); }
|
||||
.available-subagents-diagnostics[data-tone="error"],
|
||||
.available-subagent-meta[data-tone="error"] { color: var(--status-error-fg); }
|
||||
|
||||
.available-subagents-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.available-subagent-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
|
||||
/* An entry the owner tried to finish with an error is emphasised, not dimmed. */
|
||||
.available-subagent-row[data-invalid] {
|
||||
border-color: var(--status-error-border);
|
||||
background: var(--status-error-bg);
|
||||
}
|
||||
|
||||
.available-subagent-route {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.available-subagent-route { grid-template-columns: repeat(4, minmax(120px, 1fr)); }
|
||||
|
||||
.available-subagent-heading {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
|
|
@ -999,6 +1013,13 @@ textarea:focus {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.available-subagent-head .settings-inline-status {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Standalone mirror of style.css: onboarding does not load the app sheet. */
|
||||
.harness-identity {
|
||||
display: inline-flex;
|
||||
|
|
@ -1034,18 +1055,28 @@ textarea:focus {
|
|||
.available-subagent-purpose {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-meta);
|
||||
font-size: var(--type-meta);
|
||||
}
|
||||
|
||||
/* One line that grows with its text up to four, then scrolls (`field-sizing`);
|
||||
a shell without it keeps the one-line field and manual resize. */
|
||||
.available-subagent-purpose textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
field-sizing: content;
|
||||
line-height: var(--line-body);
|
||||
min-height: calc(var(--type-body) * var(--line-body) + 22px);
|
||||
max-height: calc(4 * var(--type-body) * var(--line-body) + 22px);
|
||||
overflow-y: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.available-subagent-actions { justify-content: flex-end; }
|
||||
.available-subagent-meta {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.available-subagent-actions .btn-default {
|
||||
min-height: 34px;
|
||||
|
|
@ -1184,6 +1215,7 @@ textarea:focus {
|
|||
color: var(--status-neutral-fg);
|
||||
}
|
||||
|
||||
.settings-inline-status[data-tone="neutral"] { color: var(--status-neutral-fg); }
|
||||
.settings-inline-status[data-tone="ok"] { color: var(--status-ok-fg); }
|
||||
.settings-inline-status[data-tone="warn"] { color: var(--status-warn-fg); }
|
||||
.settings-inline-status[data-tone="error"] { color: var(--status-error-fg); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ouroboros-web",
|
||||
"version": "6.113.5",
|
||||
"version": "6.114.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Ouroboros browser UI package boundary",
|
||||
|
|
|
|||
|
|
@ -521,19 +521,22 @@
|
|||
.available-subagents-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.available-subagents-toolbar,
|
||||
.available-subagent-head,
|
||||
.available-subagent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.available-subagents-toolbar .btn-default {
|
||||
/* The section's Add and the card's own actions dock right (docs/DESIGN.md §6). */
|
||||
.available-subagents-toolbar .btn-default,
|
||||
.available-subagent-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
|
@ -542,39 +545,45 @@
|
|||
.available-subagents-diagnostics,
|
||||
.available-subagents-empty,
|
||||
.available-subagent-meta {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-meta);
|
||||
font-size: var(--type-meta);
|
||||
line-height: var(--line-body);
|
||||
}
|
||||
|
||||
.available-subagents-diagnostics[data-tone="error"] {
|
||||
.available-subagents-diagnostics[data-tone="error"],
|
||||
.available-subagent-meta[data-tone="error"] {
|
||||
color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.available-subagents-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Compact card (docs/DESIGN.md §6 row anatomy): head → Description → route
|
||||
controls → one meta line. Inner rhythm is one space token and the card
|
||||
padding three, so three cards fit a laptop-height Settings body. */
|
||||
.available-subagent-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-card-soft);
|
||||
}
|
||||
|
||||
/* An entry the owner tried to save with an error is emphasised, not dimmed. */
|
||||
.available-subagent-row[data-invalid] {
|
||||
border-color: var(--status-error-border);
|
||||
background: var(--status-error-bg);
|
||||
}
|
||||
|
||||
.available-subagent-route {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.available-subagent-route {
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.available-subagent-heading {
|
||||
|
|
@ -584,22 +593,39 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Two short words on one line; the full sentence is the status's title. */
|
||||
.available-subagent-head .settings-inline-status {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.available-subagent-purpose {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--space-1);
|
||||
color: var(--text-meta);
|
||||
font-size: var(--type-meta);
|
||||
}
|
||||
|
||||
/* One line that grows with its text up to four, then scrolls: the browser
|
||||
sizes it (`field-sizing`); a shell without that property keeps the one-line
|
||||
field and manual resize. 22px is the control's own padding and border. */
|
||||
.available-subagent-purpose textarea {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
field-sizing: content;
|
||||
line-height: var(--line-body);
|
||||
min-height: calc(var(--type-body) * var(--line-body) + 22px);
|
||||
max-height: calc(4 * var(--type-body) * var(--line-body) + 22px);
|
||||
overflow-y: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.available-subagent-actions {
|
||||
justify-content: flex-end;
|
||||
.available-subagent-meta {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.available-subagent-actions .btn-default {
|
||||
|
|
|
|||
119
web/style.css
119
web/style.css
|
|
@ -383,7 +383,7 @@ body.resizing-panels { user-select: none; }
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
padding: 0 14px var(--space-2);
|
||||
}
|
||||
|
||||
/* S3 (Q2/HQ1): the three-action task stop/hurry dropdown (Chat + Activity). */
|
||||
|
|
@ -467,7 +467,7 @@ body.resizing-panels { user-select: none; }
|
|||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
|
@ -482,11 +482,24 @@ body.resizing-panels { user-select: none; }
|
|||
background: var(--project-12);
|
||||
}
|
||||
|
||||
/* The card clips its children, so the ring sits inside (the sanctioned variant). */
|
||||
.chat-live-project-card-btn:focus-visible {
|
||||
outline: 2px solid var(--focus-accent-border);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-live-project-icon {
|
||||
display: inline-flex;
|
||||
color: var(--project);
|
||||
font-size: var(--type-section);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-live-project-icon > svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.chat-live-project-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
|
@ -496,7 +509,7 @@ body.resizing-panels { user-select: none; }
|
|||
}
|
||||
|
||||
.chat-live-project-status {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
flex: 0 0 auto;
|
||||
color: var(--project);
|
||||
font-size: var(--type-meta);
|
||||
|
|
@ -509,6 +522,13 @@ body.resizing-panels { user-select: none; }
|
|||
background: var(--project-08);
|
||||
}
|
||||
|
||||
/* On a task card the task title stays the one primary thing; the pointer's
|
||||
project name steps down to meta ink. */
|
||||
.chat-live-bound-pointer .chat-live-project-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1804,8 +1824,11 @@ body.resizing-panels { user-select: none; }
|
|||
.chat-live-card {
|
||||
align-self: flex-start;
|
||||
width: min(100%, 760px);
|
||||
max-width: calc(100% - 20%);
|
||||
margin-inline-end: 20%;
|
||||
/* The 80% gutter holds only while 80% is still >= 620px; below that the card
|
||||
takes up to 620px, the chatcol breakpoint, so the width stays monotonic
|
||||
across it and the internal narrow regime (<= 560px) starts only when the
|
||||
COLUMN itself is narrow. */
|
||||
max-width: max(80%, min(100%, 620px));
|
||||
padding: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
|
|
@ -1833,32 +1856,41 @@ body.resizing-panels { user-select: none; }
|
|||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 11px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The card clips overflow, so the ring is the sanctioned inset variant. */
|
||||
.chat-live-summary-button:focus-visible {
|
||||
outline: 2px solid var(--focus-accent-border);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-live-summary-button:hover {
|
||||
background: var(--accent-05);
|
||||
}
|
||||
|
||||
.chat-live-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* Chip + typing dots only; the title is their sibling so a narrow container can
|
||||
reorder it under the side controls. */
|
||||
.chat-live-summary-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-live-summary-side {
|
||||
|
|
@ -1866,6 +1898,8 @@ body.resizing-panels { user-select: none; }
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* One title line (title type size, not the inherited body size). */
|
||||
min-height: calc(1.45 * var(--type-body));
|
||||
}
|
||||
|
||||
.chat-live-phase,
|
||||
|
|
@ -1949,8 +1983,8 @@ body.resizing-panels { user-select: none; }
|
|||
|
||||
.chat-live-title {
|
||||
font-size: var(--type-body);
|
||||
font-weight: 400;
|
||||
color: var(--text-meta);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.45;
|
||||
|
|
@ -1969,8 +2003,10 @@ body.resizing-panels { user-select: none; }
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* One reserved title line (a coined two-line name still clamps to two; the
|
||||
stable-viewport seam absorbs that one-line growth). */
|
||||
.chat-live-card:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-title {
|
||||
min-height: 2.9em;
|
||||
min-height: 1.45em;
|
||||
}
|
||||
|
||||
.chat-live-card:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-activity {
|
||||
|
|
@ -1978,11 +2014,13 @@ body.resizing-panels { user-select: none; }
|
|||
}
|
||||
|
||||
/* Root activity remains visually quiet until naming; collapsed cards still
|
||||
reserve its two-line band. Anywhere-wrap protects narrow cards and URLs. */
|
||||
reserve its two-line band while running (a finished card folds an empty
|
||||
band, below). Anywhere-wrap protects narrow cards and URLs. */
|
||||
.chat-live-activity {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-meta);
|
||||
font-size: var(--type-meta);
|
||||
line-height: 1.4;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -1996,8 +2034,14 @@ body.resizing-panels { user-select: none; }
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* A finished card receives no more narration, so an empty band is dead air:
|
||||
it folds, and the one-time height change lands with the finish re-render. */
|
||||
.chat-live-card[data-finished="1"]:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-activity:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-live-count {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--type-meta);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -2009,7 +2053,7 @@ body.resizing-panels { user-select: none; }
|
|||
}
|
||||
|
||||
.chat-live-chevron {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-disabled);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
|
|
@ -2021,12 +2065,15 @@ body.resizing-panels { user-select: none; }
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-disabled);
|
||||
font-size: var(--type-meta);
|
||||
line-height: var(--line-meta);
|
||||
}
|
||||
|
||||
.chat-live-card:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-meta {
|
||||
min-height: 1.35em;
|
||||
font-size: var(--type-meta);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chat-live-meta-text {
|
||||
|
|
@ -2040,6 +2087,10 @@ body.resizing-panels { user-select: none; }
|
|||
color: var(--text-meta);
|
||||
font-size: var(--type-meta);
|
||||
line-height: var(--line-meta);
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
align-self: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-live-reviews-host] > .chat-live-reviews {
|
||||
|
|
@ -2245,7 +2296,6 @@ body.resizing-panels { user-select: none; }
|
|||
.chat-live-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2262,16 +2312,17 @@ body.resizing-panels { user-select: none; }
|
|||
}
|
||||
|
||||
.chat-live-summary-main {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-live-title {
|
||||
order: 3;
|
||||
flex: 1 1 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-live-summary-side {
|
||||
order: 2;
|
||||
margin-inline-start: auto;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -2318,7 +2369,6 @@ body.resizing-panels { user-select: none; }
|
|||
.chat-live-card.subagent {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-inline-end: 0;
|
||||
border-color: var(--accent-18);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 200, 210, 0.03);
|
||||
|
|
@ -2370,6 +2420,11 @@ body.resizing-panels { user-select: none; }
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-live-line-toggle:focus-visible {
|
||||
outline: 2px solid var(--focus-accent-border);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-live-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -3300,6 +3355,7 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
|
|||
/* `muted` is a tone the code emits but no rule named. */
|
||||
.ui-status[data-tone="neutral"],
|
||||
.ui-status[data-tone="muted"],
|
||||
.settings-inline-status[data-tone="neutral"],
|
||||
.settings-inline-status[data-tone="muted"],
|
||||
.marketplace-status[data-tone="muted"] { color: var(--status-neutral-fg); }
|
||||
|
||||
|
|
@ -6378,17 +6434,26 @@ textarea.chat-input {
|
|||
/* === design-system:migrated-begin (harness accounts + reviewer slots + Dashboard Updates tab) ===
|
||||
Down to the matching end marker: docs/DESIGN.md tokens only, guarded by
|
||||
tests/test_web_typography_static.py. The rest of this file is a later pass. */
|
||||
/* Subsection rhythm: the section's flex gap (14px) is the baseline; the small
|
||||
negative bottom margins pull a heading and its Add toolbar to 8px from the
|
||||
rows they belong to, so heading+rows+toolbar reads as one group. */
|
||||
/* Subsection rhythm: a group is its head (heading + that group's own Add
|
||||
action, docked right — docs/DESIGN.md "List editors") followed by its rows,
|
||||
one space token apart inside the group; the section's own flex gap keeps
|
||||
the groups apart, so a heading always sits nearer its rows than its
|
||||
neighbour — structure, not negative margins. */
|
||||
.reviewer-slots-group { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.reviewer-slots-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.reviewer-slots-heading {
|
||||
margin: 10px 0 -6px;
|
||||
margin: 0;
|
||||
font-size: var(--type-body);
|
||||
line-height: var(--line-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.reviewer-slot-rows { display: flex; flex-direction: column; gap: 8px; margin-bottom: -6px; }
|
||||
.reviewer-slot-rows { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.reviewer-slot-row {
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import { readFileSync } from 'node:fs';
|
|||
|
||||
import { createChatInstance } from '../modules/chat.js';
|
||||
|
||||
const chatSource = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
||||
const styleSource = readFileSync(new URL('../style.css', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
||||
// Source pins below match across line breaks; normalize CRLF so a Windows
|
||||
// checkout (core.autocrlf) reads the same bytes the regexes were written for.
|
||||
const chatSource = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8')
|
||||
.replace(/\r\n?/g, '\n');
|
||||
const styleSource = readFileSync(new URL('../style.css', import.meta.url), 'utf8')
|
||||
.replace(/\r\n?/g, '\n');
|
||||
|
||||
// --- DOM harness (same stub family as chat_instance_dom.test.js) ---
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
projectCollapsedActivity,
|
||||
} from '../modules/chat.js';
|
||||
import { summarizeChatLiveEvent } from '../modules/log_events.js';
|
||||
import { plainActivityText } from '../modules/chat_activity.js';
|
||||
|
||||
test('named root card shows the latest activity headline under the coined title', () => {
|
||||
assert.equal(projectCollapsedActivity({
|
||||
|
|
@ -66,7 +67,7 @@ test('whitespace-only frames fall back to the previous activity', () => {
|
|||
test('clearStickyCardState resets the recycled record activity + cost (reusable slots)', () => {
|
||||
const record = {
|
||||
collapsedActivity: 'Old cycle activity',
|
||||
costMeta: { meta: ['cost=$1.00'], ts: 1, final: true },
|
||||
costMeta: { meta: ['$1.00'], ts: 1, final: true },
|
||||
executorChip: { harness: 'codex', icon: '◇', label: 'codex · no run yet' },
|
||||
// Models the real element closely enough for attribute handling.
|
||||
activityEl: {
|
||||
|
|
@ -144,3 +145,24 @@ test('subagent projection keeps identity, compact facts and complete disclosure'
|
|||
assert.deepEqual(summary.meta, ['write=workspace', 'status=running']);
|
||||
assert.doesNotMatch(summary.meta.join(' '), /subagent|role=|parent=|root=/);
|
||||
});
|
||||
|
||||
test('the collapsed activity line is plain text: the renderer\'s markdown inventory', () => {
|
||||
// The expanded timeline renders the same headline through renderMarkdown, so
|
||||
// the compact line strips that marker inventory (line by line, over-strip
|
||||
// preferred to a leaked marker).
|
||||
assert.equal(plainActivityText('**Planning a network update** I need `git fetch`'),
|
||||
'Planning a network update I need git fetch');
|
||||
assert.equal(plainActivityText('### Title\n- one\n- two [link](http://x)'), 'Title\none\ntwo link');
|
||||
assert.equal(plainActivityText('~~old~~ *new*'), 'old new');
|
||||
assert.equal(plainActivityText('```js\nlet a = 1;\n```'), 'let a = 1;');
|
||||
assert.equal(boundActivityPreview('| a | b |\n|---|---|\n| 1 | 2 |'), 'a b 1 2');
|
||||
// Markers-only text keeps its source: an empty projection would flip the
|
||||
// reserved activity band's :empty rules on the card.
|
||||
assert.equal(plainActivityText('---'), '---');
|
||||
// Whitespace-only narration projects to nothing: the band's :empty rules
|
||||
// (reserve while running, fold when finished) need a truly empty node.
|
||||
assert.equal(boundActivityPreview(' \n\t '), '');
|
||||
assert.equal(plainActivityText(''), '');
|
||||
// Composition: the bound preview is built on the plain projection.
|
||||
assert.equal(boundActivityPreview(' **Reading**\n the ledger '), 'Reading the ledger');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,22 +42,27 @@ test('task cards distinguish unavailable, pending zero, and final zero', () => {
|
|||
cost_final: false,
|
||||
}), ['cost unavailable']);
|
||||
|
||||
// The producer's amount is already the upper bound (settled + reserved +
|
||||
// unresolved, cost_projection.py); the openness fields ride beside it and the
|
||||
// card states the one number as a ceiling while the ledger is open.
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 0,
|
||||
cost_usd: 1.75,
|
||||
cost_accounting_status: 'available',
|
||||
cost_final: false,
|
||||
reserved_usd: 1.25,
|
||||
unresolved_upper_bound_usd: 0.5,
|
||||
}), ['cost=$0.00 (pending)', 'reserved=$1.25', 'unresolved≤$0.50']);
|
||||
}), ['up to $1.75']);
|
||||
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 0,
|
||||
cost_accounting_status: 'available',
|
||||
cost_final: true,
|
||||
}), ['cost=$0.00']);
|
||||
}), ['$0.00']);
|
||||
});
|
||||
|
||||
test('compact task cards show one complete cost and keep openness details', () => {
|
||||
test('compact task cards show ONE amount whose wording carries the openness', () => {
|
||||
// Calls with no known price are not named on the card (owner: no counter);
|
||||
// the open ledger still reads as a ceiling.
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 55.86,
|
||||
cost_usd_with_children: 76.82,
|
||||
|
|
@ -67,17 +72,22 @@ test('compact task cards show one complete cost and keep openness details', () =
|
|||
reserved_usd: 1.25,
|
||||
unresolved_upper_bound_usd: 0.5,
|
||||
unknown_unmetered: 2,
|
||||
}), [
|
||||
'cost=$76.82 (pending)',
|
||||
'reserved=$1.25',
|
||||
'unresolved≤$0.50',
|
||||
'unmetered=2',
|
||||
]);
|
||||
}), ['up to $76.82']);
|
||||
// Every call priced, ledger still open: a ceiling.
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 55.86,
|
||||
cost_usd_with_children: 76.82,
|
||||
cost_accounting_status: 'available',
|
||||
cost_final: false,
|
||||
cost_with_children_partial: true,
|
||||
reserved_usd: 1.25,
|
||||
unresolved_upper_bound_usd: 0.5,
|
||||
}), ['up to $76.82']);
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 4.25,
|
||||
cost_accounting_status: 'available',
|
||||
cost_final: true,
|
||||
}), ['cost=$4.25']);
|
||||
}), ['$4.25']);
|
||||
|
||||
const partialChild = taskCostProjection({
|
||||
cost_usd: 4.25,
|
||||
|
|
@ -86,7 +96,7 @@ test('compact task cards show one complete cost and keep openness details', () =
|
|||
cost_final: true,
|
||||
cost_with_children_partial: true,
|
||||
}, '2026-07-29T00:00:00Z');
|
||||
assert.deepEqual(partialChild.meta, ['cost=$6.50 (pending)']);
|
||||
assert.deepEqual(partialChild.meta, ['up to $6.50']);
|
||||
assert.equal(partialChild.final, false);
|
||||
});
|
||||
|
||||
|
|
@ -98,13 +108,13 @@ test('unknown zero-dollar accounting stays pending instead of becoming free', ()
|
|||
cost_final: false,
|
||||
cost_with_children_partial: true,
|
||||
unknown_unmetered: 1,
|
||||
}), ['cost pending', 'unmetered=1']);
|
||||
}), ['cost pending']);
|
||||
assert.deepEqual(taskCostMeta({
|
||||
cost_usd: 0,
|
||||
cost_accounting_status: 'available',
|
||||
cost_final: false,
|
||||
unknown_unmetered: 1,
|
||||
}), ['cost pending', 'unmetered=1']);
|
||||
}), ['cost pending']);
|
||||
});
|
||||
|
||||
test('a bare per-round cost_usd delta is NOT task cost (v6.82 P1)', () => {
|
||||
|
|
@ -118,7 +128,7 @@ test('a bare per-round cost_usd delta is NOT task cost (v6.82 P1)', () => {
|
|||
cost_accounting_status: 'available',
|
||||
cost_final: false,
|
||||
}, '2026-07-29T00:00:00Z');
|
||||
assert.deepEqual(projection.meta, ['cost=$0.12 (pending)']);
|
||||
assert.deepEqual(projection.meta, ['up to $0.12']);
|
||||
assert.equal(projection.final, false);
|
||||
assert.equal(projection.ts, Date.parse('2026-07-29T00:00:00Z'));
|
||||
});
|
||||
|
|
@ -276,13 +286,13 @@ test('an unavailable snapshot is sticky but never pins the card (v6.82 r2)', ()
|
|||
assert.equal(mergeStickyCostMeta(settled, unavailable), settled);
|
||||
});
|
||||
|
||||
test('a cost-only frame never moves the card’s Latest clock', () => {
|
||||
// "Latest" answers "when did this task last DO something". A cost frame carries
|
||||
test('a cost-only frame never moves the card’s activity clock', () => {
|
||||
// "updated" answers "when did this task last DO something". A cost frame carries
|
||||
// no narration, so letting it move the clock would make a silent card look
|
||||
// freshly active. Pinned at source: the meta line reads the activity clock, and
|
||||
// only a human/activity-bearing frame advances it.
|
||||
const source = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /record\.latestActivityTs \? `Latest \$\{record\.latestActivityTs\}`/);
|
||||
assert.match(source, /record\.latestActivityTs \? `updated \$\{record\.latestActivityTs\}`/);
|
||||
assert.match(source, /if \(ts && \(summary\.human \|\| activityCandidate\)\) record\.latestActivityTs = ts/);
|
||||
});
|
||||
|
||||
|
|
@ -295,7 +305,7 @@ test('one precedence rule: the deprecated alias wins a diverged pair, in every r
|
|||
cost_accounting_status: 'available', cost_final: true,
|
||||
};
|
||||
assert.equal(accountedUpperBound(diverged), 1);
|
||||
assert.deepEqual(taskCostMeta(diverged), ['cost=$1.00']);
|
||||
assert.deepEqual(taskCostMeta(diverged), ['$1.00']);
|
||||
// The additive name alone still reads (a producer that only writes it).
|
||||
assert.equal(accountedUpperBound({ accounted_upper_bound_usd: 9 }), 9);
|
||||
assert.equal(accountedUpperBound({}), null);
|
||||
|
|
|
|||
61
web/tests/project_chip.test.js
Normal file
61
web/tests/project_chip.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PAGE_ICONS } from '../modules/page_icons.js';
|
||||
import { renderProjectChip } from '../modules/ui_helpers.js';
|
||||
|
||||
// Minimal element stub: the chip is built with createElement/append/textContent
|
||||
// only, so a flat stub proves the DOM contract without a browser.
|
||||
class NodeStub {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = new Map();
|
||||
this.className = '';
|
||||
this.innerHTML = '';
|
||||
this._text = '';
|
||||
}
|
||||
set textContent(value) { this._text = String(value ?? ''); }
|
||||
get textContent() { return this._text; }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
addEventListener(type, fn) { this.listeners.set(type, fn); }
|
||||
}
|
||||
|
||||
function withDocument(fn) {
|
||||
const prior = globalThis.document;
|
||||
globalThis.document = { createElement: (tag) => new NodeStub(tag) };
|
||||
try { return fn(); } finally { globalThis.document = prior; }
|
||||
}
|
||||
|
||||
test('the project chip is one DOM contract for the bound footer and the converted card', () => {
|
||||
withDocument(() => {
|
||||
let clicks = 0;
|
||||
const footer = renderProjectChip({
|
||||
name: 'OpenClaw 2.0 <b>x</b>', status: 'in project ↗',
|
||||
className: 'chat-live-bound-pointer', onClick: () => { clicks += 1; },
|
||||
});
|
||||
assert.equal(footer.tagName, 'BUTTON');
|
||||
assert.equal(footer.type, 'button');
|
||||
assert.equal(footer.className, 'chat-live-project-card-btn chat-live-bound-pointer');
|
||||
const [icon, name, status] = footer.children;
|
||||
// Vector icon from the shared Projects glyph, decorative, never an emoji.
|
||||
assert.equal(icon.className, 'chat-live-project-icon');
|
||||
assert.equal(icon.attributes['aria-hidden'], 'true');
|
||||
assert.equal(icon.innerHTML, PAGE_ICONS.projects);
|
||||
assert.match(icon.innerHTML, /^<svg /);
|
||||
// The name is text: a project name can never inject markup.
|
||||
assert.equal(name.className, 'chat-live-project-name');
|
||||
assert.equal(name.textContent, 'OpenClaw 2.0 <b>x</b>');
|
||||
assert.equal(status.className, 'chat-live-project-status');
|
||||
assert.equal(status.textContent, 'in project ↗');
|
||||
footer.listeners.get('click')();
|
||||
assert.equal(clicks, 1);
|
||||
|
||||
const converted = renderProjectChip({ name: 'P', status: 'running in background ↗' });
|
||||
assert.equal(converted.className, 'chat-live-project-card-btn');
|
||||
assert.equal(converted.children[2].textContent, 'running in background ↗');
|
||||
assert.equal(converted.listeners.size, 0, 'no handler is attached without onClick');
|
||||
});
|
||||
});
|
||||
|
|
@ -89,6 +89,27 @@ test('the standing note states the POLICY, never the current routing', () => {
|
|||
});
|
||||
|
||||
|
||||
test('each group carries its own Add action in its head, above the rows it adds to', () => {
|
||||
// docs/DESIGN.md "List editors": a group's add action lives in its head,
|
||||
// never in a footer toolbar under the rows, and the new row lands at the
|
||||
// group's end, revealed by `revealNewRow`.
|
||||
const markup = renderReviewerSlotsSection();
|
||||
for (const [head, rows, button] of [
|
||||
['Triad slots', 'reviewer-triad-rows', 'btn-add-triad-slot'],
|
||||
['Scope slots', 'reviewer-scope-rows', 'btn-add-scope-slot'],
|
||||
]) {
|
||||
const headingAt = markup.indexOf(`class="reviewer-slots-heading">${head}`);
|
||||
assert.ok(headingAt > 0, `${head} heading exists`);
|
||||
const headOpen = markup.lastIndexOf('<div class="reviewer-slots-head">', headingAt);
|
||||
const headClose = markup.indexOf('</div>', headingAt);
|
||||
assert.ok(headOpen > 0 && headOpen < headingAt, `${head} heading sits inside a group head`);
|
||||
assert.match(markup.slice(headOpen, headClose), new RegExp(`id="${button}"`),
|
||||
`${button} lives in the ${head} head`);
|
||||
assert.ok(headClose < markup.indexOf(`id="${rows}"`), `${head} head precedes its rows`);
|
||||
}
|
||||
assert.doesNotMatch(markup, /settings-toolbar/, 'no footer Add toolbar remains');
|
||||
});
|
||||
|
||||
test('a saved account pin survives a discovery list that no longer contains it', () => {
|
||||
// The select's value must EXIST as an option or the browser silently selects the
|
||||
// first one — "automatic rotation" — so a row pinned to one account redrew as
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import {
|
|||
validateAvailableSubagentsSetting,
|
||||
} from '../modules/subagents_settings.js';
|
||||
import { buildReviewerSlotsSetting } from '../modules/reviewer_slots.js';
|
||||
import { sessionRouteVerdict } from '../modules/subagent_status_primitives.js';
|
||||
import { revealNewRow } from '../modules/ui_helpers.js';
|
||||
|
||||
const CONTRACT_FIXTURE = JSON.parse(fs.readFileSync(
|
||||
new URL('./fixtures/available_subagents_contract.json', import.meta.url),
|
||||
|
|
@ -489,7 +491,7 @@ test('session render signature follows account-pool routing verdict changes', ()
|
|||
target_id: 'codex=gpt-5.6-sol-high',
|
||||
credential_profile_id: '',
|
||||
},
|
||||
})]), baselineLabel: 'Saved intent',
|
||||
})]), baseline: 'saved',
|
||||
source: 'configured', diagnostics: [], statusError: '', catalogKnown: true,
|
||||
accountsKnown: true, quotaKnown: true, apiModels: [],
|
||||
snapshot: {
|
||||
|
|
@ -516,7 +518,7 @@ test('session render signature follows account-pool routing verdict changes', ()
|
|||
test('session render signature expires a cooldown without a changed payload', () => {
|
||||
const cooldownUntil = Date.parse('2030-01-01T00:00:00Z');
|
||||
const state = {
|
||||
loaded: true, parseError: '', setting: setting(), baselineLabel: 'Saved intent',
|
||||
loaded: true, parseError: '', setting: setting(), baseline: 'saved',
|
||||
source: 'configured', diagnostics: [], statusError: '', catalogKnown: true,
|
||||
accountsKnown: true, quotaKnown: true, apiModels: [],
|
||||
snapshot: {
|
||||
|
|
@ -663,3 +665,121 @@ test('Settings section keeps global task-authority controls beside the actor lis
|
|||
assert.match(html, /id="s-subagent-projects-root"/);
|
||||
assert.doesNotMatch(html, /chooses one by its stable ID/);
|
||||
});
|
||||
|
||||
test('revealNewRow scrolls the shortest distance and focuses the named field without a second scroll', () => {
|
||||
// docs/DESIGN.md "List editors": a freshly added entry is scrolled into
|
||||
// view without animation and takes the caret. Both arguments are the
|
||||
// caller's; a stub or detached node without the DOM methods is tolerated.
|
||||
const calls = [];
|
||||
const row = { scrollIntoView: (opts) => calls.push(['scroll', opts]) };
|
||||
const field = { focus: (opts) => calls.push(['focus', opts]) };
|
||||
revealNewRow(row, field);
|
||||
assert.deepEqual(calls, [
|
||||
['scroll', { block: 'nearest' }],
|
||||
['focus', { preventScroll: true }],
|
||||
]);
|
||||
assert.doesNotThrow(() => revealNewRow({}, null));
|
||||
assert.doesNotThrow(() => revealNewRow(null, {}));
|
||||
});
|
||||
|
||||
const QUIET_STATE = Object.freeze({
|
||||
snapshot: null, catalogKnown: false, accountsKnown: false, quotaKnown: false,
|
||||
dirty: false, baseline: 'saved', saveAttempted: false,
|
||||
});
|
||||
|
||||
test('the card head carries the ordinal, the route mark, a two-word status and the actions', () => {
|
||||
// docs/DESIGN.md §6 row anatomy on the compact card: one primary thing (the
|
||||
// ordinal), the harness mark, a dot + short words for the two status axes
|
||||
// (intent · availability, full sentences in the title), actions docked right.
|
||||
const html = availableSubagentRowMarkup(sessionRow(), QUIET_STATE, 2);
|
||||
const head = html.slice(html.indexOf('available-subagent-head'), html.indexOf('available-subagent-purpose'));
|
||||
assert.match(head, /class="available-subagent-heading"[^>]*>Subagent 3</);
|
||||
assert.match(head, /available-subagent-route-identity-wrap/);
|
||||
assert.match(head, /class="settings-inline-status" data-subagent-status data-tone="neutral" title="Saved intent · Agent session · live availability not checked">Saved · Not checked</);
|
||||
assert.match(head, /data-subagent-duplicate/);
|
||||
assert.match(head, /data-subagent-remove/);
|
||||
assert.match(html, /<textarea data-subagent-field="recommended_use" rows="1"/);
|
||||
assert.equal((html.match(/<textarea/g) || []).length, 1);
|
||||
// A routed row with no run evidence carries no meta band at all.
|
||||
assert.match(html, /data-subagent-meta[^>]*hidden/);
|
||||
assert.doesNotMatch(html, /data-invalid/);
|
||||
// An API model's availability is only known when a child starts: the
|
||||
// second word says that instead of repeating the route mark beside it.
|
||||
const api = availableSubagentRowMarkup(apiRow(), { ...QUIET_STATE, dirty: true }, 0);
|
||||
assert.match(api, /data-tone="neutral" title="Draft intent · API model · availability is checked when a child starts">Draft · Checked at start</);
|
||||
});
|
||||
|
||||
test('a fresh row invites instead of erroring until the owner tries to save', () => {
|
||||
const fresh = {
|
||||
subagent_id: 'subagent_new', recommended_use: '',
|
||||
route: { kind: ROUTE_KIND_API_MODEL, target_id: '' },
|
||||
};
|
||||
const before = availableSubagentRowMarkup(fresh, QUIET_STATE, 3);
|
||||
assert.doesNotMatch(before, /data-invalid/);
|
||||
assert.doesNotMatch(before, /data-tone="error"/);
|
||||
assert.match(before, /data-subagent-meta[^>]*>Choose how this subagent runs: an API model or an agent session\.</);
|
||||
|
||||
// A save attempt judges the rows that existed then (`_uiAttempted`) …
|
||||
const judged = availableSubagentRowMarkup({ ...fresh, _uiAttempted: true }, { ...QUIET_STATE, saveAttempted: true }, 3);
|
||||
assert.match(judged, /<article[^>]*data-invalid/);
|
||||
assert.match(judged, /data-subagent-meta data-tone="error"[^>]*>Subagent 4 needs a model or agent-session route\.</);
|
||||
// … while an entry added AFTER that attempt is an invitation again.
|
||||
const later = availableSubagentRowMarkup(fresh, { ...QUIET_STATE, saveAttempted: true }, 4);
|
||||
assert.doesNotMatch(later, /data-invalid/);
|
||||
assert.match(later, /data-subagent-meta[^>]*>Choose how this subagent runs/);
|
||||
});
|
||||
|
||||
test('validate() stays pure and names rows the way the cards do', () => {
|
||||
const editor = createAvailableSubagentsEditor({ doc: null, win: null });
|
||||
editor.load(setting([apiRow()]), { source: 'configured' });
|
||||
assert.deepEqual(editor.validate(), []);
|
||||
// The Save button reports the attempt; the validator itself changes nothing
|
||||
// and a host-less editor (node tests, detached panel) tolerates the note.
|
||||
assert.doesNotThrow(() => editor.noteSaveAttempt());
|
||||
assert.deepEqual(editor.validate(), []);
|
||||
assert.deepEqual(editor.collect(), { OUROBOROS_SUBAGENTS: setting([apiRow()]) });
|
||||
|
||||
const unrouted = validateAvailableSubagentsSetting(setting([
|
||||
apiRow({ route: { kind: ROUTE_KIND_API_MODEL, target_id: '' } }),
|
||||
]));
|
||||
assert.deepEqual(unrouted, ['Subagent 1 needs a model or agent-session route.']);
|
||||
const errors = validateAvailableSubagentsSetting(setting([apiRow(), apiRow()]));
|
||||
assert.match(errors[0], /^Subagent 2 repeats stable ID/);
|
||||
assert.doesNotMatch(errors.join(' '), /\bRow \d/);
|
||||
});
|
||||
|
||||
test('sessionRouteVerdict decides label, tone and sentence together', () => {
|
||||
const unchecked = sessionRouteVerdict(sessionRow(), { catalogKnown: false, accountsKnown: false });
|
||||
assert.deepEqual(unchecked, {
|
||||
label: 'Not checked', tone: 'neutral', text: 'Agent session · live availability not checked',
|
||||
});
|
||||
const gone = { catalogKnown: true, accountsKnown: true, quotaKnown: true, snapshot: { harnesses: [] } };
|
||||
const missing = sessionRouteVerdict(sessionRow(), gone);
|
||||
assert.deepEqual(missing, { label: 'Unavailable', tone: 'warn', text: 'codex · currently unavailable' });
|
||||
});
|
||||
|
||||
test('the head dot takes the worse of the two status axes', () => {
|
||||
// docs/ARCHITECTURE.md §3: intent · availability, one dot whose tone is the
|
||||
// worse of the two — an unsaved draft is never shown as green success even
|
||||
// when its session is available now, and a saved API row stays neutral
|
||||
// because an API model is only checked when a child starts.
|
||||
const live = {
|
||||
catalogKnown: true, accountsKnown: true, quotaKnown: true, statusError: '',
|
||||
dirty: false, baseline: 'saved', saveAttempted: false,
|
||||
snapshot: {
|
||||
harnesses: [{ id: 'codex', status: 'ok', enabled: true, models: [{ id: 'gpt-5.6-sol-high' }] }],
|
||||
profiles: { harnessAccounts: [], profiles: [{
|
||||
profile: { harness_id: 'codex', profile_id: 'koshak', enabled: true },
|
||||
status: { verification: 'passed' },
|
||||
}] },
|
||||
quota: [{ subject: { harness: 'codex', subject_id: 'koshak' }, freshness: 'fresh', constraints: [] }],
|
||||
},
|
||||
};
|
||||
assert.match(availableSubagentRowMarkup(sessionRow(), live, 0),
|
||||
/data-tone="ok" title="Saved intent · codex · available now[^"]*">Saved · Available</);
|
||||
assert.match(availableSubagentRowMarkup(sessionRow(), { ...live, dirty: true }, 0),
|
||||
/data-tone="neutral" title="Draft intent · codex · available now[^"]*">Draft · Available</);
|
||||
assert.match(availableSubagentRowMarkup(sessionRow(), { ...live, baseline: 'generated' }, 0),
|
||||
/data-tone="neutral"[^>]*>Generated · Available</);
|
||||
assert.match(availableSubagentRowMarkup(apiRow(), live, 0), /data-tone="neutral"[^>]*>Saved · Checked at start</);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,18 +23,24 @@ import { accountRows } from '../modules/harness_accounts.js';
|
|||
import { nextUpAccount } from '../modules/claudexor_status_store.js';
|
||||
import { indexProfilesByHarness } from '../modules/reviewer_slots.js';
|
||||
|
||||
// Source pins below delimit across line breaks; normalize CRLF so a Windows
|
||||
// checkout (core.autocrlf) reads the same bytes the delimiters were written for.
|
||||
const repoFile = (rel) => readFileSync(
|
||||
fileURLToPath(new URL(`../../${rel}`, import.meta.url)), 'utf-8',
|
||||
);
|
||||
).replace(/\r\n?/g, '\n');
|
||||
const moduleFile = (rel) => readFileSync(
|
||||
fileURLToPath(new URL(`../modules/${rel}`, import.meta.url)), 'utf-8',
|
||||
);
|
||||
).replace(/\r\n?/g, '\n');
|
||||
|
||||
/** Names inside a Python tuple literal assigned to `name = (...)`. */
|
||||
function pythonTupleNames(source, name) {
|
||||
const start = source.indexOf(`${name} = (`);
|
||||
assert.notEqual(start, -1, `${name} not found — the wire contract moved, update this test`);
|
||||
const body = source.slice(start, source.indexOf('\n)\n', start));
|
||||
const end = source.indexOf('\n)\n', start);
|
||||
// A missing closer must fail loudly: a slice to EOF over-fills the name set
|
||||
// and lets the subset assertions below pass vacuously.
|
||||
assert.notEqual(end, -1, `${name} tuple closer not found — update this test`);
|
||||
const body = source.slice(start, end);
|
||||
return new Set([...body.matchAll(/"([a-z_]+)"/g)].map((m) => m[1]));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue