Skip to content

English | Русский

TAUSIK Architecture Reference ​

Where it sits. The field calls this discipline harness engineering: a harness is the agent loop, the tool interface, context management and control mechanisms. TAUSIK is not a harness — the loop and the tools belong to Claude Code, Cursor, Codex and the rest. It is the verification and control layer on top of them: what makes it different is that nothing counts as done without evidence.

Architecture: CLI -> Service -> Backend ​

Three layers with clear boundaries. The Service layer contains business logic, the Backend handles only CRUD and SQL. CLI and MCP are two equal entry points.

  Engineer (free-form text)
       |
  AI Agent (Claude Code / Cursor)
       |
  +---------------------------+
  | Skills (SKILL.md)         |  <- instructions for the agent
  +---------------------------+
       v                v
  +---------+    +---------+
  | MCP     |    | CLI     |  <- two entry points
  | (tools) |    | (bash)  |
  +----+----+    +----+----+
       +------+-------+
              |
  +---------------------------+
  | Service Layer             |  <- business logic, QG-0, QG-2
  | project_service.py        |
  | + service_task.py         |
  | + service_knowledge.py    |
  +---------------------------+
              |
  +---------------------------+
  | Backend Layer             |  <- SQLite CRUD, FTS5, metrics
  | project_backend.py        |
  | + backend_queries.py      |
  | + backend_graph.py        |
  | + backend_schema.py       |
  | + backend_migrations.py   |
  +---------------------------+
              |
  +---------------------------+
  | SQLite (WAL mode)         |  <- .tausik/tausik.db
  | schema v67                |
  +---------------------------+

Key Modules ​

Scripts (Business Logic) ​

TAUSIK 1.11 still has a flat deployment layout: 563 files under scripts/, 502 at its root. This is historical compatibility, not the 2.0 target. See TAUSIK 2.0 package layout for the measured boundary, migration baseline, and rollout.

Modules in scripts/, each <=500 lines (filesize gate; raised from 400 as an interim measure by decision #190, because the tighter cap was deforming the architecture rather than improving it). Line count is not the only size control: the class_surface gate separately caps a class's composed public surface after inheritance, which a per-file cap structurally cannot see. Highlights:

FilePurpose
project.pyCLI entry point, dispatch
project_parser.pyargparse command tree
project_cli.py / _extra.py / _ops.pyCLI handlers (status, task, session, memory, gates, skills, fts, metrics, search, events, explore, audit, run)
project_cli_doctor.py / _role.py / _stack.py / _verify.pyCLI handlers (doctor, roles, stacks, verify)
project_service.py + service_*.py mixinsBusiness logic: tasks, knowledge, skills, gates, cascade, roles, verification
service_verification.pyScoped pytest gate + verify cache (10 min TTL)
service_roles.pyHybrid role storage (DB metadata + harness/roles/*.md)
service_stack_ops.pyStack scaffold, lint, diff, reset
project_backend.py + backend_*.pySQLite + FTS5 backend (WAL mode, schema v67)
backend_session_metrics.pyGap-based active-time computation
backend_tier_metrics.pycall_budget vs call_actual tier metrics
backend_migrations.py / _legacy.py / _v*.pySchema migrations through v67; 34 version modules remain for 1.11 upgrade compatibility
project_config.py + default_gates.pyConfig loader, gates config, auto-enable
gate_runner.py + gate_stack_dispatch.py + gate_test_resolver.pyScoped pytest mapping + dispatch
skill_manager.py + skill_repos.pySkill install/uninstall from repositories
knowledge_db.py + knowledge_write.py + knowledge_read.pyThe shared local store ~/.tausik-knowledge (--global on decide / memory add; folded into memory search)
publication_boundary.py + knowledge_export.pyThe one place shared-store content leaves the machine (knowledge export --redacted)
cq_client.pyCross-project queue client
doc_extract.pymarkitdown integration
docs_lint.pyWarning-only stale-version linter
plan_parser.pyMarkdown plan parser for /run
model_routing.pyModel selection helper
ide_utils.pyIDE detection, paths, registry
tausik_utils.py + tausik_version.py + project_types.pyHelpers, version, types
gen_doc_constants.pyDoc-drift entry point: --check, --write, regenerate constants.json
doc_drift_common.pyShared regex tables, scan targets, text helpers
doc_drift_scanners.pyThe six drift scans (versions, MCP counts, closed lists, test and code counts)
doc_drift_tables.pyNumeric cells of markdown table columns, with their subject registry
doc_drift_fixes.pyThe auto-fixer --write runs
code_counts.pyCounts repo state: hooks, stacks, roles, review agents, skills
mcp_tool_counts.pyCounts the MCP surface each server advertises
audit_orphan_files.py / audit_stale_docs.py / audit_unused_python.py / audit_pytest_dedupe.pyStatic audit reports (review-only, v1.5)
project_cli_hygiene.pytausik hygiene archive / unarchive (soft-archive old done tasks and the one path back)
hooks/check_docs.pyPre-commit / CI wrapper for doc-constants drift (v1.5)

Bootstrap (Generation) ​

FileLinesPurpose
bootstrap.py~320Orchestration: vendor sync, copy, generate
bootstrap_vendor.py~280Download vendor skills from GitHub (tarball)
bootstrap_copy.py~180Copy skills, scripts, MCP into .claude/
bootstrap_config.py~70Configuration, stack detection
bootstrap_generate.py~300Generate settings.json, CLAUDE.md, skill catalog
analyzer.py~260Extension-skill detection and tree walking

MCP Server ​

FilePurpose
harness/claude/mcp/project/server.pyJSON-RPC stdio server
harness/claude/mcp/project/tools.pycore tool definitions
harness/claude/mcp/project/tools_extra.pyextended tool definitions (skills, gates, doctor, verify, roles, stacks)
harness/claude/mcp/project/handlers.pyDispatch only: tool-call counter, handle_tool, merge of the per-domain tables
harness/claude/mcp/project/handlers_<domain>.pyHandlers by domain: task, session, status, knowledge, hierarchy, stack, role, verification, cq, skill, spec, adapt. Each module exports <DOMAIN>_HANDLERS; handlers.py merges them into _DISPATCH
harness/claude/mcp/project/handlers_render.pyShared list rendering (render_list) — an empty result must read as "nothing here", not as an empty string

Total MCP surface: 147 project tools (optional codebase-rag adds 7 more; not part of the main count).

THAT SURFACE IS PAID FOR ON EVERY TURN, AND THE PRICE DIFFERS BY HOST. The MCP protocol sends every tool's name AND schema each turn unless the client defers schemas. Measured in session #229: the project server's serialized definitions are 56,108 bytes (~14,000 tokens); the names alone are 3,201 bytes (~800 tokens) — a 17.5x difference. Claude Code defers and pays the names, which was OBSERVED; a host without deferral pays the whole thing, which follows from the protocol and was not observed here.

The figures above are for reading, not the source of truth: that is the mcp_surface ratchet in tausik/gates.json, which tests/test_mcp_surface_ratchet.py re-measures on every run and turns red on growth. It is done that way because the previous version of this number rotted in exactly this spot — the task filed 117 tools and 44,501 bytes in session #178, and by #229 it was 145 and 56,108: 24% and 26% of growth nobody noticed while the number lived in prose.

Contextual chunk headers (codebase-rag) ​

A chunk cut out of a file stops carrying what the file was about, so a query phrased in the document's terms cannot reach a passage phrased in its own. Each indexed chunk therefore carries a short header built by harness/claude/mcp/codebase-rag/rag_context.py: path words, the symbol it defines — or the one it sits inside, for a continuation chunk — and the file's own summary line.

This is contextual retrieval with the model taken out. The published technique asks an LLM to write a sentence of context per chunk; here the header comes from metadata the indexer already has, so the same input yields the same bytes and indexing stays reproducible and offline.

The header lives in its own indexed column (rag_chunks.context_prefix), never in the chunk's content: a word present only in the header still matches, and still does not appear in what search returns. An index built before v1.8 grows into the layout on first open — the column is added and the FTS table rebuilt from the chunks, which are the source of truth.

How that was measured, and how to measure it again. The headers are not "obviously helpful" — their gain is counted by a reproducible instrument, scripts/rag_retrieval_bench.py (python scripts/rag_retrieval_bench.py [--limit N] [--json]). It derives its queries MECHANICALLY from the corpus and samples them deterministically, so the set cannot be tuned toward a flattering result and two runs over the same tree ask the same questions. It measures recall@K against the SPECIFIC CHUNK and keeps two query sets at once: context, the case the header exists for, and control, queries built only from words already inside the chunk, where the header must not help and above all must not hurt. At n=115: recall@3 went 0.4870 → 0.8348 on context and 0.5739 → 0.6435 on control, with no regression at any K.

Cross-IDE Support ​

Skills, roles, stacks -- shared across IDEs. So are the MCP servers: harness/claude/mcp/ is the single canonical tree, and copy_mcp hands it to every IDE that has none of its own (all of them, today). A per-IDE copy would be a mirror waiting to drift — one used to exist under harness/cursor/ and was deleted in v1.7.0.

harness/
+-- skills/           # 13 core auto-deployed; external skills installed per skill
+-- roles/            # 7 roles (architect, developer, devops, qa, researcher, tech-writer, ui-ux)
+-- stacks/           # Stack guides
+-- overrides/        # IDE-specific overrides (claude/, cursor/, qwen/)
+-- claude/mcp/       # MCP servers (project, codebase-rag) — canonical for ALL IDEs
+-- opencode/plugins/ # QG-0 enforcement plugin for OpenCode (tool.execute.before)

Runtime (IDE) × Model — two orthogonal axes ​

TAUSIK separates where it runs from which model answers:

AxisWhat it controlsbootstrap --ide targetActive-model detection
claudeClaude Code (VSCode/CLI).claude/ + .mcp.jsonJSONL transcript (model field)
cursorCursor.cursor/ + .cursor/mcp.json—
qwenQwen Code.qwen/settings.json—
kiloKilo Code (addon + CLI).kilo/kilo.jsonc and .kilocode/mcp.jsonKILO_MODEL env / .kilo config
opencodeOpenCode (SST)opencode.json + .opencode/plugins/—

The model axis is data, not code: scripts/model_profiles.py maps vendor families (claude, glm/z.ai) × capability ranks → concrete model ids, overridable in .tausik/config.json model_profiles.families. The routing matrix emits an abstract rank; the active family resolves it to a real model — so a z.ai GLM session routes to GLM models with no code change. See Kilo + z.ai.

DB: Selected Tables (Schema v67) ​

TablePurpose
metaMetadata (schema_version)
epicsEpics
storiesStories (-> epic)
tasksTasks (-> story, scope, defect_of, plan, AC)
sessionsSessions (start, end, summary, handoff)
memoryProject memory (pattern, gotcha, convention, context, dead_end)
decisionsArchitectural decisions
eventsAudit log (gate_bypass, status_changed, claimed)
explorationsInvestigations (time-boxed)
memory_edgesGraph links between memory/decision (Graphiti)
fts_tasksFTS5 full-text index for tasks
fts_memoryFTS5 index for memory
fts_decisionsFTS5 index for decisions
task_logsStructured task logs (phase, message)
fts_task_logsFTS5 index for task logs
rolesRole registry (hybrid: metadata + harness/roles/{slug}.md)
session_activityPer-tool-call timestamps for gap-based active time
verification_runsVerify cache: file_hash + timestamp for QG-2 reuse (10 min TTL)

Quality Gates ​

gate_registry.py        -> GATE_REGISTRY: the one declaration per built-in gate
                        -> GateSpec(name, phase, default_config, impl)
                        -> phase: scoped | post_scope
default_gates.py        -> DEFAULT_GATES = universal (from registry)
                                         ∪ stack-scoped (from stack_registry)
                                         ∪ post-scope (from registry)
gate_runner.py          -> run_gates(trigger, files)   [scoped phase only]
                        -> dispatch via GATE_REGISTRY[name].impl,
                           unknown name -> run_command_gate()
gate_post_scope.py      -> run_post_scope_gates()      [post_scope phase]
                        -> verify_first, changelog + one gate_runs row each
service_task.py         -> _run_quality_gates() (called from task_done)

Adding a built-in gate is one GateSpec. Before gate-registry-single-source it meant four edits, and the two post-scope gates lived in only one of the four: gates status did not list them, gates enable/disable could not reach them, and they wrote no gate_runs row — so nothing could prove a QG-2 gate had run.

Scoped gates — (gate_config, files) -> (passed, output), run over the task's declared scope. Universal (always on): filesize, class_surface, tdd_order, ruff, mypy, bandit, bootstrap_drift, memory_route, renar_drift_schema, renar_drift_provenance, cross_model_parity.

cross_model_parity asks one question: did a capability go host-only without anyone saying so. It RUNS the real mechanism generators into a clean tree and compares hosts that share an extension point — Claude's hooks against Qwen's, plugins against plugins. There is no comparison ACROSS kinds: asking whether Claude is "missing" OpenCode's plugin is a question with no meaning. The gate does NOT demand sameness: Cursor has no extension point at all, so there is nothing for it to be equal to. What it demands is that a difference be NAMED, with a reason — and a declaration that no longer matches any live difference is refused as loudly as an undeclared difference. It fires only on host-layer edits (bootstrap/, scripts/hooks/, harness/opencode/): a gate that asks every task about cross-model parity is a tax, and a tax gets switched off.

bootstrap_drift checks all three links of the chain "edit → deploy → takes effect": scripts/ against the deployed profile, harness/ against its fan-out, and the deployed profile against the process that runs from it (running_source_drift: a content snapshot at process start, compared at task-done). A long-lived MCP server whose profile was rewritten underneath it executes the old copy; the gate refuses the close and names the fix — restart the server, or close via the CLI, which is a fresh process.

A gate is verified by mutation, not by passing. A green run proves only that the gate did not object — not that it would object to anything. The rule is held by tests/test_gates_catch_their_violation.py: every gate in gate_registry.GATE_REGISTRY is in exactly one of two tables. COVERED — the gate is driven through the registry's own impl_for on BOTH ends: a real violation built under tmp_path must come back failed, a clean input built the same way must come back passed (one end alone is empty: a gate that is red on every input passes a red-only check as well as a correct one). EXCUSED — a gate that cannot be driven from a synthetic tree (service-bound, an external tool's verdict, warn severity), with a reason and the names of a red and a green test in the module that does drive it; the names are checked against that module's AST. The list is closed: a new gate without a row reddens the lane. A mutation cannot stay in the tree by construction — everything is built under tmp_path, and a write outside it during the table run is caught by intercepting open, sqlite3.connect and subprocess (the three channels the builders use) — not by a git status snapshot, which under xdist loses the race to a neighbouring worker.

class_surface is the one exception to "run over the declared scope": it ignores the file list and measures the whole repo (~0.65s). A class grows past its cap through its bases, so a scoped run would never see it — the same blindness that let a module reach 406 lines without blocking anyone. It caps a class's composed public surface after inheritance, which the per-file filesize cap structurally cannot see: a god-object built from mixins keeps every file under the line cap. The two complement each other — "this class does too much" and "this file is too long to read" are different defects. Counts are a lower bound (AST, never import, so a gate can measure a branch nobody has read yet), and known oversized classes sit behind a ratchet baseline in tausik/gates.json that may only shrink.

Post-scope gates — take the close context and edit the QG-2 report: verify_first (a fresh signed verify green must exist) and changelog. get_gates_for_trigger excludes them, so run_gates never calls one with the wrong signature.

Stack-scoped gates: pytest, tsc, eslint, js-test, go-vet, go-test, golangci-lint, cargo-check, cargo-test, clippy, phpstan, phpcs, phpunit, javac, ktlint, ansible-lint, terraform-validate, helm-lint, kubeconform, hadolint.

RENAR adoption — advisory-first ("lite") ​

TAUSIK is a lightweight, zero-dependency framework, so it adopts RENAR (reasoning/governance standard) advisory-first rather than as heavyweight mandatory ceremony. Adoption climbs a ladder with explicit entry conditions per rung:

RungWhatStatus
1. ArtifactsSPEC / ADAPT / conformance embedded in the SQLite substrate + one-way renar/ exportdone (RENAR-1)
2. Advisory signalsQG-0 surfaces a non-blocking nudge when a high-stakes task (tier substantial/deep, or complex) starts with no linked SPEC and no ADAPT — gate_qg0_renar.renar_qg0_advisory, toggle renar.qg0_advisory (default on)done (1.5)
3. Evidence-based hard-gatepromote a specific advisory to blocking only when a real defect traces to its absence (per the #91 coherence audit)2.0
4. RENAR-2 signed/immutable ADAPTsign ADAPT (ed25519) → tz_immutable=true + delta-ADAPT — irreversible, user-directed only2.0

The philosophy: RENAR strengthens SENAR by making interpretation visible at the natural gate (QG-0), while keeping the agent unblocked — fail-soft on advisory, fail-closed only on proven gates. This is a deliberate lightweight-adoption policy, not "unfinished RENAR".

Orchestrator-worker (model auto-switch via sub-agents) ​

The main session is the coordinator (planning, AC, review). A task of complexity ≤ medium can be delegated to a worker sub-agent spawned with the recommended model, reasoning effort and standard speed. This is the model selection surface exposed by Claude Code and Codex; TAUSIK provides the delegation scaffolding/state, while the host agent performs the actual spawn. Kilo/GLM routes are advisory until that host surface is live-verified.

StepCommand / mechanism
Delegatetausik task delegate <slug> — records {recommended model, parent session} in the meta kv (no schema migration). complex tasks are refused (they stay with the coordinator).
Handoff contracttausik task handoff <slug> — deterministic JSON {slug, goal, acceptance_criteria, scope, scope_exclude, model, skills}; the trimmed WORKER_SKILLS profile (no plan/explore). The orchestrator passes it to the Agent tool; the worker echoes it back (round-trip identity).
In-session recognitiontask start on a delegated task surfaces worker mode (operating contract) and suppresses the orchestrator-only model-recommendation banner.
Scope hard-gatethe worker is scope-bounded — scope_write_gate blocks edits outside scope_paths, and a delegated task with no scope is blocked until it declares one (no legacy fail-open for workers).
Summary-backtausik task summary-back <slug> "<summary>" [--gates …] — the worker returns a structured result (stored in meta, surfaced in task show) so the coordinator picks it up without the worker transcript.

Delegation state is CLI-first (no MCP surface, to avoid doc-count drift) and lives entirely in the meta table (delegation:<slug>, worker_summary:<slug>). Route telemetry uses separate outcomes (selected, applied, rejected, unavailable). Selection alone is not application: worker-mode recognition at task start is the event that records applied. The coordinator cannot be switched mid-session and keeps QG-0/QG-2, evidence and task closure.

Hooks (anti-drift, see hooks.md) ​

All hook files under scripts/hooks/ are registered via bootstrap/bootstrap_generate.py (Claude Code) and bootstrap/bootstrap_qwen.py (Qwen Code). Hook scripts are non-blocking (exit 0); errors go to stderr. Shared helpers live in scripts/hooks/_common.py.

Memory Aggregates ​

service_knowledge_aggregates.py holds pure functions for memory re-injection:

  • build_memory_block(be, ...) — compact markdown (decisions + conventions + dead ends), ≤50 lines, called from /start, /checkpoint, and the SessionStart hook
  • build_compact_memory_tail(be) — the one-line-per-entry recap embedded in the CLAUDE.md dynamic block
  • build_memory_compact(be, last_n) — task_logs aggregation: phases + top words + top files

Both recaps read the memory graph through memory_supersedes.live_head: an entry a LIVE supersedes edge has retired is not printed, its line goes to the next live entry, and the surviving entry says (supersedes #N) on the line it occupies anyway. Only that relation hides anything, and only when the superseding entry is itself unarchived — otherwise the older entry is the best knowledge left. An unreadable graph retires nothing, so the recap degrades to what it printed before the filter existed.

Likewise scripts/model_routing.py and plugin_data.py are pure modules imported by CLI/MCP handlers.

Prompt Caching ​

TAUSIK relies on Anthropic's automatic prompt caching to keep agent runs cheap. The framework itself does not call the API — Claude Code does — but the structure of what TAUSIK feeds into each turn decides whether the API caches a prefix or re-bills it. Cacheable surface, in priority order:

SurfaceWhere it livesWhy it caches well
System prompt + tool schemasInjected by Claude Code from .claude/mcp/project/tools.py and tools_extra.pyIdentical across turns within a session — the longest stable prefix
CLAUDE.mdProject rootRead once per session and re-injected; stable unless tausik_update_claudemd rewrites the dynamic block
MCP tool descriptionsSame tools.py filesEditing them invalidates the cache — every wording change rewrites the prefix
Skills (SKILL.md)harness/skills/<name>/SKILL.mdLoaded only when the skill activates

What invalidates the cache mid-session. Editing any of the files above between turns rewrites the prefix and forces the next turn to pay cache_creation_input_tokens instead of cache_read_input_tokens. The biggest offender is tausik_update_claudemd — running it mid-session rewrites the dynamic-state block (session #, task counts, etc.) and the entire CLAUDE.md prefix re-caches. Run it at session boundaries (/start, /checkpoint, /end), not between regular tool calls.

Verifying caching is actually active. Anthropic returns cache_creation_input_tokens (the prefix was just laid down) and cache_read_input_tokens (a later turn hit the cache) in every response's usage block. scripts/validate_prompt_caching.py parses a Claude Code transcript JSONL and reports both totals plus a hit-rate:

bash
python scripts/validate_prompt_caching.py --auto
# or
python scripts/validate_prompt_caching.py path/to/transcript.jsonl

Exit code 0 = caching active (cache_read_input_tokens > 0); 1 = prefix is unstable (creation > 0 but reads = 0); 2 = API never returned cache fields. See troubleshooting.md "Prompt caching not active" for common failure modes.

Testing ​

bash
pytest tests/ -v                    # all tests (3844)
pytest tests/test_tausik_backend.py   # backend CRUD
pytest tests/test_tausik_service.py   # service logic
pytest tests/test_tausik_cli.py       # CLI smoke
pytest tests/test_gates.py          # quality gates + stack auto-enable
pytest tests/test_vendor.py         # vendor skills + persistence
pytest tests/test_graph_memory.py   # graph memory edges
pytest tests/test_mcp_integration.py # MCP handlers
pytest tests/test_senar.py          # SENAR compliance
pytest tests/test_e2e_workflow.py   # E2E workflow

When adding or restructuring tests (new module vs extending an existing file, scoped pytest mapping), follow Testing principles.