FILES(5) NPCAgent Manual FILES(5)

files — state on disk and in SQLite

NPCAgent has no server and no account. Everything it knows lives in two places: a .npcagent/ directory inside the repository you invoked it from, and ~/.npcagent/ for machine-wide defaults. Deleting the first resets a project; deleting both resets the installation.

Repository layout

Created by npc init, or implicitly by Runtime.for_repo() through ensure_repo_ready(). The five subdirectories under .npcagent/ are fixed in _NPCAGENT_SUBDIRS; everything deeper appears on demand.

<repo>/ ├── .gitignore # three lines appended, under a "# NPCAgent runtime data" header ├── .npcagent/ │ ├── db/ │ │ └── npcagent.db # 8 tables, migrated to alembic head on every startup │ ├── memory/ │ │ └── current.md # Decisions · Open Loops · Constraints · Notes │ ├── docs/ │ ├── agents/ │ │ └── <role>/<agent-id>.json # written by `npc agent promote --as <role>` │ ├── projects/ │ │ └── <slug>/ │ │ ├── master.md # goal · roadmap · constraints · decisions · risks │ │ ├── plans/ │ │ │ └── <run-id>-stage1.md # frontmatter + phases + success criteria │ │ └── tasks/ │ │ └── <run-id>/ │ │ ├── plan.md # Stage 2 detail, appended once per phase │ │ ├── task_ledger_<run-id>.json │ │ └── progress_ledger_<run-id>.json │ ├── env.toml # on demand — env vars injected into agent launches │ ├── serve.pid # on demand — checked before daemon autostart │ └── logs/ # on demand │ ├── serve.log # only with `npc serve --log-json` │ └── serve-autostart.log └── .npcagent-worktrees/ └── <run-id hex[:12]>/ # branch npcagent/<workflow>/<run-id hex[:8]>

Only three of these are gitignored — .npcagent/db/, .npcagent/memory/ and .npcagent-worktrees/. That is a deliberate asymmetry: project files, plans and ledgers are meant to be committable, so a plan artefact and its Stage 2 expansion can be reviewed in a pull request like any other document. Databases, working memory and scratch worktrees are not.

Worktree lifetime

What survives a run depends on worktree_cleanup_mode, default archive. delete removes the worktree and the branch; archive renames the directory to <task_id>.archived-<UTC ISO timestamp> and keeps the branch for forensic recovery; keep leaves everything registered with git. Cleanup failure is logged as worktree.cleanup_failed, never raised — a cleanup problem must not fail an otherwise successful run.

Home directory

~/.npcagent/ ├── config.toml # written by `npc model`; overridable via $NPCAGENT_CONFIG_FILE ├── mcp.json # upstream MCP servers → tools named mcp.<server>.<tool> ├── global.db # DB fallback when no repo path is bound ├── memory/ # the global memory scope, always present ├── plugins/ # user plugins; $NPCAGENT_USER_PLUGIN_DIR overrides ├── workflows/ # user workflow JSON; $NPCAGENT_USER_WORKFLOW_DIR overrides ├── skills/ # user SKILL.md folders; $NPCAGENT_USER_SKILL_DIR overrides ├── cron/ │ └── jobs.json # cron trigger jobs, written atomically └── logs/ └── codeman.log

Three of these directories sit in the middle of a three-level search stack — bundled catalog first, user directory second, repository directory third, with later entries overriding earlier ones. That applies identically to workflows, skills and prompts, so a repository can shadow a bundled workflow without editing the installation.

Database

SQLite through aiosqlite, with SQLAlchemy 2.0 async sessions. Schema is owned by Alembic — twelve migrations in a linear chain — and the ORM models exist to mirror it, not to create it. Tests build the schema from the models via Base.metadata.create_all; production never does.

The eight-table data model The runs table sits at the centre, referenced logically by workflow_state, workflow_signals, events and tasks, and produced by primitives. spawn_envelopes guards recursive spawning. conversation_turns is an FTS5 view over agent messages, populated by a trigger on events. Every id is String(36). There are no ForeignKey constraints anywhere — all references are logical. primitives id PK kind project|routine|reaction goal · name · workflow_id trigger_config_json loop_config_json is_persistent · next_fire_time created_at · updated_at archived_at spawn_envelopes root_id + idempotency_key PK parent_id · depth requested_by_agent_id created_at depth ≥ max_spawn_depth (5) rejected tasks id PK title · notes status free string, default "pending" assigned_to_agent_id parent_run_id · project_id created_at · updated_at no enum — unlike runs.status runs id PK primitive_type · goal · workflow_id status 7 values, indexed idempotency_key retry_count · max_retries (3) last_error · escalation_reason escalation_policy_json claimed_by · claimed_at started_at · completed_at deadline_at indexed next_checkpoint_at indexed parent_id · root_id · depth parent_routine_id payload_json · created_at UNIQUE (root_id, idempotency_key) a run self-roots: root_id == id when unset claim_run(): UPDATE … WHERE claimed_by IS NULL parent_id → runs.id conversation_turns (FTS5 virtual) run_id UNINDEXED agent_id · role · content ts UNINDEXED tokenize = 'porter unicode61' populated by the trigger events_after_insert_agent_message via json_extract on payload_json workflow_state id PK run_id UNIQUE workflow_def_id current_phase 9 values phase_data_json version CAS column created_at · updated_at workflow_signals id PK run_id event_type 13 values payload_json created_at applied_at NULL = pending claimed_at · apply_attempts apply_error events id PK event_type 13 types, indexed payload_json parent_id · root_id · depth ts indexed correlation_id · event_version index (root_id, ts) — replay order creates 1 : 1 1 : n root_id · 1 : n SQL trigger on INSERT WHERE event_type = 'agent.message' parent_run_id Recursion guard: spawn() validates a SpawnEnvelope before creating a child run. Depth is exclusive — depth >= max_spawn_depth is rejected. A duplicate (root_id, idempotency_key) raises DuplicateIdempotencyKey via the composite primary key. Idempotency key formats: "project:<primitive-id>" · "routine:<id>:<fired_at ISO>" · "reaction:<id>:<delivery-id>" · "spawn:<kind>:<parent-run>:<name>"
Figure 11 — the data model. The absence of foreign-key constraints is a deliberate SQLite choice, not an oversight; every reference is enforced in application code. Two uniqueness constraints do real work: uq_runs_root_idempotency is what makes a cron tick or a webhook redelivery safe to repeat, and uq_workflow_state_run_id is what guarantees a run has at most one workflow-state row for the compare-and-set to contend over.
The migration chain, 0001 through 0012. Linear — no branches or merges.
RevisionAdds
0001_initialscaffolding only; down_revision = None
0002_eventsevents
0003_spawn_envelopesspawn_envelopes
0004_runsruns
0005_primitivesprimitives
0006_workflow_stateworkflow_state
0007_workflow_signalsworkflow_signals
0008_messages_ftsconversation_turns FTS5 table + the agent.message trigger
0009_taskstasks
0010_runs_payloadruns.payload_json, index on next_checkpoint_at
0011_workflow_signal_claimsworkflow_signals.claimed_at + index
0012_runs_claimed_atruns.claimed_at, backfill nulling stale claimed_by, + index

Migrations run in-process, not through a subprocess, and in a dedicated thread. The reason is stated in repo_init.py: Alembic's env creates an async engine via asyncio.run, but Runtime construction is synchronous and may happen while an event loop is already running. A short-lived thread gives Alembic a context with no active loop.

Configuration

Settings resolve through pydantic-settings with extra = "forbid" — an unknown key is an error, not a warning. Sources in decreasing priority: constructor arguments, environment, .env, the TOML file, then file secrets. The TOML path is $NPCAGENT_CONFIG_FILE if set, otherwise ~/.npcagent/config.toml.

Every field on NPCAgentSettings. Environment names are the field name uppercased behind the NPCAGENT_ prefix.
FieldDefaultConstraint
data_dir~/.npcagent
log_levelINFODEBUG · INFO · WARNING · ERROR · CRITICAL, upper-cased
log_jsonFalse
default_cli_providerclaudenormalised to a canonical provider id
default_modelsonnetvalidated against the provider's aliases
max_spawn_depth51 ≤ n ≤ 20
prompt_cache_ttl_seconds300> 0
cron_tick_interval_seconds60> 0
webhook_port87651 ≤ n ≤ 65535
worktree_cleanup_modearchivekeep · delete · archive
worktree_base_branchHEADso main, master or a feature branch all work

Only two of these are written by npc model: default_cli_provider and default_model. Everything else is environment or hand-edited TOML.

Environment

Beyond the settings prefix, a number of variables are read directly at their point of use. They are listed here because several of them are the only way to reach the behaviour they control.

Variables read outside the settings model, grouped by what they affect.
VariableEffect
NPCAGENT_CONFIG_FILEoverride the TOML path
NPCAGENT_DB_PATHoverride the database file when no repo path is bound
NPCAGENT_USER_PLUGIN_DIRreplace ~/.npcagent/plugins/ in discovery
NPCAGENT_USER_WORKFLOW_DIRreplace ~/.npcagent/workflows/
NPCAGENT_USER_SKILL_DIRreplace ~/.npcagent/skills/
NPCAGENT_FORCE_OFFLINE_PLANNERuse the deterministic planner for both stages; execution is unaffected
NPCAGENT_LIVE_COMPACTequivalent to --compact
NPCAGENT_NO_AUTO_SERVEstop bare npc from autostarting the daemon
NPCAGENT_CAO_SHELL_TIMEOUT_SECONDSpatched into CAO; default 60.0
NPCAGENT_CLAUDE_STARTUP_TIMEOUT_SECONDSClaude Code readiness timeout; default 120.0. Suggested in the failure message when a launch times out.
NPCAGENT_CAO_KEEP_SESSIONSleave tmux sessions alive after a run
NPCAGENT_API_TOKENenable bearer auth on the REST surface
NPCAGENT_API_ALLOW_REMOTEpermit a non-loopback bind; also requires a token
NPCAGENT_OTELStub logs "OTEL enabled (stub - no exporter wired yet)"
ANTHROPIC_API_KEYoptional; enables the live Stage 1 and Stage 2 planners only
BRAVE_API_KEY / EXA_API_KEYback the web_search tool; without either it raises not_configured
CODEX_HOME / KIMI_HOMEwhere dynamic model discovery looks for a provider's model cache
Refused at startup

NPCAGENT_FORCE_STUB_CAO, NPCAGENT_TEST_STUB_GOALS and NPCAGENT_FAKE_SPAWNER are checked on the root CLI callback. If any is set, the process prints an error naming the offenders and exits 2. They are historical stub switches, and the check exists so a misconfigured shell cannot quietly regress the runtime into a fake artefact writer.

Memory cascade

A memory read walks four scopes, most specific first, and returns the first hit. Symlinked paths are skipped on read and rejected outright on write.

1 · agent
<repo>/.npcagent/agents/<agent>/
2 · project
<repo>/.npcagent/projects/<project>/memory/ — the default write level
3 · repo
<repo>/.npcagent/memory/
4 · global
~/.npcagent/memory/ — always present, re-evaluated at call time so a changed $HOME is honoured

Within any scope directory the layout is the same: a mutable current.md, dated YYYY-MM-DD.md dailies, an archive/, and — once facts have been extracted — semantic/facts.json. Writes take an fcntl.flock. Compaction rolls dailies older than one day into weekly/YYYY-WNN.md and weeklies older than seven days into monthly/YYYY-MM.md, moving originals to archive/. It is idempotent, because archived files are no longer discoverable.

Entries are filed under one of six closed categories: decision, preference, commitment, constraint, open_loop, fact.

Injection is cache-shaped

Memory reaches an agent as a single XML-ish block appended to the system prompt after a static prefix, with an explicit cache breakpoint between them:

<workspace-context captured-at="2026-04-28T15:41:02+00:00"> <current-state>…</current-state> <recent-history> <day file="2026-04-27.md">…</day> </recent-history> <facts> <fact category="decision">…</fact> </facts> </workspace-context>

The block is capped at 32,000 characters and truncated with a <truncated/> marker. On the way back out, scrub_memory_tags() strips these tags from agent output before it is displayed or written to the event log, and a StreamingScrubber handles the case where a tag is split across two stream chunks.

Two honest gaps

LocalMemoryProvider.search() is a plain substring scan over *.md — it does not use the FTS5 index, which only ever indexes agent.message events. decay() is a documented no-op: there is no TTL or retention policy. The scope= keyword on get, put and search is accepted and ignored; the provider's bound scope is what governs resolution.

See also

npcagent 0.1.0 Apache-2.0 FILES(5)