Compare commits

...

11 Commits

Author SHA1 Message Date
4f5df8ee5b Draft capability entry (reuse-surface REUSE-WP-0017-T04, cohort 1)
Honest first-pass maturity vector grounded in README/docs/tests present
in this repo; no invented evidence. Flagged for human review before
publish. See reuse-surface history/2026-07-06-coverage-classification.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 19:03:42 +02:00
6018e42930 chore: gitignore uv.lock
This repo is a library/package distributed via setuptools; lockfiles
from local uv dev sessions should not be tracked.
2026-06-24 14:55:35 +02:00
f15d253e64 feat(shell): add interactive cya shell session (CYA-WP-0007)
Implement the full interactive shell REPL with session persistence,
opt-in capped/redacted shell history, State Hub orientation and
explicit slash-command writes, orchestrator/safety wiring, end-session
learning hooks, weakness hints, docs, and tests.
2026-06-24 14:53:18 +02:00
a6266f7e2c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for can-you-assist
2026-06-23 14:26:18 +02:00
8e39e8e27c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for can-you-assist
2026-06-22 23:19:31 +02:00
a0f79af2ab Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:24 +02:00
79736d8b08 Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:34 +02:00
d95dbb8ef5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for can-you-assist
2026-06-22 10:36:26 +02:00
019f6e7dc7 Implement CYA-WP-0008 llm-connect adapter integration.
Wire LLMConnectAdapter behind the existing LLMAdapter seam with config-driven
selection, graceful degradation, --offline mode, and bounded session context.
Add unit tests, integration docs, and update README/SCOPE/AGENTS.
2026-06-22 10:36:10 +02:00
cd5db14fbf chore(consistency): sync State Hub workstream IDs for CYA-WP-0007 and 0008
Writeback from fix-consistency registers both workstreams and tasks in the hub.
2026-06-22 01:39:45 +02:00
34528308bb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for can-you-assist
2026-06-22 01:39:29 +02:00
45 changed files with 2937 additions and 350 deletions

View File

@@ -1,15 +1,3 @@
## Agent entry points
| Runtime | Canonical instructions |
| --- | --- |
| **Grok / Codex** (shell) | `AGENTS.md` at repo root |
| **Claude Code** | This file tree via `CLAUDE.md` |
`AGENTS.md` and `.claude/rules/` are kept in sync for repo-specific content.
Fleet-wide credential routing is mirrored in `credential-routing.md` and the
matching section of `AGENTS.md` — re-sync from `~/ops-warden/wiki/CredentialRouting.md`
when the catalog changes.
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
@@ -29,4 +17,4 @@ Common agents:
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -1,23 +1,8 @@
## Architecture
**Request pipeline** (`src/cya/orchestrator.py`):
1. Collect local context (`context/collector.py`)
2. Recall memory via phase-memory ports (`memory/__init__.py`)
3. Classify risk (`safety/risk.py`) — rule-based; memory signals add caution only
4. Call LLM via `LLMAdapter` Protocol (`llm/adapter.py`) — FakeLLMAdapter today
5. Render explainable response (Rich)
**Memory (Profile 0 + Profile 1 spike):**
- User-controlled local JSON behind explicit ports (`remember`, `recall`, `forget`, `export`)
- Kinds: `preference`, `retrospection`, `interaction_goal`, `reflection`
- Directory/project-bound activation via `activation_context`
- `cya retrospect` feeds higher-order memory; optional verbal lesson capture (Profile 1)
**Boundaries:** See `repo-boundary.md`. No production path bypasses the adapter or memory ports.
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
- Memory contract: `MemoryVision.md`
- Activation/retrospection concept: `docs/cya-memory-activation-and-retrospection-concept.md`
- phase-memory feedback: `docs/phase-memory-optimization-suggestions.md`
- `~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -1,8 +1,5 @@
# Credential and access routing
> Fleet template mirrored in `AGENTS.md` (Credential and access routing section).
> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes.
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
@@ -28,14 +25,13 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
### Quick routing table
Prefer `warden route find` for repo-specific needs. Common routes:
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)

View File

@@ -1,18 +1,19 @@
## First Session Protocol
Triggered when `get_domain_summary("capabilities")` shows **no workstreams**.
Triggered when `get_domain_summary("agents")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `INTENT.md`, `README.md`, `SCOPE.md`, `AGENTS.md`, `MemoryVision.md`
- Scan repo root: `src/cya/`, `workplans/`, `tests/`
- `~/the-custodian/canon/projects/agents/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/agents/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to
INTENT/SCOPE. **Wait for approval before creating.**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
@@ -27,11 +28,11 @@ create_task(workstream_id="<id>", title="...", priority="high|medium|low")
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured capabilities/can-you-assist into N workstreams, M tasks",
summary="First session: structured agents into N workstreams, M tasks",
event_type="milestone",
topic_id="64418556-3206-457a-ba29-6884b5b12cf3",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Past first session for this repo — retained for fleet consistency -->
<!-- Delete or archive this file once past first session -->

View File

@@ -1,11 +1,8 @@
## Repo boundary
This repo owns **can-you-assist** (`cya`) only. It does not own:
This repo owns **can-you-assist** only. It does not own:
- LLM provider access, API clients, or model hosting → `llm-connect`
- Durable memory storage, profile planners, graph/event stores, or lifecycle algorithms → `phase-memory`
- Global work tracking, decisions, or cross-repo coordinationState Hub / custodian
- Credential custody (API keys, DB passwords, OIDC) → OpenBao / key-cape / operator paths (route via `warden route`)
- SSH certificate issuance → `ops-warden` (`warden sign` only)
`cya` integrates via stable seams (`LLMAdapter` Protocol, memory ports). State Hub is non-runtime.
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code state-hub/
-->

View File

@@ -1,7 +1,5 @@
**Purpose:** Console-native, backend-agnostic LLM assistant (`cya`) for practical local work from the shell, using user-controlled context, memory, and provider configuration.
**Purpose:** Console-native, backend-agnostic LLM helper for practical local shell, repository, filesystem, and note workflows.
**Domain:** capabilities
**Domain:** agents
**Repo slug:** can-you-assist
**Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3
**Workplan prefix:** CYA-WP-
**Command name:** cya

View File

@@ -1,6 +1,7 @@
## Session Protocol
State Hub: http://127.0.0.1:8000 (remote tunnel: http://127.0.0.1:18000)
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
@@ -10,12 +11,11 @@ cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("capabilities")
get_domain_summary("agents")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
| python3 -m json.tool
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
@@ -40,11 +40,11 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks.
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `capabilities` — title, task counts, blocking decisions
1. **Active workstreams** for `agents` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:can-you-assist]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
@@ -54,12 +54,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:**
- Keep changes small and inspectable.
- Treat command execution safety as product behavior: explain destructive commands and require explicit confirmation before suggesting execution.
- Do not commit secrets, API keys, local transcripts, private notes, or hidden memory contents.
- Before requesting API keys, SSH access, login tokens, or database passwords, run `warden route find` / `warden route show` per `credential-routing.md`.
- `record_decision()` · `add_progress_event()` · `resolve_decision()`
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
@@ -87,4 +82,4 @@ cd ~/state-hub && make fix-consistency-remote REPO=can-you-assist
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -1,38 +1,19 @@
## Stack
- **Language:** Python 3.11+
- **CLI:** Typer + Rich
- **Config:** TOML
- **Packaging:** setuptools + setuptools_scm (`src/` layout)
- **Tests:** pytest
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# Recommended: install latest development code
make dev-install
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
make test
# or: python3 -m pytest tests/ -q
# Build distribution packages
make dist
make release-prep
make check-dist
# Lint / type check
# Run the assistant
cya "your natural language request here"
cya --help
cya --explain-context "show me what context would be collected"
cya retrospect
# Version / inspection
make version
git status --short
rg --files
# Build / package (if applicable)
```
Current memory baseline: **Profile 0** (local JSON + activation + retrospection).
Profile 1 verbal reflections shipped as a minimal spike (CYA-WP-0005); production
hardening tracked in CYA-WP-0006.

View File

@@ -25,4 +25,16 @@ Ecosystem todos from other agents arrive as `[repo:can-you-assist]` hub tasks
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
Task blocks use this shape:
```task
id: CYA-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,8 +1,8 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — can-you-assist
**Domain:** capabilities
**Last synced:** 2026-06-21 23:25 UTC
**Domain:** agents
**Last synced:** 2026-06-23 12:26 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Current Goal
@@ -17,6 +17,6 @@ Console-native user-controlled LLM assistant
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("capabilities")`
`get_domain_summary("agents")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

2
.gitignore vendored
View File

@@ -99,7 +99,7 @@ ipython_config.py
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.

16
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,16 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: agent
category: project
domain: agents
secondary_domains: []
capability_tags: []
business_stake:
- technology
- automation
- product
business_mechanics:
- coordination
- operation

305
AGENTS.md
View File

@@ -1,227 +1,108 @@
# can-you-assist - Agent Instructions
## Worker Role
Primary worker agent: Grok Build / Grok Code.
Run from the repository root. When available, start with `grok inspect` to
confirm Grok has discovered this `AGENTS.md`, the repo files, and any local
`.grok/` configuration. The xAI Build docs say Grok reads the `AGENTS.md`
instruction-file family and can inspect discovered instructions, skills,
plugins, hooks, and MCPs:
- https://docs.x.ai/build/overview
- https://docs.x.ai/build/features/skills-plugins-marketplaces
This file is the canonical worker guide for this repo. Do not assume a Claude
Code MCP server is available; use the State Hub HTTP API unless the operator
explicitly provides another integration.
# can-you-assist Agent Instructions
## Repo Identity
**Purpose:** Console-native, backend-agnostic LLM assistant for practical local
work from the shell, using user-controlled context, memory, and provider
configuration.
**Purpose:** Console-native, backend-agnostic LLM helper for practical local shell, repository, filesystem, and note workflows.
**Domain:** capabilities
**Domain:** agents
**Repo slug:** can-you-assist
**Topic ID:** `64418556-3206-457a-ba29-6884b5b12cf3`
**Workplan prefix:** `CYA-WP-`
**Command name:** `cya`
## Product Direction
`cya` should help users express intent in natural language from a terminal and
receive useful, explainable help for command-line tasks. It should be good at
repository inspection, file and note workflows, command suggestion, command
explanation, and local context summarization.
Keep these boundaries crisp:
- `can-you-assist` owns the CLI assistant experience, local context gathering,
safety prompts, command explanations, and orchestration of assistance.
- `llm-connect` owns provider/backend access. Do not hard-code one LLM vendor
as the conceptual foundation.
- `phase-memory` owns user-controlled memory, preferences, history, and
adaptation. Do not hide memory in opaque hosted state.
- State Hub tracks work, coordination, progress, and cross-repo handoffs. It
should not become a runtime dependency of the `cya` CLI.
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST.
The Custodian State Hub tracks work across all domains. Interact via HTTP REST
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient At Session Start
### Orient at session start
```bash
# Offline brief - generated by State Hub consistency tooling when available
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain/topic
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
| python3 -m json.tool
# Inbox for this repo worker
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=can-you-assist&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Update Task Status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
```
Allowed task statuses: `todo`, `in_progress`, `done`, `blocked`.
### Log Progress
Log progress at session close and after meaningful milestones:
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what changed",
"summary": "what was done",
"event_type": "note",
"author": "grok",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` and `task_id` only when there is no applicable item.
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
Start:
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=can-you-assist&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
1. Run `git status --short`.
2. Read `.custodian-brief.md` if present; if absent, use the State Hub API
queries above.
3. Check the `can-you-assist` inbox and mark read only after acting on a
message or carrying it forward in a workplan.
4. Scan `workplans/` if it exists. If it does not exist yet, the onboarding
workstream should create it.
5. Pick the highest-priority active task that is unblocked and update task
status before substantial work.
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
During work:
- Keep changes small and inspectable.
- Treat command execution safety as product behavior: explain destructive or
broad filesystem commands and require explicit confirmation before suggesting
execution.
- Before requesting API keys, SSH access, login tokens, or database passwords,
run `warden route find` / `warden route show` per **Credential and access routing**.
- Do not commit secrets, API keys, local transcripts, private notes, or hidden
memory contents.
- Preserve user-controlled memory as a design principle. Prefer explicit,
inspectable local files over hidden state.
- Record significant design decisions with `POST /decisions/`.
Close:
1. Update workplan task statuses to match reality.
2. Log a progress event in State Hub.
3. Ask the custodian operator to run:
```bash
cd ~/state-hub && make fix-consistency REPO=can-you-assist
```
That syncs repo workplan files into the State Hub DB and regenerates
`.custodian-brief.md`.
## Current Grok Handoff
State Hub registration has been created for `can-you-assist` under the
`capabilities` domain. Look for active workstream
`repo-integration-can-you-assist`.
First useful worker moves:
1. Read `INTENT.md`, `README.md`, this `AGENTS.md`, and `SCOPE.md`.
2. Create `workplans/CYA-WP-0001-console-native-mvp.md` with a focused first
implementation slice: CLI skeleton, safe command-assistance flow,
llm-connect adapter boundary, phase-memory boundary, and test strategy.
3. Keep the first workplan `ready` until the repo state and stack choice are
reviewed. Move it to `active` when implementation begins.
4. Run or request SBOM ingest from State Hub after the first dependency files
exist.
5. Register obvious extension points and technical debt once there is code to
anchor them.
## Commands
```bash
# === Packaging & Installation (CYA-WP-0004) ===
# Recommended: Stay on latest development code
make dev-install
# Build distribution packages (sdist + wheel)
make dist
# Prepare a release (tests + clean build)
make release-prep
# Verify a built wheel in isolation
make check-dist
# Run the assistant
cya "your natural language request here"
cya --help
cya --explain-context "show me what context would be collected"
# Memory features (0003 + 0005)
cya retrospect # Guided reflection session
# Memory: Profile 0 baseline + production Profile 1 (CYA-WP-0006).
# Profiles 23 (hierarchical synthesis, procedural rules) remain roadmap — see MemoryVision.md.
# Tests
python -m pytest tests/ -q
# Git / inspection
git status --short
git log --oneline -5
rg --files
```
No formal lint or build system yet (ruff is configured in pyproject.toml but not enforced in CI for the first slice). Add `make test` / `make lint` targets in a follow-on when needed.
Current primary entry point: `cya` (after editable install).
Relevant workplans:
- `workplans/CYA-WP-0002-memory-integration-roadmap.md`
- `workplans/CYA-WP-0003-contextual-memory-activation-and-retrospection.md`
- `workplans/CYA-WP-0004-dev-install-and-release-packaging.md`
- `workplans/CYA-WP-0005-agentic-memory-profiles-and-phase-memory-feedback.md`
- `workplans/CYA-WP-0006-profile-1-production-hardening.md` (finished)
- `workplans/CYA-WP-0007-interactive-shell-session.md` (ready — interactive REPL + history + hub)
- `workplans/CYA-WP-0008-llm-connect-adapter-integration.md` (ready — real LLM behind adapter seam)
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=can-you-assist
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
> Fleet template mirrored in `.claude/rules/credential-routing.md` for Claude Code.
> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes.
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
@@ -247,14 +128,13 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
### Quick routing table
Prefer `warden route find` for repo-specific needs. Common routes:
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
@@ -272,52 +152,89 @@ get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention
## Workplan Convention (ADR-001)
Work items originate as files in this repo. The hub is a read/cache/index
layer that rebuilds from files.
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/CYA-WP-NNNN-<slug>.md`
**File location:** `workplans/CAN-WP-NNNN-<slug>.md`
**Archived location:** `workplans/archived/YYMMDD-CYA-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-CAN-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad hoc tasks:** small opportunistic fixes may use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids like
`ADHOC-YYYY-MM-DD-T01`. Use this only for low-risk work completed directly.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
Frontmatter:
**Frontmatter:**
```yaml
---
id: CYA-WP-NNNN
id: CAN-WP-NNNN
type: workplan
title: "..."
domain: capabilities
domain: agents
repo: can-you-assist
status: proposed | ready | active | blocked | backlog | finished | archived
owner: grok
topic_slug: foerster-capabilities
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency - do not edit
state_hub_workstream_id: "<uuid>" # written by fix-consistency do not edit
---
```
Task block format:
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
````
**Task block format** (one per `##` section):
```
## Task Title
```task
id: CYA-WP-NNNN-T01
status: todo | in_progress | done | blocked
` ` `task
id: CAN-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency - do not edit
```
state_hub_task_id: "<uuid>" # written by fix-consistency do not edit
` ` `
Task description text.
````
```
Status progression: `todo` -> `in_progress` -> `done` or `blocked`.
Status progression: `todo` `progress` `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=can-you-assist`
(or send a message to the hub agent via `POST /messages/`)
---
## `cya shell` Command Reference
Interactive shell sessions are an owned repo capability. Use them for manual
operator assistance, not autonomous command execution.
```bash
cya shell # start a stateful shell session
cya shell --offline --no-hub # local smoke without hub HTTP calls
cya shell --with-history # opt in to capped, redacted history context
```
Session artifacts live at `~/.config/cya/sessions/<session-id>.jsonl`.
Shell history is off by default and remains capped/redacted when enabled.
Slash commands: `/help`, `/explain`, `/hub`, `/hub log "summary"`, `/inbox`,
`/inbox read <id>`, `/export-session`, `/learn`, `/exit`.
State Hub writes are never automatic. `/hub log` requires interactive
confirmation; `/inbox read <id>` is the explicit mark-read action.

View File

@@ -9,4 +9,4 @@
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md
@.claude/rules/agents.md

View File

@@ -16,7 +16,7 @@ usable after `pip install -e .`:
- `cya "your request in plain English"`
- `cya --explain-context "..."` — shows exactly what local context would be sent
- Automatic rule-based risk classification with mandatory confirmation for anything destructive, privileged, mass-edit, or network-affecting
- All LLM interaction flows through a documented `LLMAdapter` seam (currently a deterministic fake; ready for real `llm-connect`)
- All LLM interaction flows through a documented `LLMAdapter` seam (`FakeLLMAdapter` by default; real `llm-connect` when configured)
## Installation
@@ -58,6 +58,41 @@ git pull
make dev-install
```
## LLM backend (configured vs offline)
By default `cya` uses a deterministic **offline** adapter (no API keys, no network).
For real inference, configure llm-connect:
```bash
# 1. Install llm-connect (sibling checkout)
pip install -e ~/llm-connect
# 2. Route credentials — do not commit keys
warden route find "OpenRouter API key" --json
# 3. Export key and configure cya
export OPENROUTER_API_KEY="..." # from OpenBao / operator path
mkdir -p ~/.config/cya
cat > ~/.config/cya/config.toml <<'EOF'
[llm]
adapter = "connect"
backend = "openrouter"
model = "anthropic/claude-sonnet-4"
EOF
cya "show me the recent git history for this repo"
```
Force offline mode anytime (tests, CI, air-gapped):
```bash
cya --offline "your request"
# or: CYA_LLM_ADAPTER=fake cya "..."
```
See `docs/llm-connect-integration.md` for the full mapping, session context budget,
and optional `.cya.toml` project overrides.
## Usage examples
```bash
@@ -74,6 +109,60 @@ cya --explain-context "explain the changes in the last commit"
The output includes a structured suggestion, rationale, and (when relevant) a
clear preview + confirmation prompt. Nothing executes without your explicit yes.
## Interactive Shell Sessions
Start a stateful console session when you want continuity across turns:
```bash
cya shell
cya shell --offline --no-hub # local smoke / air-gapped mode
cya shell --with-history # opt in to capped, redacted shell history
```
Each session writes a user-owned JSONL artifact under:
```text
~/.config/cya/sessions/<session-id>.jsonl
```
Useful slash commands:
```text
/help show shell commands
/explain show last turn context, risk, history, hub, hints
/hub show State Hub workstreams and inbox orientation
/hub log "summary" post a progress note after confirmation
/inbox show unread State Hub messages
/export-session write a redacted JSON session summary
/learn capture Profile 1 reflections now
/exit close the session
```
Shell history is off by default. `--with-history` or `[shell_history]` config
includes at most 50 recent lines from `$HISTFILE` or common shell history files,
redacts secret-like values, and exposes provenance in `/explain`. One-shot
`cya "..."` requests remain history-free by default.
Example transcript:
```text
$ cya shell --offline --no-hub
cya> summarize recent git changes
... standard orchestrator response ...
cya> /explain
... context envelope, memory, shell history status, hub summary, and hints ...
cya> /export-session
Exported session summary: ~/.config/cya/sessions/cya-...-summary.json
cya> /exit
Session saved: ~/.config/cya/sessions/cya-...jsonl
```
State Hub writes never happen automatically. `/hub log` and `/inbox read` are
explicit operator actions; `/hub log` also asks for confirmation.
See `docs/cya-interactive-shell-session-design.md` and
`docs/cya-shell-operator-session.md` for the full design and operator example.
## Safety (core product behavior)
- Genuine rule-based assessment is the primary mechanism.
@@ -190,9 +279,11 @@ decisions, and integration guide.
```bash
# Recommended one-liner (see Installation section above)
make dev-install
pip install -e ~/llm-connect # optional, for live inference
pytest tests/ -q
cya "..." # manual verification
pytest tests/ -q # offline mocks only; no API keys
pytest -m llm_live # manual live check (requires OPENROUTER_API_KEY)
cya --offline "..." # manual verification without network
make version # show current dev version
```

View File

@@ -6,7 +6,7 @@
It allows users to express intent in natural language from the terminal and receive safe, explainable, context-aware assistance while keeping memory, history, preferences, and adaptation under explicit user control.
## Current Status (Post CYA-WP-0004 Packaging & Distribution Slice)
## Current Status (Post CYA-WP-0007 Interactive Shell Session Slice)
Four implementation slices have been delivered:
@@ -16,17 +16,19 @@ Four implementation slices have been delivered:
- **Profile 0 baseline (post-0003, formalized in CYA-WP-0005 T02)**: The current shipped memory implementation (local JSON + kinds + activation_context + provenance + retrospection helper) is now explicitly documented as **Profile 0** — the stable, high-quality foundation for future self-improving profiles 13. See MemoryVision.md for the full baseline description.
- **CYA-WP-0005 (Agentic Memory Profiles + first self-improvement capability)**: Complete profile model (Profile 0 baseline + detailed definitions + integration plans + Capability Matrix for Profiles 13) plus initial **Profile 1** delivery. **CYA-WP-0006** hardened Profile 1 to production quality: guided 13 lesson capture with preview in `cya retrospect`, `cya memory reflections`, near-duplicate compaction, and surfacing in responses / `--explain-context`. See MemoryVision.md and `docs/CYA-WP-0006-profile-1-gap-checklist.md`.
- **CYA-WP-0004 (Dev-Head Install & Release Packaging)**: Reliable installation from development head (`make dev-install`, direct `git+` installs), dynamic versioning via `setuptools_scm`, clean distribution package building (`python -m build` + verification), lightweight release process, and supporting documentation/Makefile.
- **CYA-WP-0007 (Interactive Shell Session)**: `cya shell` REPL, session JSONL artifacts, optional capped/redacted shell history, read-only State Hub orientation, explicit hub slash-command writes, end-session learning/export, and deterministic weakness hints.
Core capabilities now include:
- Natural language request handling via clean Typer CLI.
- Bounded, transparent local context collection.
- Genuine rule-based (memory-aware) risk classification with mandatory confirmation.
- Stable `LLMAdapter` Protocol.
- Stable `LLMAdapter` Protocol with `LLMConnectAdapter` behind the same factory when configured.
- Real, user-controlled, contextually activated memory (Profile 0: directory/project scoped local JSON with kinds, activation_context, provenance, and retrospection outcomes as higher-order memory).
- Automatic memory activation based on working directory/git root.
- `cya retrospect` for structured reflection and goal setting, with production Profile 1 verbal lesson capture, review (`cya memory reflections`), and compaction.
- Full developer workflow: dev-head install, testing, building distribution packages, and a documented release process.
- Transparent, inspectable behavior via `--explain-context`.
- Transparent, inspectable behavior via `--explain-context` and shell `/explain`.
- Stateful `cya shell` sessions with local JSONL artifacts, slash commands, optional history context, hub orientation, export, and opt-in learning.
All LLM interaction flows through the documented adapter seam. Memory flows through explicit ports. Packaging and distribution are now first-class concerns with a clear path forward. No production path bypasses the defined boundaries.
@@ -39,7 +41,8 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro
- Orchestration of the request → context → safety → LLM adapter → response pipeline.
- The stable `LLMAdapter` Protocol and the contract for how `cya` talks to LLM backends.
- Explicit, now real (persisting) integration with user-controlled memory via `phase-memory` ports.
- Transparent, inspectable behavior (especially via `--explain-context`).
- Transparent, inspectable behavior (especially via `--explain-context` and shell `/explain`).
- Interactive `cya shell` sessions: prompt loop, session state, local artifacts, slash commands, optional shell-history context, hub orientation, export, and end-session learning prompts.
- User-facing documentation, examples, and safety guarantees for the CLI tool.
## Does Not Own
@@ -51,7 +54,7 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro
- Deep repository indexing, embeddings, or large-scale content analysis (explicit non-goal of the first slice).
- Voice, speech, phone-bridge, or non-terminal interfaces (future work).
- Production PyPI publishing and automated release CI (documented process and local tooling exist; actual publication is future work).
- Long-lived conversational REPL or session state (one-shot + very lightweight session only).
- Hosted, team-shared, or background session state; `cya shell` artifacts remain local and user-owned.
## Integrates With
@@ -77,10 +80,10 @@ See the individual workplans for detailed scope per slice.
## Explicitly Out of Scope (Current and Near-Term)
- Full deep integration with the complete `phase-memory` profile/planner/graph system (current implementation uses a deliberate, user-visible local JSON store with contextual activation; deeper integration is planned future work per MemoryVision.md).
- Real `llm-connect` client implementation (only the stable `LLMAdapter` Protocol contract + FakeLLMAdapter exists).
- Deep llm-connect features beyond basic `execute_prompt` delegation (adaptive routing, cost dashboards, structured output schemas).
- Deep semantic repository understanding or large-scale content analysis.
- Automatic command execution (even "safe" suggestions) — explicit user confirmation remains mandatory for anything non-safe.
- Rich multi-turn conversational state beyond lightweight scoped memory + retrospection.
- Hosted/team-shared shell sessions or autonomous background agents.
- Cost tracking, token budgeting, or usage dashboards.
- Team/shared memory or collaboration features.
- Plugin system or domain-specific extensions.
@@ -93,6 +96,7 @@ See the individual workplans for detailed scope per slice.
- `cya/safety/risk.py` — the `_RULES` table and `classify()` function (memory-aware).
- `cya/context/collector.py` — collection functions and ignore policy.
- `cya/orchestrator.py` — the main coordination entry point.
- `cya/shell_session.py` — interactive shell runtime, slash commands, session artifacts, optional history, hub orientation, and weakness hints.
- Packaging & distribution: `Makefile`, `pyproject.toml`, `docs/release-process.md`, and `MANIFEST.in` (first-class concern with registered future work).
## Success Criteria (Current State)
@@ -101,6 +105,7 @@ A new user or contributor can:
- Install the latest development code easily (`make dev-install` or direct git+) and use `cya` for realistic tasks after reading the README.
- Understand exactly what context and memory influenced a response via `--explain-context`.
- Trust that dangerous actions will never execute without explicit confirmation.
- Start `cya shell` for multi-turn help, inspect `/explain`, export a redacted session, and opt in explicitly before shell history or session-turn memory is used.
- Use `cya retrospect` to reflect on usage and set goals that influence future behavior.
- Build and verify distribution packages locally.
- See a clear path for how real `llm-connect`, deeper `phase-memory`, and future PyPI releases will integrate.
@@ -109,7 +114,7 @@ Sibling project owners (llm-connect, phase-memory, State Hub) can read the workp
---
**This SCOPE document reflects the state after CYA-WP-0004 (Dev-Head Install & Release Packaging).**
**This SCOPE document reflects the state after CYA-WP-0007 (Interactive Shell Session) and CYA-WP-0008 (llm-connect Adapter Integration).**
It remains intentionally narrower than the long-term vision in INTENT.md and MemoryVision.md, but now incorporates significant advances in contextual memory activation, user-driven retrospection/optimization loops, and proper packaging & distribution capabilities.

View File

@@ -0,0 +1,16 @@
# Example ~/.config/cya/config.toml — placeholders only; do not commit secrets.
[llm]
adapter = "connect"
backend = "openrouter"
model = "anthropic/claude-sonnet-4"
temperature = 0.3
max_tokens = 2000
api_key_env = "OPENROUTER_API_KEY"
# Optional interactive shell history context. Off by default.
# Values are capped/redacted before they enter session context.
[shell_history]
enabled = false
limit = 50
# histfile = "~/.bash_history"

View File

@@ -0,0 +1,172 @@
# cya Interactive Shell Session Design
Status: implemented for CYA-WP-0007
Date: 2026-06-23
## Scope Alignment
This design moves `cya shell` from the old deferred REPL note in SCOPE.md into
owned product scope. It preserves the core INTENT principle: `cya` helps from
inside the console while the user keeps control of context, history, memory,
backend choice, and safety decisions.
Reviewed against:
- INTENT.md: console-native, user-controlled, explainable assistance.
- SCOPE.md: `cya` owns UX, orchestration, safety, context, and documentation;
State Hub remains non-runtime coordination.
- MemoryVision.md: session turns are an episodic precursor with explicit
`kind: session_turn`, user-triggered export, and opt-in persistence to memory.
- AGENTS.md / State Hub boundary: hub reads are allowed for orientation;
hub writes require explicit operator confirmation.
## REPL UX
Command:
```bash
cya shell
cya shell --offline --no-hub
cya shell --with-history
```
Prompt: `cya> `
The shell is intentionally still a helper alongside the user's shell. It never
auto-executes commands. Natural-language turns are passed through the existing
orchestrator path: context collection, memory recall, risk classification,
mandatory confirmation for non-safe levels, adapter call, and rendering.
Slash commands:
- `/help` - show the shell command list.
- `/exit` or `/quit` - close the shell session.
- `/explain` - show the last turn's risk, context envelope, shell history
provenance, hub summary, and weakness hints.
- `/hub` - show loaded active workstreams, open tasks, inbox, and hub errors.
- `/hub log "summary"` - post a State Hub progress note only after interactive
confirmation.
- `/inbox` - show unread messages.
- `/inbox read <id>` - explicitly mark a message read.
- `/export-session` - write a redacted JSON session summary.
- `/learn` - capture Profile 1 reflections immediately.
EOF exits cleanly. Ctrl-C interrupts the current prompt and keeps the session
alive. Non-TTY input is accepted for smoke/scripted use; end-session learning
prompts are skipped unless the session is interactive.
## Session File Layout
Session artifacts are user-owned and local:
```text
~/.config/cya/sessions/<session-id>.jsonl
~/.config/cya/sessions/<session-id>-summary.json
```
Session ids use `cya-YYYYMMDD-HHMMSS-<random>`.
JSONL event kinds:
- `session_start`: session metadata, cwd, loaded history context, compact hub
orientation.
- `session_turn`: one redacted turn with user text, assistant text, risk,
cancellation/dry-run flags, weakness hints, and redaction count.
- `session_export`: emitted when `/export-session` writes a summary file.
- `session_end`: final turn count and timestamp.
`session_turn` is also registered as a memory kind. End-of-session memory writes
are opt-in and store only redacted turn summaries through the existing memory
port, not a new private store.
## Shell History Model
History is off by default.
Opt-in paths:
```bash
cya shell --with-history
```
or:
```toml
[shell_history]
enabled = true
limit = 50
histfile = "~/.bash_history"
```
Rules:
- Default hard cap: 50 lines.
- Source: `$HISTFILE`, then `.bash_history`, `.zsh_history`, or fish history.
- Per-line provenance: source path, line number, and `shell_history.histfile`.
- Redaction before session context or persistence: API keys, tokens, passwords,
common provider key forms, and secret-like env assignments.
- `/explain` shows whether history was enabled, how many lines were included,
redaction count, and provenance.
- One-shot `cya "..."` remains history-free by default; no collector invariant
changes are required for one-shot mode.
## State Hub Boundary
Session start reads:
- `.custodian-brief.md` if present.
- `GET /workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active`.
- `GET /messages/?to_agent=can-you-assist&unread_only=true`.
Remote hub failures are non-fatal and are recorded in the session context.
Writes are explicit slash commands only:
- `/hub log "summary"` posts a progress note after confirmation.
- `/inbox read <id>` marks one message read by explicit command.
No automatic State Hub writes occur on shell start, normal turns, export, or
session close.
## Weakness Hints v1
Hints are deterministic informational panels. They never change the risk level,
never skip confirmation, and never execute fixes.
Rules:
- `credential-routing`: secret-like topics without `warden route` guidance.
- `ops-warden-secret-anti-pattern`: requests that imply ops-warden should vend
API keys or passwords.
- `remote-exec-review`: `curl`/`wget` piped to interpreters.
- `repeated-destructive-intent`: repeated destructive or mass-edit turns.
- `state-hub-alignment`: requested workplan id not visible in loaded hub
summary, reminding the user that local workplan files are source of truth.
## End-of-Session Learning
On interactive close, the shell offers:
1. Profile 1 lesson capture using the same reflection helpers as
`cya retrospect`.
2. Optional redacted `session_turn` memory persistence.
Skipping either path writes no reflection or session-turn memory records.
`/export-session` remains separate and always writes only a redacted JSON
summary under the sessions directory.
## Gate Checklist
- T02 REPL skeleton: `cya shell` command, prompt loop, JSONL session file,
`/help`, `/exit`, `/explain`, EOF/Ctrl-C handling.
- T03 history: off by default, `--with-history`, config support, caps,
redaction, provenance, tests.
- T04 hub: brief + active workstreams + inbox, graceful offline behavior,
`/hub`, `/hub log`, `/inbox`, explicit mark-read.
- T05 orchestrator/safety: every turn uses `handle_request`; non-safe levels
still require confirmation; `/explain` shows the full session context.
- T06 learning/export: Profile 1 capture, `/export-session`, opt-in
`session_turn` memory persistence.
- T07 hints: at least three deterministic rules with tests; advisory only.
- T08 docs/tests: README, AGENTS command reference, operator example, and
pytest coverage.

View File

@@ -0,0 +1,43 @@
# Example Operator Session
This example uses offline mode and skips remote hub calls for a local smoke.
In normal repo work, omit `--no-hub` so the shell reads State Hub orientation.
```text
$ cya shell --offline --no-hub
cya shell
Session: cya-20260623-101500-1a2b3c4d
Artifact: ~/.config/cya/sessions/cya-20260623-101500-1a2b3c4d.jsonl
State Hub remote orientation not available.
History lines: 0
Type /help for commands, /exit to finish.
cya> summarize what changed in this repo
... standard orchestrator response ...
cya> /explain
... context envelope, memory, shell history status, hub summary, and hints ...
cya> /hub
... active workstreams or offline notes ...
cya> /export-session
Exported session summary: ~/.config/cya/sessions/cya-...-summary.json
cya> /exit
Session saved: ~/.config/cya/sessions/cya-...jsonl
```
For history-aware continuity, opt in explicitly:
```bash
cya shell --with-history
```
The history context is capped, redacted, and shown in `/explain`. Secret values
should still be routed through the approved custody path, not pasted into the
session.
State Hub writes require a slash command and interactive confirmation:
```text
cya> /hub log "Implemented CYA-WP-0007 shell skeleton and tests"
Post progress note to http://127.0.0.1:8000? [y/N]: y
```

View File

@@ -0,0 +1,100 @@
# llm-connect Integration (CYA-WP-0008)
## Mapping: cya ↔ llm-connect
| cya (`AssistanceRequest`) | llm-connect |
|---------------------------|-------------|
| `user_request` | User message body (after context framing) |
| `context` (envelope + memory + `session_turns`) | Serialized into the prompt via `cya.llm.prompt.build_assistance_prompt` |
| `hints` (`model`, `temperature`, `max_tokens`) | `RunConfig` fields for `execute_prompt` |
| `AssistanceResponse.suggestion` | `LLMResponse.content` |
| `AssistanceResponse.metadata` | `LLMResponse.model`, `usage`, `finish_reason` |
llm-connect owns provider clients (`create_adapter`), API key resolution, retries, and
token usage. `cya` never imports vendor SDKs directly.
## Configuration
User config: `~/.config/cya/config.toml`
```toml
[llm]
adapter = "connect" # "connect" | "fake" (default: fake when absent)
backend = "openrouter" # openrouter | openai | gemini | claude-code | mock
model = "anthropic/claude-sonnet-4"
temperature = 0.3
max_tokens = 2000
api_key_env = "OPENROUTER_API_KEY" # optional override
# system_prompt = "..." # optional; uses cya default when omitted
```
Optional project override: `.cya.toml` (same `[llm]` section; merged over user config).
Environment overrides:
| Variable | Purpose |
|----------|---------|
| `CYA_LLM_ADAPTER` | `connect` or `fake` |
| `CYA_LLM_BACKEND` / `CYA_LLM_PROVIDER` | Provider name |
| `CYA_LLM_MODEL` | Model id |
CLI: `cya --offline "..."` forces `FakeLLMAdapter`.
## Session context budget (multi-turn / `cya shell`)
Recent turns are passed in `AssistanceRequest.context["session_turns"]` as
`{"user": "...", "assistant": "..."}` records.
Bounds (see `cya.config`):
- **Max turns:** 10
- **Max characters:** 4000 (total across included turns)
Older or oversized history is dropped from the prompt automatically.
## Credential routing
Do **not** commit API keys. Before requesting secrets, route custody:
```bash
warden route find "OpenRouter API key" --json
warden route show <catalog-id> --json
```
Typical ownership:
| Need | Owner | ops-warden executes? |
|------|-------|----------------------|
| `OPENROUTER_API_KEY` | OpenBao (`railiance-platform`) | No — route only |
| `OPENAI_API_KEY` | OpenBao | No — route only |
| `GEMINI_API_KEY` | OpenBao | No — route only |
llm-connect resolves keys via `resolve_api_key()` (explicit arg → env var → project key file).
## Adapter selection
`cya.llm.factory.get_adapter()` is the single factory for one-shot and shell paths:
1. `--offline` or `CYA_LLM_ADAPTER=fake``FakeLLMAdapter`
2. `adapter = "connect"` in config/env → `LLMConnectAdapter` (graceful degrade on failure)
3. Otherwise → `FakeLLMAdapter` (current default)
## Installation
```bash
make dev-install
pip install -e ~/llm-connect # sibling checkout
```
Optional extra group (placeholder for packaging): `pip install -e ".[llm]"`.
## Tests
- Default CI: `make test` — mocks llm-connect; no network.
- Manual live check: `pytest -m llm_live` (requires configured API key).
## Known gaps
- Structured JSON output schema not enforced yet (free-form model text).
- `claude-code` backend does not require an API key; other backends do.
- Per-directory `.cya.toml` overrides user config but does not yet mirror llm-connect's full 7-layer resolution.

View File

@@ -15,6 +15,7 @@ authors = [
dependencies = [
"typer[standard]>=0.12.0",
"rich>=13.0.0",
"tomli>=2.0.0; python_version<'3.11'",
]
[project.optional-dependencies]
@@ -26,6 +27,9 @@ dev = [
test = [
"pytest>=8.0",
]
llm = [
# Install llm-connect from a sibling checkout, e.g. pip install -e ~/llm-connect
]
[project.scripts]
cya = "cya.cli.main:run"
@@ -57,6 +61,7 @@ python_functions = ["test_*"]
addopts = "-q --tb=short"
markers = [
"safety: core safety and risk classifier invariants (always run)",
"llm_live: live llm-connect inference (requires API key; manual runs only)",
]
[tool.setuptools_scm]

View File

@@ -0,0 +1,120 @@
---
id: capability.agents.cli-assistant
name: Console-Native LLM Assistant (cya)
summary: Console-native, backend-agnostic assistant CLI that expresses user intent in natural language
and returns safe, explainable, context-aware help.
owner: can-you-assist
status: draft
domain: agents
tags:
- cli
- assistant
- llm
- safety
maturity:
discovery:
current: D2
target: D4
confidence: medium
rationale: README documents the MVP slice (CYA-WP-0001), the LLMAdapter seam, and the rule-based risk-classification
safety layer; no separate INTENT/SCOPE file found.
availability:
current: A2
target: A3
confidence: medium
rationale: Installable today via `pip install -e .`; ships a working CLI (`cya "..."`, `--explain-context`)
with a FakeLLMAdapter default and optional real llm-connect backend.
external_evidence:
completeness:
level: C1
confidence: low
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- natural-language CLI entry point
- context-explain mode
- mandatory confirmation for destructive/privileged/mass-edit/network actions
broken_expectations: []
out_of_scope_expectations: []
reliability:
level: R0
confidence: low
basis: consumer_quality_signals
known_reliability_risks:
- MVP slice only; real llm-connect backend optional/not always configured
discovery:
intent: Let users express intent in natural language from the terminal and receive safe, explainable,
context-aware help, without owning LLM inference or memory storage itself.
includes:
- CLI orchestration and UX
- rule-based risk classification and confirmation gating
- LLMAdapter seam to llm-connect
excludes:
- LLM inference (owned by llm-connect)
- memory storage (owned by phase-memory)
assumptions: []
use_cases: []
research_memos: []
availability:
current_level: A2
target_level: A3
current_artifacts:
- '`cya` CLI package, pip-installable'
target_artifacts: []
consumption_modes:
- cli
relations:
depends_on: []
supports: []
related_to: []
evidence:
documentation:
- README.md
tests:
- tests/
consumer_feedback: []
bug_reports: []
incidents: []
consumer_guidance:
recommended_for:
- shell-based workflows wanting a safety-gated natural-language assistant
not_recommended_for:
- needs beyond the first MVP slice (CYA-WP-0001)
known_limitations:
- first narrow MVP slice; FakeLLMAdapter is the default, real backend is optional
promotion_history: []
---
# Console-Native LLM Assistant (cya)
## Overview
`cya` is the CLI surface for the capabilities domain: it owns orchestration, UX, and a safety layer, and talks to `llm-connect` only through a stable `LLMAdapter` boundary, keeping memory under `phase-memory`.
## Assessment notes
### Discovery
README documents the MVP slice (CYA-WP-0001), the LLMAdapter seam, and the rule-based risk-classification safety layer; no separate INTENT/SCOPE file found.
### Availability
Installable today via `pip install -e .`; ships a working CLI (`cya "..."`, `--explain-context`) with a FakeLLMAdapter default and optional real llm-connect backend.
### Completeness
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
(reuse-surface). No external consumer feedback exists yet; levels reflect
scope-vs-intent documentation quality, not internal code quality.
### Reliability
No production consumer telemetry exists yet; reliability level is
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
## Promotion checklist
- [x] ID follows `capability.<domain>.<name>` pattern
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
- [x] `external_evidence` is populated separately from `maturity`
- [ ] Relations reference valid capability IDs (none yet)
- [x] Index entry added in `registry/indexes/capabilities.yaml`

View File

@@ -1,4 +1,20 @@
version: 1
updated: '2026-06-16'
updated: '2026-07-06'
domain: helix_forge
capabilities: []
capabilities:
- id: capability.agents.cli-assistant
name: Console-Native LLM Assistant (cya)
summary: Console-native, backend-agnostic assistant CLI that expresses user intent in natural language
and returns safe, explainable, context-aware help.
vector: D2 / A2 / C1 / R0
domain: agents
status: draft
owner: can-you-assist
path: registry/capabilities/capability.agents.cli-assistant.md
tags:
- cli
- assistant
- llm
- safety
consumption_modes:
- cli

View File

@@ -66,6 +66,11 @@ def main(
"-n",
help="Preview mode — do not perform any actions (stub in T01).",
),
offline: bool = typer.Option(
False,
"--offline",
help="Use the deterministic FakeLLMAdapter (no llm-connect / no API keys).",
),
version: bool = typer.Option(
None,
"--version",
@@ -106,6 +111,53 @@ def main(
request,
explain_context=explain_context,
dry_run=dry_run,
offline=offline,
)
@app.command("shell")
def shell_command(
with_history: bool = typer.Option(
False,
"--with-history",
help="Opt in to capped, redacted shell-history context for this session.",
),
history_limit: int | None = typer.Option(
None,
"--history-limit",
help="Maximum history lines to include, capped at the configured safe limit.",
),
offline: bool = typer.Option(
False,
"--offline",
help="Use the deterministic FakeLLMAdapter for this shell session.",
),
hub_url: str = typer.Option(
"http://127.0.0.1:8000",
"--hub-url",
help="State Hub base URL for read-only session orientation.",
),
no_hub: bool = typer.Option(
False,
"--no-hub",
help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.",
),
offer_session_lessons: bool = typer.Option(
True,
"--session-lessons/--no-session-lessons",
help="Offer end-of-session Profile 1 reflection and session_turn memory capture.",
),
) -> None:
"""Start an interactive, stateful cya shell session."""
from cya.shell_session import run_shell
run_shell(
with_history=with_history,
history_limit=history_limit,
offline=offline,
hub_url=hub_url,
no_hub=no_hub,
offer_end_learning=offer_session_lessons,
)
@@ -189,8 +241,136 @@ def retrospect(
run_retrospection(scope=scope, limit=limit)
if __name__ == "__main__":
app()
def _dispatch_shell_argv(argv: list[str] | None = None) -> bool:
"""Handle `cya shell ...` before Typer's root REQUEST argument sees it."""
import argparse
argv = list(sys.argv if argv is None else argv)
if len(argv) < 2 or argv[1] != "shell":
return False
parser = argparse.ArgumentParser(
prog=f"{argv[0]} shell",
description="Start an interactive, stateful cya shell session.",
)
parser.add_argument(
"--with-history",
action="store_true",
help="Opt in to capped, redacted shell-history context for this session.",
)
parser.add_argument(
"--history-limit",
type=int,
default=None,
help="Maximum history lines to include, capped at the configured safe limit.",
)
parser.add_argument(
"--offline",
action="store_true",
help="Use the deterministic FakeLLMAdapter for this shell session.",
)
parser.add_argument(
"--hub-url",
default="http://127.0.0.1:8000",
help="State Hub base URL for read-only session orientation.",
)
parser.add_argument(
"--no-hub",
action="store_true",
help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.",
)
parser.add_argument(
"--session-lessons",
dest="offer_session_lessons",
action="store_true",
default=True,
help="Offer end-of-session Profile 1 reflection and session_turn memory capture.",
)
parser.add_argument(
"--no-session-lessons",
dest="offer_session_lessons",
action="store_false",
help="Skip end-of-session learning prompts.",
)
args = parser.parse_args(argv[2:])
from cya.shell_session import run_shell
run_shell(
with_history=args.with_history,
history_limit=args.history_limit,
offline=args.offline,
hub_url=args.hub_url,
no_hub=args.no_hub,
offer_end_learning=args.offer_session_lessons,
)
return True
def _dispatch_memory_argv(argv: list[str] | None = None) -> bool:
"""Handle `cya memory ...` before the root REQUEST argument sees it."""
import argparse
argv = list(sys.argv if argv is None else argv)
if len(argv) < 2 or argv[1] != "memory":
return False
parser = argparse.ArgumentParser(
prog=f"{argv[0]} memory",
description="Inspect and manage user-controlled memory.",
)
sub = parser.add_subparsers(dest="command", required=True)
reflections = sub.add_parser("reflections", help="List or export Profile 1 reflections.")
reflections.add_argument(
"--scope",
"-s",
default=".",
help="Directory or scope to list reflections for.",
)
reflections.add_argument(
"--json",
dest="export_json",
action="store_true",
help="Export reflections as JSON.",
)
args = parser.parse_args(argv[2:])
if args.command == "reflections":
memory_reflections(scope=args.scope, export_json=args.export_json)
return True
return False
def _dispatch_retrospect_argv(argv: list[str] | None = None) -> bool:
"""Handle `cya retrospect ...` before the root REQUEST argument sees it."""
import argparse
argv = list(sys.argv if argv is None else argv)
if len(argv) < 2 or argv[1] != "retrospect":
return False
parser = argparse.ArgumentParser(
prog=f"{argv[0]} retrospect",
description="Start a guided retrospection session.",
)
parser.add_argument(
"--scope",
"-s",
default=".",
help="Directory or scope to run retrospection on.",
)
parser.add_argument(
"--limit",
type=int,
default=8,
help="Number of recent memory items to review.",
)
args = parser.parse_args(argv[2:])
from cya.orchestrator import run_retrospection
run_retrospection(scope=args.scope, limit=args.limit)
return True
def run() -> None:
@@ -200,4 +380,10 @@ def run() -> None:
Using a thin wrapper around app() lets us keep the full-featured
@app.callback(invoke_without_command=True) + ctx signature for Typer/Click.
"""
if _dispatch_shell_argv() or _dispatch_memory_argv() or _dispatch_retrospect_argv():
return
app()
if __name__ == "__main__":
run()

263
src/cya/config.py Normal file
View File

@@ -0,0 +1,263 @@
"""User configuration for cya (CYA-WP-0008-T03).
Reads ``~/.config/cya/config.toml`` and optional project ``.cya.toml``.
Environment variables override file values where noted.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_USER_CONFIG = Path.home() / ".config" / "cya" / "config.toml"
_PROJECT_CONFIG_NAME = ".cya.toml"
# Session context bounds (CYA-WP-0008-T04) — documented in docs/llm-connect-integration.md
MAX_SESSION_TURNS = 10
MAX_SESSION_CHARS = 4000
DEFAULT_SHELL_HISTORY_LINES = 50
def _load_toml(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
if sys.version_info >= (3, 11):
import tomllib
return tomllib.loads(path.read_text())
import tomli
return tomli.loads(path.read_bytes())
def _find_project_config(start: Path | None = None) -> Path | None:
current = (start or Path.cwd()).resolve()
for directory in [current, *current.parents]:
candidate = directory / _PROJECT_CONFIG_NAME
if candidate.is_file():
return candidate
return None
def _merge_llm_sections(*sources: dict[str, Any]) -> dict[str, Any]:
merged: dict[str, Any] = {}
for source in sources:
section = source.get("llm")
if isinstance(section, dict):
merged.update(section)
return merged
def _merge_shell_history_sections(*sources: dict[str, Any]) -> dict[str, Any]:
merged: dict[str, Any] = {}
for source in sources:
section = source.get("shell_history")
if isinstance(section, dict):
merged.update(section)
return merged
@dataclass
class LLMSettings:
"""Resolved LLM adapter settings."""
adapter: str = "fake" # "fake" | "connect"
backend: str = "openrouter"
model: str | None = None
temperature: float = 0.3
max_tokens: int = 2000
api_key_env: str | None = None
system_prompt: str | None = None
configured: bool = False
source: str = "default"
def to_hints(self) -> dict[str, Any]:
hints: dict[str, Any] = {
"backend": self.backend,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
}
if self.model:
hints["model"] = self.model
if self.api_key_env:
hints["api_key_env"] = self.api_key_env
return hints
@dataclass
class ShellHistorySettings:
"""Resolved shell-history collection settings.
History is intentionally off unless enabled by the CLI or config. The line
limit is capped so a session cannot accidentally ingest an unbounded shell
history file.
"""
enabled: bool = False
limit: int = DEFAULT_SHELL_HISTORY_LINES
histfile: str | None = None
source: str = "default"
requested_limit: int | None = None
notes: list[str] = field(default_factory=list)
def _coerce_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _coerce_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y", "on"}:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
return default
def load_llm_settings(*, offline: bool = False) -> LLMSettings:
"""Resolve LLM settings from env, user config, and project config."""
if offline:
return LLMSettings(adapter="fake", configured=False, source="--offline")
env_adapter = os.environ.get("CYA_LLM_ADAPTER", "").strip().lower()
if env_adapter in ("fake", "connect"):
base = LLMSettings(adapter=env_adapter, configured=env_adapter == "connect", source="CYA_LLM_ADAPTER")
else:
base = LLMSettings()
user_data = _load_toml(_USER_CONFIG)
project_path = _find_project_config()
project_data = _load_toml(project_path) if project_path else {}
merged = _merge_llm_sections(user_data, project_data)
if merged:
file_adapter = str(merged.get("adapter", "")).strip().lower()
if file_adapter in ("fake", "connect") and not env_adapter:
base.adapter = file_adapter
base.configured = file_adapter == "connect"
base.source = str(project_path or _USER_CONFIG)
backend = merged.get("backend") or merged.get("provider")
if backend:
base.backend = str(backend)
if not env_adapter and file_adapter != "fake":
base.adapter = "connect"
base.configured = True
base.source = str(project_path or _USER_CONFIG)
if merged.get("model"):
base.model = str(merged["model"])
base.temperature = _coerce_float(merged.get("temperature"), base.temperature)
base.max_tokens = _coerce_int(merged.get("max_tokens"), base.max_tokens)
if merged.get("api_key_env"):
base.api_key_env = str(merged["api_key_env"])
if merged.get("system_prompt"):
base.system_prompt = str(merged["system_prompt"])
env_backend = os.environ.get("CYA_LLM_BACKEND") or os.environ.get("CYA_LLM_PROVIDER")
if env_backend:
base.backend = env_backend.strip()
if base.adapter != "fake":
base.adapter = "connect"
base.configured = True
base.source = "CYA_LLM_BACKEND"
env_model = os.environ.get("CYA_LLM_MODEL")
if env_model:
base.model = env_model.strip()
if base.adapter != "fake":
base.adapter = "connect"
base.configured = True
base.source = "CYA_LLM_MODEL"
return base
def load_shell_history_settings(
*,
with_history: bool = False,
history_limit: int | None = None,
) -> ShellHistorySettings:
"""Resolve optional shell-history collection settings.
The default is always disabled. Config can enable history with:
``[shell_history] enabled = true``
The CLI ``--with-history`` flag also enables it for just that shell session.
"""
user_data = _load_toml(_USER_CONFIG)
project_path = _find_project_config()
project_data = _load_toml(project_path) if project_path else {}
merged = _merge_shell_history_sections(user_data, project_data)
settings = ShellHistorySettings()
if merged:
source = str(project_path or _USER_CONFIG)
settings.enabled = _coerce_bool(merged.get("enabled"), False)
settings.limit = _coerce_int(merged.get("limit"), settings.limit)
settings.histfile = str(merged["histfile"]) if merged.get("histfile") else None
settings.source = source
if with_history:
settings.enabled = True
settings.source = "--with-history"
if history_limit is not None:
settings.requested_limit = history_limit
settings.limit = _coerce_int(history_limit, settings.limit)
if with_history:
settings.source = "--with-history"
if settings.limit < 0:
settings.notes.append("Negative history limit was treated as 0.")
settings.limit = 0
if settings.limit > DEFAULT_SHELL_HISTORY_LINES:
settings.notes.append(
f"History limit capped at {DEFAULT_SHELL_HISTORY_LINES} lines."
)
settings.limit = DEFAULT_SHELL_HISTORY_LINES
return settings
def bound_session_turns(
turns: list[dict[str, str]] | None,
*,
max_turns: int = MAX_SESSION_TURNS,
max_chars: int = MAX_SESSION_CHARS,
) -> list[dict[str, str]]:
"""Trim session history to a bounded token/line budget for the adapter."""
if not turns:
return []
recent = turns[-max_turns:]
bounded: list[dict[str, str]] = []
used = 0
for turn in recent:
user = turn.get("user", "")
assistant = turn.get("assistant", "")
chunk_len = len(user) + len(assistant)
if used + chunk_len > max_chars and bounded:
break
bounded.append({"user": user, "assistant": assistant})
used += chunk_len
return bounded

View File

@@ -17,11 +17,15 @@ from .adapter import (
LLMAdapter,
FakeLLMAdapter,
)
from .connect_adapter import LLMConnectAdapter
from .factory import get_adapter
__all__ = [
"AssistanceRequest",
"AssistanceResponse",
"LLMAdapter",
"FakeLLMAdapter",
"LLMConnectAdapter",
"get_adapter",
]

View File

@@ -0,0 +1,138 @@
"""llm-connect-backed adapter (CYA-WP-0008-T02)."""
from __future__ import annotations
from typing import Any
from cya.config import LLMSettings
from cya.llm.adapter import AssistanceRequest, AssistanceResponse
from cya.llm.prompt import build_assistance_prompt
_PROVIDER_ENV_KEYS: dict[str, str] = {
"openrouter": "OPENROUTER_API_KEY",
"openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY",
}
class LLMConnectAdapter:
"""Delegates to llm-connect while satisfying cya's LLMAdapter protocol."""
def __init__(self, settings: LLMSettings) -> None:
self._settings = settings
self._client: Any | None = None
self._init_error: str | None = None
self._ensure_client()
def _ensure_client(self) -> None:
try:
from llm_connect import create_adapter
from llm_connect.config import resolve_api_key
except ImportError:
self._init_error = (
"llm-connect is not installed. Install with:\n"
" pip install -e ~/llm-connect\n"
"or: pip install -e \".[llm]\" after adding llm-connect to your environment."
)
return
env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get(
self._settings.backend, "OPENROUTER_API_KEY"
)
api_key = resolve_api_key(env_var=env_var)
if self._settings.backend in ("openrouter", "openai", "gemini") and not api_key:
self._init_error = (
f"No API key found for backend {self._settings.backend!r} "
f"(checked env {env_var!r}).\n"
"Route credential custody via warden before requesting secrets:\n"
" warden route find \"OpenRouter API key\" --json\n"
"Then export the key into your environment — never commit it to the repo."
)
return
try:
self._client = create_adapter(
provider=self._settings.backend,
model=self._settings.model,
api_key=api_key,
system_prompt=self._settings.system_prompt,
)
except Exception as exc: # noqa: BLE001 — surface config errors to the user
self._init_error = f"Failed to initialize llm-connect adapter: {exc}"
def complete(self, request: AssistanceRequest) -> AssistanceResponse:
if self._init_error or self._client is None:
return self._degraded_response(self._init_error or "llm-connect client unavailable.")
try:
from llm_connect.models import RunConfig
except ImportError:
return self._degraded_response(self._init_error or "llm-connect not installed.")
system, user_prompt = build_assistance_prompt(
request,
system_prompt=self._settings.system_prompt,
)
hints = {**self._settings.to_hints(), **request.hints}
run_config = RunConfig(
model_name=hints.get("model") or self._settings.model or "anthropic/claude-sonnet-4",
temperature=float(hints.get("temperature", self._settings.temperature)),
max_tokens=int(hints.get("max_tokens", self._settings.max_tokens)),
)
# Re-create adapter when per-request system prompt differs (llm-connect stores it at init).
client = self._client
if system and not self._settings.system_prompt:
from llm_connect import create_adapter
from llm_connect.config import resolve_api_key
env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get(
self._settings.backend, "OPENROUTER_API_KEY"
)
api_key = resolve_api_key(env_var=env_var)
client = create_adapter(
provider=self._settings.backend,
model=self._settings.model,
api_key=api_key,
system_prompt=system,
)
try:
llm_response = client.execute_prompt(user_prompt, run_config)
except Exception as exc: # noqa: BLE001 — user-facing degrade path
return self._degraded_response(
f"llm-connect request failed: {exc}",
partial_raw=str(exc),
)
content = (llm_response.content or "").strip()
return AssistanceResponse(
suggestion=content or "(empty model response)",
explanation="Response generated via llm-connect.",
rationale="Model inference using configured backend and bounded local context.",
risks=[],
raw_model_output=content,
metadata={
"adapter": "LLMConnectAdapter",
"backend": self._settings.backend,
"model": llm_response.model,
"usage": llm_response.usage,
"finish_reason": llm_response.finish_reason,
},
)
@staticmethod
def _degraded_response(message: str, *, partial_raw: str | None = None) -> AssistanceResponse:
return AssistanceResponse(
suggestion=(
"cya could not reach a configured LLM backend.\n\n"
f"{message}\n\n"
"Continuing in offline mode: re-run with `--offline` or configure "
"`~/.config/cya/config.toml` (see README)."
),
explanation="Graceful degradation — no live inference was performed.",
rationale="llm-connect unavailable or misconfigured.",
risks=["No live model inference"],
raw_model_output=partial_raw,
metadata={"adapter": "LLMConnectAdapter", "degraded": True},
)

18
src/cya/llm/factory.py Normal file
View File

@@ -0,0 +1,18 @@
"""Adapter selection factory (CYA-WP-0008-T04)."""
from __future__ import annotations
from cya.config import LLMSettings, load_llm_settings
from cya.llm.adapter import FakeLLMAdapter, LLMAdapter
from cya.llm.connect_adapter import LLMConnectAdapter
def get_adapter(*, offline: bool = False, settings: LLMSettings | None = None) -> LLMAdapter:
"""Return the active LLMAdapter for one-shot and shell code paths."""
resolved = settings or load_llm_settings(offline=offline)
if resolved.adapter == "connect":
return LLMConnectAdapter(resolved)
return FakeLLMAdapter()
__all__ = ["get_adapter", "load_llm_settings"]

73
src/cya/llm/prompt.py Normal file
View File

@@ -0,0 +1,73 @@
"""Prompt construction for llm-connect delegation (CYA-WP-0008)."""
from __future__ import annotations
import json
from typing import Any
from cya.llm.adapter import AssistanceRequest
_DEFAULT_SYSTEM = """You are cya, a console-native assistant for practical local work from the shell.
Help the user with command-line tasks: repository inspection, file workflows, command
suggestion, command explanation, and local context summarization.
Be concise and practical. When suggesting shell commands, explain risks briefly.
Do not claim to have executed anything — the user runs commands themselves.
Reference the provided context when it is relevant."""
def default_system_prompt() -> str:
return _DEFAULT_SYSTEM
def build_assistance_prompt(request: AssistanceRequest, *, system_prompt: str | None = None) -> tuple[str, str]:
"""Return (system_prompt, user_prompt) for llm-connect execute_prompt."""
system = system_prompt or default_system_prompt()
parts: list[str] = []
context = request.context or {}
session_turns = context.get("session_turns")
if session_turns:
parts.append("## Recent conversation")
for turn in session_turns:
parts.append(f"User: {turn.get('user', '')}")
parts.append(f"Assistant: {turn.get('assistant', '')}")
envelope = {k: v for k, v in context.items() if k not in ("session_turns", "memory")}
if envelope:
parts.append("## Local context")
parts.append(_summarize_context(envelope))
memory = context.get("memory")
if isinstance(memory, dict) and memory.get("items"):
parts.append("## Activated memory")
for item in memory["items"][:8]:
parts.append(f"- [{item.get('kind', '?')}] {item.get('key', '?')}: {item.get('value', '')}")
parts.append("## Current request")
parts.append(request.user_request.strip())
return system, "\n\n".join(parts)
def _summarize_context(envelope: dict[str, Any]) -> str:
"""Compact, JSON-safe context summary to stay within prompt budget."""
summary: dict[str, Any] = {}
if envelope.get("cwd"):
summary["cwd"] = envelope["cwd"]
if envelope.get("git"):
git = envelope["git"]
summary["git"] = {
k: git[k]
for k in ("branch", "status_short", "workdir", "is_repo")
if k in git
}
if envelope.get("top_level"):
names = [e.get("name") for e in envelope["top_level"][:30] if e.get("name")]
summary["top_level"] = names
if envelope.get("env"):
summary["env"] = envelope["env"]
if envelope.get("notes"):
summary["notes"] = envelope["notes"][:5]
return json.dumps(summary, indent=2, default=str)

View File

@@ -39,6 +39,7 @@ KIND_PREFERENCE = "preference"
KIND_RETROSPECTION = "retrospection"
KIND_INTERACTION_GOAL = "interaction_goal"
KIND_REFLECTION = "reflection" # Profile 1 (Reflexion-style verbal lessons) — T05 minimal spike
KIND_SESSION_TURN = "session_turn" # CYA-WP-0007 episodic shell turn precursor
def _warn_not_connected(feature: str) -> None:
@@ -311,5 +312,6 @@ __all__ = [
"KIND_RETROSPECTION",
"KIND_INTERACTION_GOAL",
"KIND_REFLECTION",
"KIND_SESSION_TURN",
]

View File

@@ -23,7 +23,9 @@ See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect)
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import typer
from rich.console import Console
@@ -48,18 +50,58 @@ from cya.memory.reflections import (
session_provenance,
)
from cya.safety.risk import classify, get_user_confirmation
from cya.llm.adapter import AssistanceRequest, FakeLLMAdapter
from cya.config import bound_session_turns
from cya.llm.adapter import AssistanceRequest
from cya.llm.factory import get_adapter
console = Console()
@dataclass
class OrchestratorResult:
"""Structured result from a single assistance turn.
CLI callers still receive the rich terminal rendering. Interactive clients
use this object to persist session turns and explain the last turn.
"""
user_request: str
assistant: str
explanation: str = ""
rationale: str = ""
context: dict[str, Any] = field(default_factory=dict)
memory: dict[str, Any] = field(default_factory=dict)
risk: dict[str, Any] = field(default_factory=dict)
cancelled: bool = False
dry_run: bool = False
def _build_assistance_context(
envelope: Any,
memory: dict[str, Any],
*,
session_turns: list[dict[str, str]] | None = None,
extra_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
ctx = (envelope.to_dict() if envelope else {}) or {}
ctx["memory"] = memory
if session_turns:
ctx["session_turns"] = bound_session_turns(session_turns)
if extra_context:
ctx["session"] = extra_context
return ctx
def handle_request(
user_request: str,
*,
explain_context: bool = False,
dry_run: bool = False,
) -> None:
offline: bool = False,
session_turns: list[dict[str, str]] | None = None,
extra_context: dict[str, Any] | None = None,
) -> OrchestratorResult:
"""Primary orchestrator entry point.
This is what the CLI (and future tests / other front-ends) should call.
@@ -131,8 +173,29 @@ def handle_request(
except Exception:
pass
if explain_context and extra_context:
try:
import json
console.print(
Panel(
json.dumps(extra_context, indent=2, default=str),
title="Shell Session Context",
border_style="cyan",
padding=(0, 1),
)
)
except Exception:
pass
# 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals)
assessment = classify(user_request, envelope, memory=memory)
ctx = _build_assistance_context(
envelope,
memory,
session_turns=session_turns,
extra_context=extra_context,
)
if assessment.requires_confirmation:
from rich.table import Table
@@ -151,17 +214,31 @@ def handle_request(
console.print(table)
if not get_user_confirmation(assessment):
console.print("[yellow]Action cancelled by user. No changes made.[/yellow]")
return
msg = "Action cancelled by user. No changes made."
console.print(f"[yellow]{msg}[/yellow]")
return OrchestratorResult(
user_request=user_request,
assistant=msg,
context=ctx,
memory=memory,
risk=assessment.to_dict(),
cancelled=True,
)
if dry_run:
console.print("[green]--dry-run acknowledged.[/green] No side-effects.")
return
msg = "--dry-run acknowledged. No side-effects."
console.print(f"[green]{msg}[/green]")
return OrchestratorResult(
user_request=user_request,
assistant=msg,
context=ctx,
memory=memory,
risk=assessment.to_dict(),
dry_run=True,
)
# 3. Call through the single LLMAdapter boundary (T04)
adapter = FakeLLMAdapter()
ctx = (envelope.to_dict() if envelope else {}) or {}
ctx["memory"] = memory # T03: memory now in context passed to LLM (for personalization + explain)
# 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008)
adapter = get_adapter(offline=offline)
llm_request = AssistanceRequest(
user_request=user_request,
context=ctx,
@@ -191,6 +268,16 @@ def handle_request(
"[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)."
)
return OrchestratorResult(
user_request=user_request,
assistant=llm_response.suggestion,
explanation=llm_response.explanation,
rationale=llm_response.rationale,
context=ctx,
memory=memory,
risk=assessment.to_dict(),
)
def run_retrospection(scope: str = ".", limit: int = 8) -> None:
"""Guided retrospection session (T04 of CYA-WP-0003).
@@ -382,4 +469,4 @@ def _offer_reflection_compaction(scope: str) -> None:
)
__all__ = ["handle_request", "run_retrospection"]
__all__ = ["OrchestratorResult", "handle_request", "run_retrospection"]

862
src/cya/shell_session.py Normal file
View File

@@ -0,0 +1,862 @@
"""Interactive shell session runtime for ``cya shell`` (CYA-WP-0007).
The shell is a thin interactive client for the existing orchestrator. It owns
session-local state, optional history context, State Hub orientation, slash
commands, JSONL persistence, and user-triggered learning/export flows.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import sys
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib import parse, request
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cya.config import ShellHistorySettings, load_shell_history_settings
from cya.memory import KIND_SESSION_TURN, remember_preference
from cya.memory.reflections import (
REFLECTION_CAPTURE_PROMPTS,
collect_lessons_from_answers,
preview_lessons,
save_reflection_lessons,
)
from cya.orchestrator import OrchestratorResult, handle_request
TOPIC_ID = "64418556-3206-457a-ba29-6884b5b12cf3"
DEFAULT_AGENT = "can-you-assist"
DEFAULT_HUB_URL = os.environ.get("CYA_STATE_HUB_URL", "http://127.0.0.1:8000")
@dataclass
class ShellHistoryContext:
enabled: bool
source: str
limit: int
histfile: str | None = None
lines: list[dict[str, Any]] = field(default_factory=list)
redactions: int = 0
notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class HubOrientation:
base_url: str
topic_id: str
agent: str
brief: str | None = None
active_workstreams: list[dict[str, Any]] = field(default_factory=list)
inbox: list[dict[str, Any]] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
remote_checked: bool = False
@property
def hub_available(self) -> bool:
return self.remote_checked and not self.errors
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["hub_available"] = self.hub_available
return data
def compact(self) -> dict[str, Any]:
return {
"base_url": self.base_url,
"topic_id": self.topic_id,
"agent": self.agent,
"hub_available": self.hub_available,
"active_workstream_count": len(self.active_workstreams),
"inbox_count": len(self.inbox),
"errors": self.errors,
"workstreams": [
{
"id": w.get("id") or w.get("workstream_id"),
"title": w.get("title") or w.get("name"),
"status": w.get("status"),
}
for w in self.active_workstreams[:5]
],
}
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET)[A-Z0-9_]*)"
r"(\s*=\s*)([^\s;]+)"
),
re.compile(r"(?i)\b(--(?:api-key|token|password|secret)\s+)([^\s;]+)"),
re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{12,})\b"),
re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{12,})\b"),
re.compile(r"\b(AKIA[0-9A-Z]{12,})\b"),
)
InputFunc = Callable[[str], str]
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def make_session_id() -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return f"cya-{stamp}-{uuid.uuid4().hex[:8]}"
def session_root() -> Path:
root = Path.home() / ".config" / "cya" / "sessions"
root.mkdir(parents=True, exist_ok=True)
return root
def default_session_path(session_id: str) -> Path:
return session_root() / f"{session_id}.jsonl"
def redact_secrets(text: str) -> tuple[str, int]:
redacted = text
count = 0
def _replace(match: re.Match[str]) -> str:
groups = match.groups()
if len(groups) >= 3:
return f"{groups[0]}{groups[1]}[REDACTED]"
return "[REDACTED_SECRET]"
for pattern in _SECRET_PATTERNS:
redacted, n = pattern.subn(_replace, redacted)
count += n
return redacted, count
def _normalize_history_line(line: str) -> str:
text = line.strip()
if text.startswith(": ") and ";" in text:
return text.split(";", 1)[1].strip()
if text.startswith("- cmd: "):
return text.removeprefix("- cmd: ").strip()
return text
def _default_histfile(env: dict[str, str], home: Path) -> Path | None:
explicit = env.get("HISTFILE")
if explicit:
return Path(explicit).expanduser()
for candidate in (
home / ".bash_history",
home / ".zsh_history",
home / ".local" / "share" / "fish" / "fish_history",
):
if candidate.is_file():
return candidate
return None
def collect_shell_history(
settings: ShellHistorySettings,
*,
env: dict[str, str] | None = None,
home: Path | None = None,
) -> ShellHistoryContext:
env = env or os.environ
home = home or Path.home()
ctx = ShellHistoryContext(
enabled=settings.enabled,
source=settings.source,
limit=settings.limit,
histfile=settings.histfile,
notes=list(settings.notes),
)
if not settings.enabled:
ctx.notes.append("Shell history disabled; no history was collected.")
return ctx
if settings.limit <= 0:
ctx.notes.append("Shell history enabled with limit 0; no lines collected.")
return ctx
histfile = Path(settings.histfile).expanduser() if settings.histfile else _default_histfile(env, home)
if not histfile:
ctx.notes.append("No shell history file found from HISTFILE or known fallbacks.")
return ctx
ctx.histfile = str(histfile)
try:
raw = histfile.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as exc:
ctx.notes.append(f"Could not read history file: {exc}")
return ctx
normalized = [_normalize_history_line(line) for line in raw]
normalized = [line for line in normalized if line]
start = max(0, len(normalized) - settings.limit)
for offset, line in enumerate(normalized[start:], start=start + 1):
clean, count = redact_secrets(line[:1000])
ctx.redactions += count
ctx.lines.append(
{
"line_number": offset,
"command": clean,
"source": str(histfile),
"provenance": "shell_history.histfile",
}
)
if ctx.redactions:
ctx.notes.append(f"Redacted {ctx.redactions} secret-like value(s).")
return ctx
def render_shell_history_explanation(history: ShellHistoryContext) -> str:
lines = [
f"enabled: {history.enabled}",
f"source: {history.source}",
f"limit: {history.limit}",
f"histfile: {history.histfile or '(none)'}",
f"lines: {len(history.lines)}",
]
if history.notes:
lines.append("notes:")
lines.extend(f" - {note}" for note in history.notes)
if history.lines:
lines.append("commands:")
for item in history.lines:
lines.append(
f" {item['line_number']}: {item['command']} ({item['provenance']})"
)
return "\n".join(lines)
def _read_json_url(url: str, *, timeout: float) -> Any:
req = request.Request(url, headers={"User-Agent": "cya-shell/0.1"})
with request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8")
if not data.strip():
return []
return json.loads(data)
def _post_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
body = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=body,
method="POST",
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
)
with request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8")
if not data.strip():
return {}
return json.loads(data)
def _patch_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
body = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=body,
method="PATCH",
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
)
with request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode("utf-8")
if not data.strip():
return {}
return json.loads(data)
def _as_list(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
for key in ("items", "results", "data"):
if isinstance(value.get(key), list):
return [item for item in value[key] if isinstance(item, dict)]
return []
def load_hub_orientation(
*,
cwd: Path | None = None,
base_url: str = DEFAULT_HUB_URL,
topic_id: str = TOPIC_ID,
agent: str = DEFAULT_AGENT,
timeout: float = 1.5,
include_remote: bool = True,
) -> HubOrientation:
cwd = cwd or Path.cwd()
orientation = HubOrientation(base_url=base_url.rstrip("/"), topic_id=topic_id, agent=agent)
brief = cwd / ".custodian-brief.md"
if brief.is_file():
try:
orientation.brief = brief.read_text(encoding="utf-8", errors="replace")[:6000]
except OSError as exc:
orientation.errors.append(f"brief read failed: {exc}")
if not include_remote:
orientation.errors.append("State Hub remote orientation skipped by --no-hub.")
return orientation
try:
qs = parse.urlencode({"topic_id": topic_id, "status": "active"})
data = _read_json_url(f"{orientation.base_url}/workstreams/?{qs}", timeout=timeout)
orientation.active_workstreams = _as_list(data)
orientation.remote_checked = True
except Exception as exc:
orientation.errors.append(f"workstreams unavailable: {exc}")
try:
qs = parse.urlencode({"to_agent": agent, "unread_only": "true"})
data = _read_json_url(f"{orientation.base_url}/messages/?{qs}", timeout=timeout)
orientation.inbox = _as_list(data)
orientation.remote_checked = True
except Exception as exc:
orientation.errors.append(f"inbox unavailable: {exc}")
return orientation
def render_hub_summary(orientation: HubOrientation) -> str:
lines: list[str] = []
if orientation.brief:
for line in orientation.brief.splitlines():
clean = line.strip("# ").strip()
if clean:
lines.append(clean)
break
if orientation.remote_checked:
lines.append(f"Active workstreams: {len(orientation.active_workstreams)}")
lines.append(f"Unread inbox messages: {len(orientation.inbox)}")
else:
lines.append("State Hub remote orientation not available.")
if orientation.errors:
lines.append("Notes: " + "; ".join(orientation.errors[:2]))
return "\n".join(lines)
def render_hub_details(orientation: HubOrientation) -> str:
lines = [render_hub_summary(orientation), ""]
if orientation.active_workstreams:
lines.append("Workstreams:")
for ws in orientation.active_workstreams[:8]:
title = ws.get("title") or ws.get("name") or "(untitled)"
ident = ws.get("id") or ws.get("workstream_id") or "?"
status = ws.get("status") or "?"
lines.append(f" - {title} [{status}] {ident}")
tasks = ws.get("tasks") or ws.get("open_tasks") or []
if isinstance(tasks, list):
for task in tasks[:5]:
if isinstance(task, dict):
t_title = task.get("title") or task.get("name") or "(task)"
t_status = task.get("status") or "?"
lines.append(f" * {t_title} [{t_status}]")
if orientation.inbox:
lines.append("\nInbox:")
for msg in orientation.inbox[:10]:
title = msg.get("title") or msg.get("summary") or msg.get("subject") or "(message)"
ident = msg.get("id") or "?"
lines.append(f" - {ident}: {title}")
if orientation.errors:
lines.append("\nErrors:")
lines.extend(f" - {err}" for err in orientation.errors)
return "\n".join(lines).strip()
def post_progress(summary: str, orientation: HubOrientation) -> dict[str, Any]:
return _post_json_url(
f"{orientation.base_url}/progress/",
{"summary": summary, "event_type": "note", "author": "cya-shell"},
)
def mark_message_read(message_id: str, orientation: HubOrientation) -> dict[str, Any]:
return _patch_json_url(f"{orientation.base_url}/messages/{message_id}/read", {})
def detect_weakness_hints(
user_text: str,
assistant_text: str = "",
*,
risk: dict[str, Any] | None = None,
turn_history: list[dict[str, Any]] | None = None,
hub: HubOrientation | None = None,
) -> list[dict[str, str]]:
text = f"{user_text}\n{assistant_text}".lower()
risk = risk or {}
turn_history = turn_history or []
hints: list[dict[str, str]] = []
secret_terms = ("api key", "token", "password", "secret", "openrouter", "ops_hub_key")
if any(term in text for term in secret_terms) and "warden route" not in text:
hints.append(
{
"rule": "credential-routing",
"title": "Credential routing",
"message": "Route secret custody first with warden route/OpenBao; do not paste keys into prompts, Git, State Hub, or logs.",
}
)
if "ops-warden" in text and any(term in text for term in secret_terms):
hints.append(
{
"rule": "ops-warden-secret-anti-pattern",
"title": "ops-warden boundary",
"message": "ops-warden issues SSH certificates only; API keys and passwords belong to their routed owner.",
}
)
if re.search(r"\b(curl|wget)\b.*\|\s*(bash|sh|zsh|python)", text, re.S):
hints.append(
{
"rule": "remote-exec-review",
"title": "Remote execution review",
"message": "Inspect downloaded content before execution and keep the safety confirmation gate intact.",
}
)
level = str(risk.get("level", "")).lower()
previous_destructive = any(
str(turn.get("risk", "")).lower() in {"destructive", "mass_edit"}
for turn in turn_history[-3:]
)
if level in {"destructive", "mass_edit"} and previous_destructive:
hints.append(
{
"rule": "repeated-destructive-intent",
"title": "Repeated destructive intent",
"message": "Repeated destructive or mass-edit turns are a cue to slow down and verify scope before proceeding.",
}
)
wp_tokens = set(re.findall(r"\b[A-Z]{2,5}-WP-\d{4}\b", user_text.upper()))
if wp_tokens and hub is not None:
haystack = json.dumps(hub.compact(), default=str).upper()
missing = sorted(token for token in wp_tokens if token not in haystack)
if missing:
hints.append(
{
"rule": "state-hub-alignment",
"title": "State Hub alignment",
"message": f"{', '.join(missing)} was not visible in the loaded hub summary; confirm the local workplan file is the source of truth.",
}
)
return hints
def render_weakness_hints(hints: list[dict[str, str]]) -> str:
lines = []
for hint in hints:
lines.append(f"[{hint['rule']}] {hint['message']}")
return "\n".join(lines)
class ShellSession:
def __init__(
self,
*,
session_id: str | None = None,
offline: bool = False,
history: ShellHistoryContext | None = None,
hub: HubOrientation | None = None,
console: Console | None = None,
input_func: InputFunc | None = None,
interactive: bool | None = None,
offer_end_learning: bool = True,
) -> None:
self.session_id = session_id or make_session_id()
self.path = default_session_path(self.session_id)
self.offline = offline
self.history = history or ShellHistoryContext(False, "default", 0)
self.hub = hub or HubOrientation(DEFAULT_HUB_URL, TOPIC_ID, DEFAULT_AGENT)
self.console = console or Console()
self.input_func = input_func or input
self.interactive = sys.stdin.isatty() if interactive is None else interactive
self.offer_end_learning = offer_end_learning
self.turns: list[dict[str, Any]] = []
self.last_result: OrchestratorResult | None = None
self.last_hints: list[dict[str, str]] = []
self._started = False
def append_event(self, event: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(event, default=str) + "\n")
def start(self) -> None:
if self._started:
return
self._started = True
self.append_event(
{
"kind": "session_start",
"session_id": self.session_id,
"created_at": utc_now(),
"cwd": str(Path.cwd()),
"history": self.history.to_dict(),
"hub": self.hub.compact(),
}
)
body = (
f"Session: {self.session_id}\n"
f"Artifact: {self.path}\n\n"
f"{render_hub_summary(self.hub)}\n\n"
f"History lines: {len(self.history.lines)}"
)
self.console.print(Panel(body, title="cya shell", border_style="cyan"))
self.console.print("Type /help for commands, /exit to finish.")
try:
import readline # noqa: F401
except Exception:
pass
def run(self) -> Path:
self.start()
while True:
try:
line = self.input_func("cya> ")
except KeyboardInterrupt:
self.console.print("[yellow]Interrupted. Use /exit to end the session.[/yellow]")
continue
except EOFError:
self.console.print("[dim]EOF received; ending session.[/dim]")
break
text = line.strip()
if not text:
continue
if text.startswith("/"):
keep_going = self.handle_command(text)
if not keep_going:
break
continue
self.handle_turn(text)
self.close()
return self.path
def handle_turn(self, text: str) -> OrchestratorResult:
extra_context = {
"session_id": self.session_id,
"session_path": str(self.path),
"shell_history": self.history.to_dict(),
"hub_orientation": self.hub.compact(),
}
result = handle_request(
text,
offline=self.offline,
session_turns=list(self.turns),
extra_context=extra_context,
)
hints = detect_weakness_hints(
text,
result.assistant,
risk=result.risk,
turn_history=self.turns,
hub=self.hub,
)
if hints:
self.console.print(
Panel(render_weakness_hints(hints), title="Weakness Hints", border_style="yellow")
)
clean_user, red_user = redact_secrets(text)
clean_assistant, red_assistant = redact_secrets(result.assistant)
risk_level = result.risk.get("level", "safe")
turn = {
"user": text,
"assistant": result.assistant,
"risk": risk_level,
}
self.turns.append(turn)
self.last_result = result
self.last_hints = hints
self.append_event(
{
"kind": KIND_SESSION_TURN,
"session_id": self.session_id,
"turn_index": len(self.turns),
"created_at": utc_now(),
"user": clean_user,
"assistant": clean_assistant,
"risk": result.risk,
"cancelled": result.cancelled,
"dry_run": result.dry_run,
"weakness_hints": hints,
"redactions": red_user + red_assistant,
}
)
return result
def handle_command(self, text: str) -> bool:
try:
parts = shlex.split(text)
except ValueError as exc:
self.console.print(f"[red]Could not parse command: {exc}[/red]")
return True
if not parts:
return True
cmd = parts[0].lower()
if cmd in {"/exit", "/quit"}:
return False
if cmd == "/help":
self.show_help()
return True
if cmd == "/explain":
self.show_explain()
return True
if cmd == "/hub":
self.handle_hub(parts)
return True
if cmd == "/inbox":
self.handle_inbox(parts)
return True
if cmd == "/export-session":
self.export_session()
return True
if cmd == "/learn":
self.capture_reflections()
return True
self.console.print(f"[yellow]Unknown command {parts[0]!r}. Try /help.[/yellow]")
return True
def show_help(self) -> None:
self.console.print(
Panel(
"\n".join(
[
"/help - show commands",
"/explain - show context, risk, history, hub, and hints for the last turn",
"/hub - show active State Hub workstreams and tasks",
"/hub log \"summary\" - post a progress note after confirmation",
"/inbox - show unread State Hub messages",
"/inbox read <id> - explicitly mark a message read",
"/export-session - write a redacted session summary JSON",
"/learn - capture Profile 1 reflections now",
"/exit - close the shell session",
]
),
title="cya shell commands",
border_style="cyan",
)
)
def show_explain(self) -> None:
if not self.last_result:
self.console.print("[yellow]No turns yet.[/yellow]")
self.console.print(
Panel(
render_shell_history_explanation(self.history),
title="Shell History Context",
border_style="cyan",
)
)
return
payload = {
"session_id": self.session_id,
"risk": self.last_result.risk,
"context": self.last_result.context,
"weakness_hints": self.last_hints,
}
self.console.print(
Panel(
json.dumps(payload, indent=2, default=str),
title="Last Turn Explanation",
border_style="green",
)
)
def handle_hub(self, parts: list[str]) -> None:
if len(parts) == 1:
self.console.print(Panel(render_hub_details(self.hub), title="State Hub"))
return
if len(parts) >= 3 and parts[1].lower() == "log":
summary = " ".join(parts[2:]).strip()
if not summary:
self.console.print("[yellow]Usage: /hub log \"summary\"[/yellow]")
return
if not self.interactive:
self.console.print("[yellow]Hub writes require an interactive confirmation.[/yellow]")
return
if not typer.confirm(f"Post progress note to {self.hub.base_url}?", default=False):
self.console.print("[yellow]Progress note not posted.[/yellow]")
return
try:
result = post_progress(summary, self.hub)
self.console.print(f"[green]Posted progress note.[/green] {result}")
except Exception as exc:
self.console.print(f"[red]Progress post failed: {exc}[/red]")
return
self.console.print("[yellow]Usage: /hub or /hub log \"summary\"[/yellow]")
def handle_inbox(self, parts: list[str]) -> None:
if len(parts) == 1:
body = "No unread messages." if not self.hub.inbox else render_hub_details(self.hub)
self.console.print(Panel(body, title="State Hub Inbox"))
return
if len(parts) == 3 and parts[1].lower() == "read":
message_id = parts[2]
if not self.interactive:
self.console.print("[yellow]Mark-read requires an interactive session.[/yellow]")
return
try:
result = mark_message_read(message_id, self.hub)
self.console.print(f"[green]Marked message read.[/green] {result}")
except Exception as exc:
self.console.print(f"[red]Mark-read failed: {exc}[/red]")
return
self.console.print("[yellow]Usage: /inbox or /inbox read <id>[/yellow]")
def export_session(self) -> Path:
out = self.path.with_name(f"{self.session_id}-summary.json")
turns = []
for i, turn in enumerate(self.turns, 1):
user, user_redactions = redact_secrets(str(turn.get("user", "")))
assistant, assistant_redactions = redact_secrets(str(turn.get("assistant", "")))
turns.append(
{
"turn_index": i,
"user": user,
"assistant": assistant,
"risk": turn.get("risk"),
"redactions": user_redactions + assistant_redactions,
}
)
payload = {
"kind": "session_export",
"session_id": self.session_id,
"exported_at": utc_now(),
"session_path": str(self.path),
"turn_count": len(turns),
"turns": turns,
"history": self.history.to_dict(),
"hub": self.hub.compact(),
}
out.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
self.console.print(f"[green]Exported session summary:[/green] {out}")
self.append_event(
{"kind": "session_export", "session_id": self.session_id, "path": str(out), "created_at": utc_now()}
)
return out
def capture_reflections(self) -> int:
if not self.interactive:
self.console.print("[yellow]Reflection capture needs an interactive terminal.[/yellow]")
return 0
answers: dict[str, str] = {}
for prompt_key, label in REFLECTION_CAPTURE_PROMPTS:
answers[prompt_key] = typer.prompt(label, default="", show_default=False)
lessons = collect_lessons_from_answers(answers)
if not lessons:
self.console.print("[yellow]No lessons captured.[/yellow]")
return 0
self.console.print(Panel(preview_lessons(lessons), title="Preview lessons"))
if not typer.confirm("Save these lessons?", default=False):
self.console.print("[yellow]Lessons discarded.[/yellow]")
return 0
count = save_reflection_lessons(
lessons,
".",
provenance={
"session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"scope": ".",
"source": "cya shell",
"session_id": self.session_id,
},
)
self.console.print(f"[green]Saved {count} reflection(s).[/green]")
return count
def remember_turns(self) -> int:
count = 0
for i, turn in enumerate(self.turns, 1):
user, _ = redact_secrets(str(turn.get("user", "")))
assistant, _ = redact_secrets(str(turn.get("assistant", "")))
remember_preference(
f"session_turn_{self.session_id}_{i}",
{"user": user, "assistant": assistant, "risk": turn.get("risk")},
scope=".",
kind=KIND_SESSION_TURN,
provenance={"source": "cya shell", "session_id": self.session_id, "turn_index": i},
)
count += 1
return count
def close(self) -> None:
self.append_event(
{
"kind": "session_end",
"session_id": self.session_id,
"ended_at": utc_now(),
"turn_count": len(self.turns),
}
)
if self.interactive and self.offer_end_learning and self.turns:
if typer.confirm("Capture Profile 1 lessons from this shell session?", default=False):
self.capture_reflections()
if typer.confirm("Store redacted session turns as session_turn memory?", default=False):
count = self.remember_turns()
self.console.print(f"[green]Stored {count} session turn(s).[/green]")
self.console.print(f"[green]Session saved:[/green] {self.path}")
def run_shell(
*,
with_history: bool = False,
history_limit: int | None = None,
offline: bool = False,
hub_url: str = DEFAULT_HUB_URL,
no_hub: bool = False,
session_id: str | None = None,
console: Console | None = None,
input_func: InputFunc | None = None,
interactive: bool | None = None,
offer_end_learning: bool = True,
) -> Path:
settings = load_shell_history_settings(with_history=with_history, history_limit=history_limit)
history = collect_shell_history(settings)
hub = load_hub_orientation(base_url=hub_url, include_remote=not no_hub)
shell = ShellSession(
session_id=session_id,
offline=offline,
history=history,
hub=hub,
console=console,
input_func=input_func,
interactive=interactive,
offer_end_learning=offer_end_learning,
)
return shell.run()
__all__ = [
"DEFAULT_HUB_URL",
"TOPIC_ID",
"HubOrientation",
"ShellHistoryContext",
"ShellSession",
"collect_shell_history",
"detect_weakness_hints",
"load_hub_orientation",
"redact_secrets",
"render_shell_history_explanation",
"run_shell",
]

View File

@@ -8,3 +8,39 @@ import pytest
# Future: common fixtures for envelopes, fake adapters, etc.
# For now this file exists to establish the test layout.
# The llm-connect integration tests mock llm_connect symbols. When the optional
# sibling package is not installed in a default dev checkout, provide a tiny
# import target so unittest.mock.patch can attach to it. Real installations win.
def pytest_configure(config):
import importlib.util
import sys
import types
if importlib.util.find_spec("llm_connect") is not None:
return
if "llm_connect" in sys.modules:
return
llm_connect = types.ModuleType("llm_connect")
llm_config = types.ModuleType("llm_connect.config")
llm_models = types.ModuleType("llm_connect.models")
class RunConfig:
def __init__(self, *, model_name, temperature, max_tokens):
self.model_name = model_name
self.temperature = temperature
self.max_tokens = max_tokens
def _missing_create_adapter(*args, **kwargs):
raise RuntimeError("llm_connect test stub was not patched")
llm_connect.create_adapter = _missing_create_adapter
llm_config.resolve_api_key = lambda env_var=None: None
llm_models.RunConfig = RunConfig
llm_connect.config = llm_config
llm_connect.models = llm_models
sys.modules["llm_connect"] = llm_connect
sys.modules["llm_connect.config"] = llm_config
sys.modules["llm_connect.models"] = llm_models

View File

@@ -0,0 +1,111 @@
"""LLMConnectAdapter unit tests with mocked llm-connect (CYA-WP-0008)."""
from unittest.mock import MagicMock, patch
import pytest
from cya.config import LLMSettings
from cya.llm.adapter import AssistanceRequest
from cya.llm.connect_adapter import LLMConnectAdapter
def _mock_llm_response(content: str = "Try: git status"):
resp = MagicMock()
resp.content = content
resp.model = "mock/model"
resp.usage = {"total_tokens": 42}
resp.finish_reason = "stop"
return resp
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
def test_complete_delegates_to_llm_connect(mock_resolve, mock_create):
client = MagicMock()
client.execute_prompt.return_value = _mock_llm_response()
mock_create.return_value = client
settings = LLMSettings(adapter="connect", backend="mock", model="mock/model", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(
AssistanceRequest(user_request="show git status", context={"cwd": "/tmp"})
)
assert "git status" in response.suggestion.lower()
assert response.metadata.get("adapter") == "LLMConnectAdapter"
assert response.metadata.get("degraded") is not True
client.execute_prompt.assert_called_once()
def test_graceful_degrade_when_llm_connect_missing(monkeypatch):
import builtins
real_import = builtins.__import__
def _import(name, *args, **kwargs):
if name == "llm_connect" or name.startswith("llm_connect."):
raise ImportError("no llm_connect")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _import)
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="hello"))
assert response.metadata.get("degraded") is True
assert "llm-connect" in response.suggestion
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value=None)
def test_graceful_degrade_when_api_key_missing(mock_resolve, mock_create):
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="hello"))
assert response.metadata.get("degraded") is True
assert "API key" in response.suggestion
mock_create.assert_not_called()
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
def test_session_turns_included_in_prompt(mock_resolve, mock_create):
client = MagicMock()
client.execute_prompt.return_value = _mock_llm_response("ok")
mock_create.return_value = client
settings = LLMSettings(adapter="connect", backend="mock", configured=True)
adapter = LLMConnectAdapter(settings)
adapter.complete(
AssistanceRequest(
user_request="follow up",
context={
"session_turns": [{"user": "first", "assistant": "reply"}],
},
)
)
_prompt_arg, _ = client.execute_prompt.call_args[0]
assert "first" in _prompt_arg
assert "follow up" in _prompt_arg
@pytest.mark.llm_live
def test_live_openrouter_smoke():
"""Manual verification only — skipped unless OPENROUTER_API_KEY is set."""
import os
if not os.environ.get("OPENROUTER_API_KEY"):
pytest.skip("OPENROUTER_API_KEY not set")
settings = LLMSettings(
adapter="connect",
backend="openrouter",
model="anthropic/claude-sonnet-4",
configured=True,
)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="Reply with exactly: pong"))
assert response.metadata.get("degraded") is not True
assert response.suggestion

67
tests/test_llm_factory.py Normal file
View File

@@ -0,0 +1,67 @@
"""Adapter factory and config resolution (CYA-WP-0008)."""
import os
from pathlib import Path
import pytest
from cya.config import bound_session_turns, load_llm_settings
from cya.llm.adapter import FakeLLMAdapter
from cya.llm.connect_adapter import LLMConnectAdapter
from cya.llm.factory import get_adapter
def test_default_adapter_is_fake(monkeypatch):
monkeypatch.delenv("CYA_LLM_ADAPTER", raising=False)
monkeypatch.setattr("cya.config._USER_CONFIG", Path("/nonexistent/config.toml"))
adapter = get_adapter()
assert isinstance(adapter, FakeLLMAdapter)
def test_offline_forces_fake(monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "openrouter"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
adapter = get_adapter(offline=True)
assert isinstance(adapter, FakeLLMAdapter)
def test_connect_adapter_when_configured(monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "mock"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
adapter = get_adapter()
assert isinstance(adapter, LLMConnectAdapter)
def test_env_adapter_override(monkeypatch):
monkeypatch.setenv("CYA_LLM_ADAPTER", "connect")
monkeypatch.setenv("CYA_LLM_BACKEND", "mock")
adapter = get_adapter()
assert isinstance(adapter, LLMConnectAdapter)
def test_load_llm_settings_merges_project_config(monkeypatch, tmp_path):
user_cfg = tmp_path / "user.toml"
user_cfg.write_text('[llm]\nbackend = "openrouter"\nmodel = "from-user"\n')
project_cfg = tmp_path / ".cya.toml"
project_cfg.write_text('[llm]\nmodel = "from-project"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", user_cfg)
monkeypatch.setattr("cya.config._find_project_config", lambda start=None: project_cfg)
settings = load_llm_settings()
assert settings.backend == "openrouter"
assert settings.model == "from-project"
assert settings.adapter == "connect"
def test_bound_session_turns_limits():
turns = [
{"user": "a" * 1000, "assistant": "b" * 1000},
{"user": "c" * 1000, "assistant": "d" * 1000},
{"user": "e", "assistant": "f"},
]
bounded = bound_session_turns(turns, max_turns=10, max_chars=2500)
assert len(bounded) >= 1
total = sum(len(t["user"]) + len(t["assistant"]) for t in bounded)
assert total <= 2500 or len(bounded) == 1

22
tests/test_llm_prompt.py Normal file
View File

@@ -0,0 +1,22 @@
"""Prompt builder tests."""
from cya.llm.adapter import AssistanceRequest
from cya.llm.prompt import build_assistance_prompt
def test_build_assistance_prompt_includes_context_and_request():
system, user = build_assistance_prompt(
AssistanceRequest(
user_request="list files",
context={
"cwd": "/home/user/proj",
"session_turns": [{"user": "hi", "assistant": "hello"}],
"memory": {"items": [{"kind": "preference", "key": "style", "value": "concise"}]},
},
)
)
assert "cya" in system.lower()
assert "list files" in user
assert "/home/user/proj" in user
assert "hi" in user
assert "concise" in user

213
tests/test_shell_session.py Normal file
View File

@@ -0,0 +1,213 @@
"""Tests for CYA-WP-0007 interactive shell sessions."""
from __future__ import annotations
import json
from io import StringIO
from pathlib import Path
from rich.console import Console
from typer.testing import CliRunner
from cya.cli.main import app
from cya.cli import main as cli_main
from cya.config import ShellHistorySettings
from cya.orchestrator import OrchestratorResult
from cya.safety.risk import classify
from cya.shell_session import (
HubOrientation,
ShellHistoryContext,
ShellSession,
collect_shell_history,
detect_weakness_hints,
load_hub_orientation,
)
def _events(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
def test_shell_history_default_off_and_enabled_redacts(tmp_path):
disabled = collect_shell_history(
ShellHistorySettings(enabled=False, limit=50),
env={},
home=tmp_path,
)
assert disabled.lines == []
assert any("disabled" in note.lower() for note in disabled.notes)
hist = tmp_path / ".bash_history"
hist.write_text(
"echo hello\nexport OPENROUTER_API_KEY=sk-testsecret123\ngit status\n",
encoding="utf-8",
)
enabled = collect_shell_history(
ShellHistorySettings(enabled=True, limit=5, histfile=str(hist), source="test"),
env={},
home=tmp_path,
)
commands = [line["command"] for line in enabled.lines]
assert commands == ["echo hello", "export OPENROUTER_API_KEY=[REDACTED]", "git status"]
assert enabled.redactions == 1
assert all(line["provenance"] == "shell_history.histfile" for line in enabled.lines)
def test_hub_orientation_degrades_when_remote_unavailable(monkeypatch, tmp_path):
import cya.shell_session as shell_session
(tmp_path / ".custodian-brief.md").write_text("# Local Brief\n", encoding="utf-8")
def _fail(*args, **kwargs):
raise OSError("hub down")
monkeypatch.setattr(shell_session.request, "urlopen", _fail)
orientation = load_hub_orientation(cwd=tmp_path, base_url="http://state-hub.test")
assert orientation.brief is not None
assert not orientation.hub_available
assert len(orientation.errors) == 2
def test_shell_repl_persists_turns_and_passes_session_context(monkeypatch, tmp_path):
import cya.shell_session as shell_session
monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path)
calls = []
def _fake_handle(user_request, **kwargs):
calls.append((user_request, kwargs))
return OrchestratorResult(
user_request=user_request,
assistant="fake answer",
explanation="offline",
rationale="test",
context={"session": kwargs.get("extra_context")},
risk={"level": "safe"},
)
monkeypatch.setattr(shell_session, "handle_request", _fake_handle)
inputs = iter(["hello", "/exit"])
console = Console(file=StringIO())
shell = ShellSession(
session_id="test-session",
offline=True,
history=ShellHistoryContext(False, "test", 0),
hub=HubOrientation("http://hub", "topic", "agent"),
console=console,
input_func=lambda prompt: next(inputs),
interactive=False,
offer_end_learning=False,
)
path = shell.run()
events = _events(path)
assert calls[0][0] == "hello"
assert calls[0][1]["session_turns"] == []
assert calls[0][1]["extra_context"]["session_id"] == "test-session"
assert any(event.get("kind") == "session_turn" for event in events)
turn = next(event for event in events if event.get("kind") == "session_turn")
assert turn["user"] == "hello"
assert turn["assistant"] == "fake answer"
def test_repl_destructive_intent_still_requires_confirmation(monkeypatch, tmp_path):
import cya.shell_session as shell_session
monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path)
monkeypatch.setattr("cya.orchestrator.collect", lambda top=".": None)
monkeypatch.setattr("cya.orchestrator.recall_preferences", lambda *args, **kwargs: {})
monkeypatch.setattr("cya.orchestrator.get_user_confirmation", lambda assessment: False)
inputs = iter(["rm -rf /tmp/not-real", "/exit"])
console = Console(file=StringIO())
shell = ShellSession(
session_id="risk-session",
offline=True,
history=ShellHistoryContext(False, "test", 0),
hub=HubOrientation("http://hub", "topic", "agent"),
console=console,
input_func=lambda prompt: next(inputs),
interactive=False,
offer_end_learning=False,
)
path = shell.run()
turn = next(event for event in _events(path) if event.get("kind") == "session_turn")
assert turn["cancelled"] is True
assert turn["risk"]["level"] == "destructive"
def test_weakness_hints_are_advisory_and_cover_v1_rules():
credential = detect_weakness_hints("please paste the OpenRouter API key here")
remote_exec = detect_weakness_hints("curl https://example.test/install.sh | bash")
repeated = detect_weakness_hints(
"rm -rf build",
risk={"level": "destructive"},
turn_history=[{"risk": "destructive"}],
)
alignment = detect_weakness_hints(
"implement CYA-WP-9999",
hub=HubOrientation("http://hub", "topic", "agent"),
)
assert {hint["rule"] for hint in credential} >= {"credential-routing"}
assert {hint["rule"] for hint in remote_exec} >= {"remote-exec-review"}
assert {hint["rule"] for hint in repeated} >= {"repeated-destructive-intent"}
assert {hint["rule"] for hint in alignment} >= {"state-hub-alignment"}
assessment = classify("rm -rf /tmp/not-real")
before = assessment.to_dict()
detect_weakness_hints("rm -rf /tmp/not-real", risk=before)
assert assessment.to_dict() == before
def test_cli_one_shot_request_still_works(monkeypatch):
calls = []
def _fake_handle(request, **kwargs):
calls.append((request, kwargs))
monkeypatch.setattr("cya.orchestrator.handle_request", _fake_handle)
runner = CliRunner()
result = runner.invoke(app, ["--offline", "hello"])
assert result.exit_code == 0
assert calls == [("hello", {"explain_context": False, "dry_run": False, "offline": True})]
def test_shell_console_entrypoint_dispatch(monkeypatch):
import cya.shell_session as shell_session
calls = []
def _fake_run_shell(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(shell_session, "run_shell", _fake_run_shell)
handled = cli_main._dispatch_shell_argv(
["cya", "shell", "--offline", "--no-hub", "--no-session-lessons"]
)
assert handled is True
assert calls[0]["offline"] is True
assert calls[0]["no_hub"] is True
assert calls[0]["offer_end_learning"] is False
def test_memory_console_entrypoint_dispatch(monkeypatch):
calls = []
def _fake_memory_reflections(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(cli_main, "memory_reflections", _fake_memory_reflections)
handled = cli_main._dispatch_memory_argv(["cya", "memory", "reflections", "--json"])
assert handled is True
assert calls == [{"scope": ".", "export_json": True}]

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0001
type: workplan
title: "Console-Native MVP: CLI Skeleton, Safe Assistance Flow, and Integration Boundaries"
domain: capabilities
domain: agents
repo: can-you-assist
status: finished
owner: grok

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0002
type: workplan
title: "Memory Integration Roadmap: From Thin Ports to Profile-Driven phase-memory Backing"
domain: capabilities
domain: agents
repo: can-you-assist
status: done
owner: grok

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0003
type: workplan
title: "Contextual Memory Activation and Retrospection Loops for Continuous Optimization"
domain: capabilities
domain: agents
repo: can-you-assist
status: done
owner: grok

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0004
type: workplan
title: "Developer Installation from Git and Release Distribution Packaging"
domain: capabilities
domain: agents
repo: can-you-assist
status: done
owner: grok

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0005
type: workplan
title: "Agentic Memory Profiles (03) and phase-memory Interface Optimization"
domain: capabilities
domain: agents
repo: can-you-assist
status: done
owner: grok

View File

@@ -2,7 +2,7 @@
id: CYA-WP-0006
type: workplan
title: "Profile 1 Production Hardening: Reflection UX, Compaction, and Surfacing"
domain: capabilities
domain: agents
repo: can-you-assist
status: finished
owner: grok

View File

@@ -2,13 +2,14 @@
id: CYA-WP-0007
type: workplan
title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper"
domain: capabilities
domain: agents
repo: can-you-assist
status: ready
status: finished
owner: grok
topic_slug: foerster-capabilities
created: "2026-06-22"
updated: "2026-06-22"
updated: "2026-06-23"
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
---
# CYA-WP-0007: Interactive Shell Session
@@ -63,8 +64,9 @@ Claude Code — that:
```task
id: CYA-WP-0007-T01
status: todo
status: done
priority: high
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
```
Produce `docs/cya-interactive-shell-session-design.md` covering:
@@ -84,8 +86,9 @@ Produce `docs/cya-interactive-shell-session-design.md` covering:
```task
id: CYA-WP-0007-T02
status: todo
status: done
priority: high
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
```
Implement `cya shell` Typer subcommand:
@@ -104,8 +107,9 @@ Implement `cya shell` Typer subcommand:
```task
id: CYA-WP-0007-T03
status: todo
status: done
priority: high
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
```
Extend context collection (new module or `collector` extension):
@@ -125,8 +129,9 @@ Extend context collection (new module or `collector` extension):
```task
id: CYA-WP-0007-T04
status: todo
status: done
priority: medium
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
```
At session start, load orientation (graceful if hub offline):
@@ -150,8 +155,9 @@ Slash commands:
```task
id: CYA-WP-0007-T05
status: todo
status: done
priority: high
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
```
Connect each REPL turn to existing pipeline:
@@ -169,8 +175,9 @@ Connect each REPL turn to existing pipeline:
```task
id: CYA-WP-0007-T06
status: todo
status: done
priority: medium
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
```
On `/exit` or session end:
@@ -189,8 +196,9 @@ On `/exit` or session end:
```task
id: CYA-WP-0007-T07
status: todo
status: done
priority: medium
state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b"
```
Deterministic post-turn or end-session hints:
@@ -209,8 +217,9 @@ Surface as informational panels — not auto-fixes. Optional save as reflection.
```task
id: CYA-WP-0007-T08
status: todo
status: done
priority: high
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
```
- `tests/test_shell_session.py` — REPL loop, history opt-in, hub degrade, safety
@@ -226,8 +235,9 @@ priority: high
```task
id: CYA-WP-0007-T09
status: todo
status: done
priority: low
state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d"
```
Run `make fix-consistency REPO=can-you-assist`, log progress, move to `active` when
@@ -245,4 +255,27 @@ When complete:
---
**Status note:** Promoted to `ready` on 2026-06-22 after operator approval of direction.
Pair with CYA-WP-0008 for production LLM quality in the REPL.
Pair with CYA-WP-0008 for production LLM quality in the REPL.
## Completion Note - 2026-06-23
Implemented in this repo by Codex. Delivered `cya shell` with local JSONL
session artifacts, optional capped/redacted shell history, State Hub orientation
and explicit hub slash-command writes, `/explain`, `/export-session`, end-session
Profile 1 learning hooks, opt-in `session_turn` memory persistence, deterministic
weakness hints, docs, and tests.
Verification:
```bash
make test
python3 -m cya.cli.main --offline hello
python3 -m cya.cli.main shell --help
git diff --check
```
Operator follow-up: after this workplan file change, run from `~/state-hub`:
```bash
make fix-consistency REPO=can-you-assist
```

View File

@@ -2,13 +2,14 @@
id: CYA-WP-0008
type: workplan
title: "llm-connect Adapter Integration for Production Assistance"
domain: capabilities
domain: agents
repo: can-you-assist
status: ready
status: finished
owner: grok
topic_slug: foerster-capabilities
created: "2026-06-22"
updated: "2026-06-22"
state_hub_workstream_id: "f713db12-5b90-4453-8fbb-a0e50f61699b"
---
# CYA-WP-0008: llm-connect Adapter Integration
@@ -49,8 +50,9 @@ llm-connect. Credential routing via `warden route` before requesting secrets.
```task
id: CYA-WP-0008-T01
status: todo
status: done
priority: high
state_hub_task_id: "483d13bb-aabe-48ad-96c2-8df83de5f442"
```
Document mapping from `AssistanceRequest` / `AssistanceResponse` to llm-connect calls.
@@ -60,12 +62,15 @@ Identify config surface (TOML keys, env vars). Note gaps requiring llm-connect c
- Short integration note in `docs/` or workplan appendix.
- Credential route catalog id(s) documented via `warden route find`.
**Delivered:** `docs/llm-connect-integration.md`
### T02 — Implement `LLMConnectAdapter`
```task
id: CYA-WP-0008-T02
status: todo
status: done
priority: high
state_hub_task_id: "0fc17ad5-d90b-4ad1-b060-a1a2f9c25ea8"
```
New class in `src/cya/llm/` implementing `LLMAdapter`:
@@ -77,12 +82,15 @@ New class in `src/cya/llm/` implementing `LLMAdapter`:
**Acceptance criteria:**
- Protocol-compliant; swap via config or env (`CYA_LLM_ADAPTER=connect|fake`).
**Delivered:** `src/cya/llm/connect_adapter.py`, `src/cya/llm/factory.py`
### T03 — Configuration and developer ergonomics
```task
id: CYA-WP-0008-T03
status: todo
status: done
priority: medium
state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512"
```
- `~/.config/cya/config.toml` `[llm]` section (backend, model hints)
@@ -93,12 +101,15 @@ priority: medium
- Operator can configure adapter without editing source.
- No secrets committed; example config uses placeholders.
**Delivered:** `src/cya/config.py`, `docs/cya-config.example.toml`, README section
### T04 — Orchestrator and shell integration
```task
id: CYA-WP-0008-T04
status: todo
status: done
priority: high
state_hub_task_id: "f2781963-fecf-4576-96de-bd745df271a0"
```
Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection:
@@ -111,12 +122,15 @@ Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection:
- One-shot and shell paths use same adapter factory.
- Session context bounded (token/line budget documented).
**Delivered:** `get_adapter()` wired in orchestrator; `session_turns` + `bound_session_turns()` ready for CYA-WP-0007 shell
### T05 — Tests and offline CI strategy
```task
id: CYA-WP-0008-T05
status: todo
status: done
priority: high
state_hub_task_id: "32de980b-1a24-4159-9550-7c516570cae3"
```
- Mock llm-connect for unit tests (no live API in default `make test`)
@@ -127,12 +141,15 @@ priority: high
- `make test` passes without network or API keys.
- Live test documented for operator manual verification.
**Delivered:** `tests/test_llm_factory.py`, `tests/test_llm_connect_adapter.py`, `tests/test_llm_prompt.py`
### T06 — Documentation and SCOPE update
```task
id: CYA-WP-0008-T06
status: todo
status: done
priority: medium
state_hub_task_id: "2d152d4b-e4b2-4a94-8f85-d8f033e55d5f"
```
Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md.
@@ -144,8 +161,9 @@ Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md
```task
id: CYA-WP-0008-T07
status: todo
status: done
priority: low
state_hub_task_id: "2fb42517-b2df-43d3-8195-f02d310107dc"
```
`make fix-consistency`, progress event, coordinate with llm-connect repo if API gaps found.
@@ -158,4 +176,4 @@ priority: low
---
**Status note:** `ready` on 2026-06-22. Can start T01T03 in parallel with CYA-WP-0007 T02T04.
**Status note:** `finished` on 2026-06-22. Integration doc: `docs/llm-connect-integration.md`.