Architecture
This page is for contributors changing pm-cli internals. Users should start with Quickstart. Agents should start with Agent Guide.
Agent Quick Context
- CLI wiring lives in
src/cli/. - Domain behavior lives in
src/core/. - Public SDK exports live in
src/sdk/. - Items are stored as TOON by default; history is append-only JSONL.
pm contractsis the machine-readable runtime contract source.
Tracked documentation work: pm-u9d0. Search and invocation decomposition: pm-jt3b and pm-yh8r.
System Overview
pm-cli is a TypeScript ESM CLI for Node.js 22.18+. It is file-backed, git-native, deterministic, and designed for concurrent human plus agent workflows.
High-level flow:
- Commander parses CLI input in
src/cli/main.tswith commands registered via per-family modules (register-setup.ts,register-list-query.ts,register-mutation.ts,register-operations.ts). - Command modules normalize options and call domain services.
- Domain services load settings, acquire locks when needed, mutate canonical item documents, and append history.
- Renderers emit TOON by default, JSON when requested, and markdown for calendar views.
- Extensions can add commands, schema, renderers, import/export handlers, search providers, lifecycle hooks, and selected service overrides.
Source Tree
src/
cli.ts
cli/
main.ts
runtime/
activation.ts
invocation-options.ts
selection.ts
telemetry-outcome.ts
register-setup.ts
register-list-query.ts
register-mutation.ts
register-operations.ts
registration-helpers.ts
schema/
registration.ts
commands/
annotations/
history/
lifecycle/
query/
workspace/
governance/
test/
extension/
help-content.ts
error-guidance.ts
extension-command-options.ts
core/
extensions/
loader.ts
manifest-parser.ts
registration-contracts.ts
fs/
history/
item/
lock/
output/
schema/
search/
store/
item-metadata-cache.ts
test/
validate/
shared/
mcp/
server.ts
tool-definitions.ts
sdk/
cli-contracts.ts
index.ts
runtime.ts
runtime/
actions.ts
context.ts
schema.ts
completion.ts
completion/
shared.ts
bash.ts
zsh.ts
fish.ts
query/
search.ts
search/
input.ts
filters.ts
corpus.ts
lexical.ts
semantic.ts
response.ts
types.ts
mcp/
discovery.ts
types/
tests/
unit/
integration/
.agents/
pm/
extensions/
docs/
scripts/
Important public docs:
Storage Layout
Project tracker root defaults to .agents/pm/.
.agents/pm/
settings.json
epics/
features/
tasks/
chores/
issues/
decisions/
events/
reminders/
milestones/
meetings/
plans/
stories/
history/
locks/
schema/
checkpoints/
runtime/
search/
extensions/
Type folders are created on demand: the listing above shows the built-in item
types plus stories/, a representative folder created by a custom or preset
type. schema/ holds config-driven customization (types.json,
statuses.json, fields.json), checkpoints/ holds bulk-mutation rollback
snapshots, and runtime/ holds non-canonical operational state (for example
background-refresh coordination). The legacy required index/ directory was
removed in 2026-05-31 (pm-yf31).
Required data:
- item documents under type folders
history/<id>.jsonlsettings.json
Optional rebuildable data:
- keyword and vector search cache files (
search/) checkpoints/andruntime/operational state
Item Documents
Default format is TOON:
id: pm-a1b2
title: Implement restore replay
description: Restore should rebuild target item state from history.
type: Task
status: in_progress
priority: 1
tags[2]: history,restore
body: |
Implementation notes.
Legacy JSON-front-matter markdown files are read only for one-way migration into TOON. Runtime internals use metadata as the item metadata model key.
Item metadata carries an explicit, monotonically increasing storage format version (pm_format_version). The baseline version (1) is the implicit default and is never serialized, so the field stays absent for the entire current corpus and adds no per-item token cost; it only materializes once an item advances past the baseline via a future breaking migration. Absence therefore always means the baseline. core/item/item-format-version.ts owns the constant and the classification helpers (effectiveItemFormatVersion, classifyItemFormatVersion, scanItemFormatVersions) that pm health (the integrity check) and pm validate (the format_version check) use to flag items that are outdated (a future migration would rewrite them) or ahead of the runtime (written by a newer pm). When a breaking item metadata change ships, bump CURRENT_ITEM_FORMAT_VERSION and add a migration that rewrites items below it — the version gate makes that a per-item decision instead of a full-corpus structural re-parse.
Built-in item types (11; confirm at runtime with pm schema list):
EpicFeatureTaskChoreIssueDecisionEventReminderMilestoneMeetingPlan
Runtime type resolution merges built-ins, persisted project schema in
.agents/pm/schema/types.json (pm schema add-type / pm init --type-preset),
settings.item_types.definitions, and extension registerItemTypes(...)
registrations.
Mutation Contract
Every item mutation follows the same safety path:
- Resolve project root and settings.
- Acquire item lock when mutating existing item state.
- Read and parse the current canonical item document.
- Enforce ownership and policy gates.
- Compute
before_hash. - Apply mutation in memory.
- Set
updated_at. - Compute RFC6902 patch and
after_hash. - Write item atomically through temp-file plus rename.
- Append one history JSONL line.
- Release lock.
If a write fails after state changes begin, mutation code attempts rollback before returning the error.
History and Restore
History entries are append-only JSONL records:
{
"ts": "2026-05-01T12:00:00.000Z",
"author": "codex-agent",
"op": "update",
"patch": [],
"before_hash": "sha256...",
"after_hash": "sha256...",
"message": "Start implementation"
}
pm restore <id> <timestamp-or-version> replays history from create through the target record and appends a restore event. Restore does not rewrite prior history.
Useful diagnostics:
pm history <id> --full --diff --verify
pm activity --id <id> --limit 50
pm validate --check-history-drift
Command Contracts
Command/action metadata is centralized in src/sdk/cli-contracts.ts and used by:
- CLI option normalization
- help output
- completion generation
- provider-safe tool schemas
pm contracts- extension command/action contract exposure
Use runtime contracts instead of duplicating flag lists:
pm contracts --json
pm contracts --command create --flags-only --json
pm help create --json
Adding a Command or Flag (Wiring Checklist)
A new command or field-mutating flag touches several registries. Missing one
produces a silently partial surface (for example a flag that parses on the CLI
but is absent from pm contracts, MCP, or completions). Wire each site that
applies:
- Commander registration — register the command/flag in the relevant
src/cli/register-*.tsfamily module (register-setup,register-list-query,register-mutation,register-operations). - SDK operation — implement domain behavior in
src/sdk/and expose it through the appropriate published SDK entrypoint. Register the CLI adapter directly in its command family. Private command barrels are unnecessary; executable consumers and published entrypoints establish module reachability. - Flag contracts — declare flags in
src/sdk/cli-contracts.ts(the*_FLAG_CONTRACTSregistries). Uselist: trueonly for comma-list accumulation flags, never for Commandercollectrepeatable flags. Flags that should not appear in the public surface go through theNO_SURFACEset. - MCP exposure — if the command is agent-callable, add or extend its tool in
src/mcp/tool-definitions.ts(tool definition plus parameter properties). Shared parameter names (fields,scope) are owned centrally — prefer a new boolean over overloading a shared enum. - Option policies — if the flag participates in
command_option_policies(provided-set governance), wire it into the command's policy declaration. - Package policy scope — optional packages validate their policy-specific flags before mapping them onto the core's narrow internal ownership controls.
- Docs and completions — document the command in
Command Reference; completion output is generated from the
contracts, so confirm
pm completionreflects the new surface. - Contract snapshot — run
pnpm contracts:updateto regeneratetests/fixtures/contracts/full.json; the static gate compares against it. - Coverage — add focused tests so the new module keeps the corpus at
100/100/100/100(see Testing Architecture).
Verify the end-to-end surface with pm contracts --command <name> --json,
pm help <name> --json, and the matching MCP tool listing.
Private Export and Directory Boundaries
Command adapters and their specs are grouped by domain under
src/cli/commands/ and tests/unit/commands/. Registration stays in the
CLI family modules; domain behavior stays in the SDK. The removed private
command barrel is not a package entrypoint.
pnpm quality:exports runs Knip with zero allowed unused values or types,
then executes an isolated negative control that introduces both kinds of dead
export. The configured entrypoints are published SDK/bin sources, package
activation modules, executable examples, and the invoked static-gate module.
Only externally consumed entrypoint exports and declarations used inside their
own module are retained without an internal importer. There is no directory-wide
SDK ignore rule. Explicit public migration re-exports remain available to
package authors; the SDK surface snapshot separately guards their signatures.
Tracked by pm-f4yn and pm-kb5h.
SDK-First Boundary
The target layering is: src/core remains private implementation detail,
src/sdk is the public programmatic API, and src/cli plus src/mcp are
presentation layers over SDK primitives. The migration is tracked under
pm-usfg, with the current map in
SDK Primitive Inventory.
The static quality gate now enforces the completed boundary without an allowance file:
- The hard gate counts type-only imports and re-exports too. A type-only
src/corereference still couples the presentation layer to private core contracts, so it must be promoted behindsrc/sdkwith the rest of the primitive surface. pnpm quality:staticfails on every private edge or computed dynamicimport()from a boundary source; the required edge count is always zero.- Shared presentation-host services live in
src/sdk/runtime-primitives.ts. Higher-level consumers should prefer typed SDK operations andPmClient.
Do not add new CLI/MCP behavior by deep-importing src/core. Add or extend an
SDK primitive first, then consume that SDK surface from the presentation layer.
Schema command registration and rendering live in src/cli/schema/registration.ts,
with field, workflow, and migration behavior delegated to the SDK-backed schema
adapter. This keeps the general mutation registrar focused on family wiring
(pm-gdi7).
src/cli/main.ts owns invocation state and lifecycle sequencing. Its runtime/
modules separately own extension activation decisions, command-family selection,
option validation and provenance, and telemetry outcome classification. These
modules consume SDK contracts and never import the main entrypoint. Mutable
invocation caches remain in the orchestrator so repeated calls retain the same
reset boundary.
src/sdk/runtime.ts owns the public client, extension lifecycle isolation and
final read projection. runtime/actions.ts routes built-in actions to SDK
operations, runtime/schema.ts translates schema requests, and
runtime/context.ts owns shared dispatch types and argument validation. These
modules do not import the runtime entrypoint. Public imports remain stable.
Completion generation follows the same pattern: src/sdk/completion.ts selects
the shell, completion/shared.ts derives shared vocabulary from contracts, and
the Bash, Zsh and Fish modules render their respective scripts.
Progressive tool discovery follows this boundary directly: src/sdk/mcp/discovery.ts owns ranking, authorization filtering, budgets, cursors, omissions, cache identity, and the canonical result contract. src/mcp/server.ts only negotiates the extension, selects the entry catalog, and adapts compatibility text. See Progressive Tool Discovery.
Telemetry Schema Negotiation
Telemetry preserves wire compatibility through an explicit client/server negotiation split:
- Event payloads keep
event.schema_versionas the event-document schema (currently v1). - Queue envelopes include
client_schema_versionso client/runtime evolution can be tracked independently from event payload versioning. pm health --check-telemetryprobes/healthzand records any advertised max-version header for observability/debugging.
This keeps v1 behavior stable while providing a forward path for future telemetry schema upgrades.
Output Pipeline
Core output formats:
- TOON for sparse, token-efficient default command output
- JSON for strict machine parsing
- markdown for calendar-oriented views
The renderer omits null, undefined, empty arrays, and empty objects from sparse TOON fallback output. JSON preserves the machine payload.
Search Architecture
src/sdk/query/search.ts is the search orchestrator and compatibility export
surface. Its search/ directory separates input parsing, metadata filtering,
corpus reads, lexical scoring and highlighting, semantic provider execution,
response projection, and shared type contracts. The CLI search adapter delegates
to this SDK pipeline; package consumers continue to use the published SDK
entrypoints rather than these internal module paths.
Inline field:value filters are extracted before keyword tokenization; explicit
flags take precedence. Filtering precedes ranking, and projection follows
ranking and pagination. Lexical scoring and highlighting share their field
definitions, while linked-content reads retain both path and realpath containment
checks. Changes to a stage must preserve this ordering and the public result
contract.
Search supports:
- keyword mode, always available
- semantic mode, when an embedding provider and vector store are available
- hybrid mode, combining keyword and semantic results
Keyword scoring uses weighted fields such as title, description, tags, status, body, comments, notes, learnings, reminders, events, and dependencies. Semantic indexing uses the same core corpus so calendar-heavy work remains discoverable through normal search and reindex flows.
Runtime semantic components can come from built-ins or extensions:
- provider selection:
settings.search.provider - vector adapter selection:
settings.vector_store.adapter - extension registration:
registerSearchProvider(...)andregisterVectorStoreAdapter(...)
Useful commands:
pm search "restore history" --mode keyword --limit 10
pm reindex --mode hybrid --progress # requires the search-advanced package
pm health --check-only
Performance and Startup Latency
pm-cli is optimized for the agent loop, where many short commands run back to
back. The performance model has three layers (the absolute timings below are
indicative order-of-magnitude figures at the time of writing — treat the relative
behavior, not the exact milliseconds, as the durable contract):
- Per-command startup. After a command-family code split, each handler imports only its own command module rather than the full command barrel, so a read command does not pay for mutation/search modules. On a clean project the dominant remaining cost is Node ESM module resolution (~90ms); this is the last structural startup lever and is tracked under the observability epic.
- Reads. The item metadata cache splits item metadata from body text and skips
re-reads of unchanged files, and on-read hooks are skipped when no extension
registers one.
pm healthuses a drift-scan verification cache so repeated health checks do not re-hash every history stream. - Mutations. Mutations are non-blocking: the semantic reindex runs in a
detached background worker behind a reindex lock instead of inline embedding,
and item-format migration skips already-migrated items rather than re-parsing
the whole corpus on every write. This is what keeps
create/updatein the hundreds-of-milliseconds range instead of multi-second inline-embed latency.
What dominates latency in a given repository:
- a clean project is fast (~140ms); a large dev repo is slower mainly from many auto-loaded extensions and any inline embedding provider, not from item count.
pm --versionshort-circuits before the main entrypoint, so it is not a valid probe for command startup cost.
Profiling startup cost:
node --cpu-prof --cpu-prof-dir=/tmp/pmprof dist/cli.js list >/dev/null
pm health --check-only # drift-scan + telemetry timings
Reindex and embedding remain the heaviest background operations; keep them off the synchronous mutation path. See the observability epic (pm-5oj5) for tracked perf work.
Extension Host
core/extensions/loader.ts sequences discovery and extension lifecycle calls.
manifest-parser.ts parses untrusted manifest data without loading code;
registration-contracts.ts validates contributed definitions before the
registrar publishes them. Containment checks, activation rollback and teardown
remain at their original lifecycle boundaries.
Load order:
- core commands
- global extensions
- project extensions
Project extensions take precedence over global extensions for matching command or renderer keys. Extension dispatch is extension-first when a registered handler matches a core command path.
Extension override planes:
- commands
- parser overrides
- preflight overrides
- service overrides
- renderers
- import/export handlers
- item fields and item types
- migrations
- search providers and vector adapters
- lifecycle hooks
See Extensions and SDK.
Testing Architecture
Tests live under:
tests/unit/
tests/integration/
All tests must run with sandboxed PM_PATH and PM_GLOBAL_PATH. Use:
node scripts/run-tests.mjs test
node scripts/run-tests.mjs coverage
Linked-test execution also creates sandbox roots and can seed settings/extensions for schema parity. See Testing.
Coverage governance is literal all-source, not a curated allowlist:
vitest.config.tscoverage.includeis the full ship surface:src/*.ts,src/**/*.ts,packages/**/*.ts,scripts/*.mjs,scripts/**/*.mjs,plugins/*.mjs,plugins/**/*.mjs, and thedocs/examples/**/*.{ts,js,mjs}reference snippets. The onlycoverage.excludeentry issrc/**/*.d.ts(type-only declarations have no executable lines).- Global thresholds are
100/100/100/100(lines/branches/functions/statements) for the whole measured corpus — there is no per-file ratchet and no per-file/* c8 ignore */allowlist for production modules. - Adding a new module under any included root automatically pulls it into the gate. There is no include-list to edit; if a new module is genuinely not shippable source (a throwaway script), it belongs outside these roots rather than in a hand-maintained exclude list.
- When authoring example snippets under
docs/examples/, import the published SDK by its bare specifier (@unbrained/pm-cli/sdk);vitest.config.tsaliases that tosrc/sdk/index.tsso the example specs cover without the workspace self-link present in a clean CI install. - When a module is hard to test end-to-end (for example CLI orchestration),
extract pure logic helpers into small modules and cover those directly instead
of weakening thresholds. Run
node scripts/run-tests.mjs coveragelocally to confirm100/100/100/100before pushing.
Terminal Compatibility
Runtime behavior should remain terminal-neutral:
- no required ANSI or custom terminal protocol
- deterministic TOON/JSON/markdown output
- graceful
process.exitCodehandling - broken-pipe-safe output writes
- explicit TTY rejection for stdin token paths that require piped input
- non-interactive linked-test subprocess handling
Public Documentation Boundary
Architecture docs should describe source structure and public runtime behavior only. Ignored local operations material and host-specific runbooks must stay out of tracked docs.