Documentation
On this page
  1. Install
  2. Import Surfaces
  3. Public-surface compatibility
  4. Public Exports
  5. Lock ownership and recovery
  6. Portable merge settlement
  7. Build an entire custom project tool
  8. Build a non-PM temporal domain
  9. Plan workflows
  10. Immutable history and rich item reads
  11. Linked resources and dependency governance
  12. Agent output budgets and discovery
  13. Static And Runtime Contracts
  14. Atomic workspace transactions
  15. Expected item revisions
  16. Schema evolution and workspace history
  17. Query execution
  18. Execution and diagnostics
  19. Context relevance and evaluation
  20. Capability Requirements
  21. Minimal Command Extension
  22. Self-Identity and Lifecycle
  23. Flag Contracts
  24. Handler keys and ownership
  25. Output Ownership
  26. Package Error Provenance
  27. Expected CLI Errors
  28. Producer-owned diagnostics (context.details)
  29. Package Runtime Imports
  30. Authoring Builders
  31. Declarative Authoring
  32. Modular blueprints
  33. Generate the manifest (author once)
  34. Ship both halves (author once)
  35. Author-time introspection and preflight
  36. Testing Helpers
  37. Project profiles (registerProfile)
  38. Custom Item Type
  39. Importer / Exporter
  40. Search Provider
  41. Completion-aware reporting and migration
  42. Robust Automation Pattern
  43. CLI Simplification Migration
  44. Authoring Pattern
  45. Related Docs

SDK

The supported programmatic surface is @unbrained/pm-cli/sdk.

API reference consolidation is tracked by pm-4g4qv0.

Current SDK initialization and migration additions are tracked by pm-1w3ljt and pm-s79kel. Tiered entrypoints and the public-surface compatibility gate are tracked by pm-38bskj and pm-e6tm5c. Aggregate completeness, published surface data, embedded concurrency, and duplicate discovery are tracked by pm-obbh43, pm-lnswp0, pm-zpoyg9, and pm-n13lzc. Public package-runtime imports, list projection contracts, merge-fence schema scope, and linked-test source coordinates are tracked by pm-w7mqzt, pm-pjnu91, pm-mkzw1x, and pm-uawujr. Lean read envelopes, service-override compatibility, and mutation-aware drift-cache invalidation are tracked by pm-oi4zs3, pm-u2tqn6, and pm-ajaskl. Fresh-clone directory semantics are tracked by pm-xyuhh7 and pm-0k4o8t. Reproducible recipes and content-addressed snapshots are tracked by pm-rbcvt2 and pm-dkrmzv; see Reproducible Workspaces and Snapshots.

Extension migration receipts, install-source identity, and scoped preflight ownership are tracked by pm-ig5cfe, pm-495lkc, and pm-miy5k6.

Shared item addressing, lossless recovery, explicit-empty create policy, semantic command discovery, and append-stable linked tests are tracked by pm-mkinft, pm-p316vn, pm-st7wgu, pm-g543, and pm-x2vx. The supported CLI entrypoint boundary, truthful context-intent receipts, and first-class recurrence relationships are tracked by pm-rf120g, pm-3crymx, and pm-ouyq3n. Fail-closed whole-corpus reads and package-catalog output controls are tracked by pm-y4aaol and pm-2firut. Positional command signatures, action-scoped discovery, and MCP/CLI option parity are tracked by pm-ya7x55, pm-2tan, and pm-y0htkd. Terminal recurrence and executable recovery are tracked by pm-83cz0o, pm-qljv, and pm-surv; see Recurrence and Executable Recovery Contracts.

Linked-resource argument recovery and assignment-only test admission are tracked by pm-t2dqzijokl31 and pm-gh1555.

Use it for extension authoring, package authoring, command/action contract discovery, and deterministic app or CI automation. Do not import private src/core/... modules from external integrations or packages.

MCP hosts can build on the public stateless protocol, MRTR, cache/schema, and durable task primitives described in MCP Interaction and Task SDK. Those contracts are owned by pm-rz9gep, pm-rzs24j, and pm-hv1x1x.

Install

npm install @unbrained/pm-cli

The SDK ships inside the CLI package. There is no separate @unbrained/pm-sdk package; package authors should depend on @unbrained/pm-cli and import the public subpaths below.

The shipped declarations reference Node globals and node:* modules. TypeScript consumers must declare @types/node as a development dependency matching their supported Node major. The package declares the bounded optional peer ^22 || ^24 || ^26; CLI-only installs do not require type packages. Use "moduleResolution": "node16", "nodenext", or "bundler" so the exports-mapped ./sdk types resolve.

npm install --save-dev typescript @types/node@26

Import Surfaces

import { defineExtension } from "@unbrained/pm-cli/sdk";

Choose the narrowest stable entrypoint that owns the capability you need. This keeps package startup and memory proportional to its purpose while the aggregate barrel remains source-compatible:

Package export Intended capability family
@unbrained/pm-cli/sdk/authoring Extension blueprints, builders, manifests, and package-author helpers
@unbrained/pm-cli/sdk/contracts Static CLI command/action contracts and expected-error protocol
@unbrained/pm-cli/sdk/core Broad item, schema, profile, transaction, and runtime primitives
@unbrained/pm-cli/sdk/governance Validation, health, garbage collection, and transaction cleanup
@unbrained/pm-cli/sdk/graph Relationship stores, graph assembly, traversal, analytics, and remediation
@unbrained/pm-cli/sdk/merge VCS-neutral tracker merge contracts
@unbrained/pm-cli/sdk/query List/search engines, filtering, pagination, and query rendering
@unbrained/pm-cli/sdk/runtime Embedded command execution and package runtime helpers
@unbrained/pm-cli/sdk/testing Package and extension assertion/invocation helpers
@unbrained/pm-cli/sdk/public-surface.json Published machine-readable SDK compatibility snapshot
@unbrained/pm-cli/sdk Compatibility aggregate containing every supported SDK export

@unbrained/pm-cli/cli is the supported embeddable CLI entrypoint and exports only runPmCli. Repository tests import their internal seams from source; those helpers are not package API. The surface snapshot records the subpath under the executable_entry classification, so any future exposure is a classified surface change. The bare @unbrained/pm-cli root export is recorded as an aggregate_alias of @unbrained/pm-cli/sdk: its symbols are not duplicated, but a retarget of its declaration path is a snapshot change. The committed entrypoint import-cost table records fresh-process latency, RSS, and reduction versus the aggregate barrel. Every narrow entrypoint is bundled and type-tested independently.

Package runtime modules use ordinary static ESM imports from these public entrypoints. They do not locate copied source files through PM_CLI_PACKAGE_ROOT, generate loader shims, or dynamically import host internals. The package installer provides the host SDK link for copied extensions, so the same source works from npm, GitHub, a bundled package, or a local directory.

List consumers can inspect LIST_COMMAND_DEFAULT_PROJECTIONS instead of copying command-specific defaults. Every compact or field-projected list result now echoes the applied projection; brief remains distinct from compact, and DEFAULT_COMPACT_LIST_FIELDS / BRIEF_LIST_FIELDS provide the canonical field sets. pm contracts --command list --flags-only --json includes the same per-command projection surface.

Public-surface compatibility

sdk/public-surface.json is the semantic public API snapshot for every supported SDK entrypoint. It records exported names, declaration kinds, normalized signatures and type parameters, support classification, and stable SDK error codes. Run:

pnpm sdk:surface:check

The gate classifies new exports as additive, declaration-kind-preserving signature evolution as compatible only when the semantic signature is unchanged, and removals or signature changes as breaking. Intentional additive changes use pnpm sdk:surface:update. An intentional breaking change must use pnpm sdk:surface:update -- --acknowledge-breaking "<release rationale>"; the reason, affected surface, package version, and date remain reviewable in the snapshot. Breaking SDK changes require a date-based release and matching package compatibility bounds or migration guidance. Never refresh the snapshot merely to silence an unexplained diff.

The mutation-admission update adds optional changed?: false to PmCliErrorContext. The conservative signature gate records this interface addition with a breaking acknowledgement; existing context objects and error codes remain valid. Consumers need no data migration or new dependency bounds. When adopting this update, read error.context?.changed === false as proof of a preflight refusal. An absent property gives no guarantee about writes; do not coerce it to false or treat an executed failure as safe to retry. Consumers that enumerate error keys should accept the additional optional property.

The telemetry consent update (pm-gh1660) records two conservative breaking acknowledgements. WriteSettingsOptions gains optional omitSections and skipIfStored; existing calls compile and behave unchanged. ConfigResult.values becomes Partial, because project-scope list and export omit the global-only telemetry_tracking key: treat that key as possibly absent, and read consent from a global-scope export. No data migration or new dependency bounds are needed.

The collection-mutation receipt update (pm-gh1700) records a breaking acknowledgement for runTest, runFiles, runDocs and their option and result types. A write (add, addJson, addGlob, remove, removeIndex, migrate, or a linked-test edit) now returns only the entries it added or rewrote: count equals the entries returned, total_count the entries stored, and mutation_receipt (changed_count, removed_count, full_history_included) plus the annotation-style omission_receipt describe the rest. Pass fullHistory: true (CLI --full-history, MCP full) to receive the whole collection after a write, or read it with a call that has no mutation options; list reads are unchanged. TestResult.failure_categories is present only when the call ran linked tests, so read it as result.failure_categories?.timeout. The implicit post-write path validation covers the returned entries, while validatePaths: true still checks every stored path. No data migration or new dependency bounds are needed: on 2026-11-11 the released pm-vcs and pm-web read stored lists only from list calls. projectLinkedCollectionMutation and writtenLinkedEntries are exported so a package can bound its own collection writes the same way.

The waiver notice update (pm-xcndcu) records a breaking acknowledgement for BoundaryFixtureRegistry, BoundaryFixtureReport, DefectRecurrencePolicy and DefectGateEvidenceReport. Both inputs gain an optional whole-day waiver_notice_days (0 to 366, absent means 0), and both reports gain a required waiver_notices array of live waivers expiring inside that window. Notices never change ok, so existing readers and gates behave unchanged; code that constructs either report, such as a test double, must add waiver_notices: []. An expired gate-evidence finding's detail now names the recorded expiry. No data migration or new dependency bounds are needed. isWaiverNoticeDays, waiverNoticeFor and sortWaiverNotices are exported so a package can apply the same notice window to its own waivers.

Validation refusals for item status and priority carry producer-owned code: "invalid_argument_value" and context.field, context.value and context.allowed. The allowed spellings come from the active status schema or priority domain; values are represented as strings, including numeric priority input. Missing-item lookups carry code: "item_not_found" and context.id. These are the same codes as the corresponding CLI envelopes. SDK status messages name status rather than a CLI flag, and callers can handle these refusals through isPmCliExpectedError without parsing the message. Existing document-parse and ownership diagnostics retain their separate codes.

resolvePmCommandScopeArgs({ path, noExtensions }, cwd?) builds portable tracker and extension arguments for package-authored guidance. It preserves an explicit path's spelling, omits the implicit cwd tracker, and expresses a different environment-selected tracker relative to the invocation directory. An environment-selected root-layout tracker at that directory is rendered as --pm-path . when a nested default tracker also exists. Completion corrections, missing close-evidence updates and lifecycle-verb examples share this formatter. SDK dispatch preserves the original scope before resolving storage paths, so copied commands retain relative selection and --no-extensions without adding a redundant absolute host path.

JavaScript create/update callers must also remove misspelled keys and move mutation fields into options on generic run calls: these requests now fail instead of silently dropping fields. See Robust Automation Pattern for supported aliases and runtime schema discovery.

The snapshot also records an aggregate-completeness census. Every public symbol from every non-testing SDK subpath must be available from @unbrained/pm-cli/sdk; deliberate test-only exclusions require a named reason. Consumers and release tooling can read the exact shipped artifact from @unbrained/pm-cli/sdk/public-surface.json without locating repository files.

The snapshot also records a signature-type census (signature_type_reachability). Every package type named by an exported function's parameters, return type, or type-parameter constraints must be importable from the same entrypoint, so import type { ProjectedGraphResult } from "@unbrained/pm-cli/sdk/graph" works beside runGraph instead of forcing Awaited<ReturnType<typeof runGraph>>. Identity is by declaration, so a same-named but different type does not count. pnpm sdk:surface:check refuses a gap and names the type, where it is declared, and the functions that name it (pm-gh1667, tracker record excluded from npm). The merge receipt's conflict_resolution field is typed by the exported ItemScalarConflictResolution.

The denominator is the package export map itself, not a list inside the generator. Every exports entry that declares a types path must carry a classification, and pnpm sdk:surface:check refuses to run while any published code export is unclassified — so adding a public entrypoint cannot ship it ungoverned.

Public Exports

The published SDK surface snapshot records every export, subpath, signature and support classification. Import its installed copy from @unbrained/pm-cli/sdk/public-surface.json when building discovery or compatibility tooling. It is generated from the package export map and checked against declarations; consumers do not need a second manually maintained list of symbols. Start with PmClient for typed actions, then choose a narrow entrypoint from Import Surfaces for direct primitives.

Use these focused guides to choose the primitive and understand its policy:

Capability Reference
Item lifecycle, schema, profiles and initialization Customization primitives, Lifecycle
History, whole-item input, transactions and previews Audited mutations, Atomic transactions, Schema evolution
Context ranking, packing, memory and evaluation Context relevance, Context coordination
Annotations, linked files/docs and dependencies Linked resources, Evidence contracts
Relationships, graph queries, adapters and federation Relationship graph
Merge drivers and immutable history convergence Merge safety
Execution, diagnostics and linked tests Execution, Linked-test edits
Plans and materialization Plan workflows
Extension builders and declarative packages Authoring builders, Declarative authoring
Package installation, activation and upgrade Extensions, Runtime boundaries
Flags, output budgets and deterministic refusals Flag contracts, Output budgets, Expected errors
Package and extension assertions Testing helpers

PmClient.run is the wide action adapter; typed convenience methods retain their exact option contracts. Runtime custom fields use repeatable field assignments. New symbols require classified surface evidence, and exhaustive consumers must follow the migration guidance for changed unions or error codes. The shared primitive inventory records CLI/MCP ownership: presentation modules import the SDK and have zero private-core edges.

Bulk recovery is tracked by pm-gh1563 and pm-gh1564. PmUpdateManyActionOptions and PmCloseManyActionOptions accept rollback: "checkpoint-id"; boolean values are refused before dispatch. The shared SDK inverse writer verifies attributed history and current field postimages under the item lock, preserving unrelated peer context. Rollback rows can now be skipped when no committed effect remains. Exhaustive consumers should handle that additional row state. See bulk operations for conflict, collection, legacy-checkpoint and ownership semantics.

Lock ownership and recovery

Use the public acquireLock primitive when a package must serialize its own state changes. Always await its release in finally:

import { acquireLock } from "@unbrained/pm-cli/sdk";

async function writePackageState(pmRoot: string, write: () => Promise<void>) {
  const release = await acquireLock(pmRoot, "example.state", 3600, "example-package", false, true, 30_000);
  try {
    await write();
  } finally {
    await release();
  }
}

The built-in provider automatically recovers a provably dead original Linux owner before TTL, including an owner killed inside its commit fence. Recovery retains the gate generation as evidence so concurrent recoverers cannot remove its replacement, including recovery after the age grace when a dead PID or verified PID reuse establishes abandonment. A readable gate with a live PID and unavailable start identity is retained regardless of age. An unreadable gate owner is also retained; age and failed reads do not prove abandonment. Recorded host coordinates must be verified; a local PID comparison cannot authorize recovery of foreign or unverifiable host evidence. Acquisition, commit-gate and release wait budgets use a monotonic clock; wall-clock adjustments do not extend those budgets. Unknown owners retain the existing lease policy. Conflicts expose the lock identity, observed process state, waited_ms, observed owner_handoffs and wait advice through PmCliError.context. The wait budget is a fixed total by default. Pass { waitPolicy: "progress" } in the options object to treat it as a no-progress window instead: each observed owner handoff restarts it, so a queue of live writers drains while a stalled owner still refuses after the budget. Without an explicit maxWaitMs such a wait is capped at 120 seconds (or the budget, when larger). Item mutations, restore and the hierarchy lock use the progress policy. Force does not bypass an unexpired lock whose owner is alive or unverifiable. TTL expiry permits removal regardless of owner liveness.

An expired holder can keep running, so fence the protected write with acquireLockLease. With the built-in lock provider, fence(commit) runs commit only while this lease still owns its lock generation; after a TTL or forced takeover it refuses with lock_lost (exit 4, context.lease and context.current_owner) and never runs commit. Takeover and fencing share one per-lock gate, so a takeover cannot interleave with a fenced commit. When an extension lock_acquire override handles the acquisition, the extension owns the lock lifecycle and fence(commit) runs commit unfenced: such a provider must itself exclude or fence concurrent commits, or perform an atomic compare-and-write at the storage boundary. A revision check made before the write is not a substitute, because another writer can commit between the check and the write. mutateItem and deleteItem additionally refuse with stale_revision when the stored item bytes changed after the locked read; that narrows, but does not close, the window for an unfenced override-held lock. restore fences its write with the lease (lock_lost) and re-verifies item and history under its lock, refusing with its own "changed while waiting for lock" conflict rather than stale_revision:

import { acquireLockLease } from "@unbrained/pm-cli/sdk";

const lease = await acquireLockLease(pmRoot, "example.state", 3600, "example-package", false, true, 30_000);
try {
  const next = await computeNextState(); // may outlive the TTL
  await lease.fence(() => writeState(next)); // refuses instead of overwriting a newer owner
} finally {
  await lease.release();
}

Locks are confined to the tracker (pm-gh1647, a repository-only tracker record excluded from npm). The built-in provider never follows a symlink below the tracker root. If locks/ or any other component between the tracker root and the lock entry is a symlink or not a directory, or the lock entry itself is a symlink, acquireLock and acquireLockLease refuse with lock_path_unsafe (exit 4). A regular-file lock entry is a peer's lock, not a refusal. The refusal carries context.reason (lock_directory_symlink, lock_directory_not_directory, lock_entry_symlink or lock_path_outside_tracker), the tracker-relative context.lock_component, and context.required recovery text for that reason. For these four reasons nothing is created outside the tracker and no cleanup gate remains. After creating the lock file, the provider checks that the new file is the confined entry in the directory it inspected. A directory swapped during acquisition refuses with the same lock_path_unsafe code and the fifth reason, lock_directory_replaced, with context.lock_component naming the replaced tracker-relative directory, and the lease is never returned. The lock file created through the swapped directory stays where the swap pointed, which can be outside the tracker, and a cleanup gate created before the swap can stay in the displaced directory. pm never deletes outside the tracker: Node has no descriptor-relative unlink, so a path-based delete could remove a same-name replacement. pm gc --scope locks applies the same check and refuses to sweep through a symlinked locks/ directory. The tracker root itself may be a symlink: confinement is relative to the root you pass.

See Configuration for host identity, local recovery audit receipts and PM_LOCK_WAIT_MS precedence. Extension lock providers retain responsibility for their own ownership and recovery policy.

Portable merge settlement

Tracked by pm-gh1475 and pm-gh1491. Import runMergeInstall, runMergeReconcile, and inspectMergeReceiptEvidence from @unbrained/pm-cli/sdk/merge. runMergeInstall({ preCommitHook: true }, global) explicitly installs the merge-aware pre-commit hook; the default installs no hook. runMergeReconcile({ stage: true }, global) previews first and stages only verified lossless history and receipt artifacts. It refuses forced acceptance, competing values, unrelated drift, dirty reconciliation paths and unresolved Git paths. staged_paths reports exactly which relative paths were added to the index. Git-ignored artifacts refuse before staging or auditing with merge_settlement_review_required, reason: ignored_merge_artifacts, relative paths, and owning ignore-rule coordinates. The same check applies to { stage: true, dryRun: true }; health reports legacy directory rules before a merge exists. This recovery is tracked by pm-gh1556.

mergeHistoryStreams preserves validated whole-field assignment/removal intent when another branch removed its target. Nested or conditional patches that cannot replay refuse with history_union_rebase_conflict and typed PmCliError.context.history_merge coordinates, retaining source inputs for deliberate recovery. See history semantics and pm-gh1553.

The single-commit journey pauses Git with git merge --no-commit --no-ff before reconciliation or an opted-in pre-commit hook runs. durable_receipt_ids on the inspection result reports counterparts with matching immutable provenance in the active Git index. unstaged_durable_receipt_ids separates valid filesystem sidecars that a read-only preview may inspect. Guarded staging prepares new sidecars before auditing and restores their new index entries if apply fails. Inspection lists indexed candidates once and reads bounded object batches. Guarded settlement resolves tracker aliases before preview, proof verification and apply, so every phase uses the same physical tracker root. Ordinary apply requires indexed proof first. Missing portable proof refuses an audit write even with force; local receipts alone cannot authorize committed history references. See Merge safety for the complete operator workflow and recovery boundaries.

Build an entire custom project tool

The SDK-only custom-tool exemplar is the acceptance reference for applications that use pm as a universal context engine rather than as an extension host. It imports only @unbrained/pm-cli/sdk and composes schema customization, custom workflow statuses, lifecycle mutations, annotations, linked resources, dependency graphs, bounded context, search, health, and validation into a standalone domain CLI.

Copy its reusable client pattern when building a company workflow, domain-specific tracker, VCS-like system, or other application whose durable state and business rules fit pm's public primitives. Keep the executable layer thin: domain logic belongs in a reusable SDK client, while argv parsing and rendering stay at the edge.

Embedded hosts can also import the SDK-owned bootstrap normalization primitives (normalizeBootstrapInvocation, parseBootstrapGlobalOptions, and related contracts) when they intentionally expose pm-compatible argv. Every runPmCli() call constructs a fresh Commander graph and restores the host's existing process.exitCode after both successful and failed invocations, so extension state and CLI exit state from one workspace cannot leak into the next in-process invocation.

Build a non-PM temporal domain

Tracked: pm-kr3t, pm-8ngt, and pm-j3swnb.

The installable digital-twin exemplar is the acceptance reference for a domain that is not project management. It models a production facility using public SDK contracts only: custom entity types and fields, a project profile, application-defined relationship kinds, durable event history, point-in-time projections, graph impact, package-owned invariants, crash-consistent multi-step mutations, offline replica merge, and tamper-evident bounded export.

Timestamp snapshots select every event whose event time is at or before the requested boundary while retaining append order among the selected events. This matters for offline systems: a late-arriving historical event becomes visible in its historical view without admitting an earlier-appended event from the future. Version snapshots and cursors remain append-sequence based.

Use the exemplar when evaluating whether a domain belongs in pm core. Entity and policy concepts stay package-owned; stable identity, schema, immutable history, graph semantics, attribution, transactions, and bounded context are the reusable SDK primitives. Its gap report maps each domain need to the public primitive and canonical pm lineage so future packages can extend the platform without duplicating work.

Plan workflows

Tracked: pm-qd3woa and pm-ypuc39.

Custom tools can manage durable plans without shelling out or importing CLI internals. The typed façade uses the same engine and result envelopes as pm plan and MCP pm_plan:

import { PmClient } from "@unbrained/pm-cli/sdk";

const pm = new PmClient({ pmRoot, author: "planning-agent" });
await pm.schemaAddField("acceptance_owner", {
  type: "string",
  requiredOnCreate: true,
  requiredTypes: ["Task"],
});
const created = await pm.planCreate({
  title: "Ship governed workflow",
  createMode: "progressive",
  acceptanceCriteria: "Every materialized task passes its linked tests",
  comment: ["text=Plan created through the shared mutation contract"],
  step: ["Implement", "Verify"],
});
await pm.planUpdateStep(created.plan.id, "plan-step-001", {
  stepStatus: "in_progress",
});
const result = await pm.planMaterialize(created.plan.id, {
  steps: "all",
  materializeType: "Task",
  field: ["acceptance_owner=planning-agent"],
});

Plan creation accepts the shared typed mutation metadata and linked-resource fields and forwards them through normal schema-aware create validation. field is repeatable and also forwards name=value pairs through that validation for every materialized child. Required-on-create fields remain mandatory. Each materialized entry includes id, title, type, parent, tags, and from_step, so an agent can confirm the created work without extra get calls.

Promoted Plan links retain their graph semantics: depends_on canonicalizes to blocked_by, while implements and verifies are built-in directed relationship kinds. Custom graph tools therefore receive the same meaning advertised by PlanStepLinkKind instead of a lossy related fallback.

Immutable history and rich item reads

runHistory(id, { compact: true }, global) and { provenance: true } default to newest-first rows, with or without limit. Full SDK reads (runHistory(id, {}, global)) and runAction with full: true default to chronological rows. Set order: "oldest_first" or order: "newest_first" explicitly to use the same direction across every projection, including value diffs. limit selects the latest N matching entries before ordering; it never changes the direction. CLI and runAction defaults use compact rows. The result declares order and a complete matching total; physical indices and durable versions stay unchanged. Read-output continuations traverse the chosen order without changing it. Verification and value-diff reconstruction still consume the authoritative chain.

Compatibility: compact/provenance reads with a limit previously returned chronological rows. Replay consumers must now request order: "oldest_first" (or CLI --order oldest_first) explicitly. Full reads retain their prior default. Tracked by pm-gh1617 (tracker record excluded from npm).

Tracked: pm-4a7m, pm-hib1, pm-y4z5, and pm-x1g5.

History rewrite ownership lives in the SDK. The CLI history-redact, history-repair, and history-compact modules are thin compatibility shims over runHistoryRedact, runHistoryRepair / runHistoryRepairAll, and runHistoryCompact / runHistoryCompactBulk. Applications normally use the typed PmClient methods (or matching top-level historyRedact, historyRepair, historyRepairAll, historyCompact, and historyCompactBulk helpers), while package authors can call the run* primitives when they already own GlobalOptions.

import { PmClient, getItemAt } from "@unbrained/pm-cli/sdk";

const pm = new PmClient({ pmRoot, author: "records-agent" });

// Normal get projection, reconstructed at a one-based version.
const historical = await pm.get(itemId, {
  at: "7",
  fields: "id,title,status,body",
});
// historical.reconstructed === true
// historical.as_of_version === 7

// Direct canonical document primitive for a custom provenance/VCS interface.
const snapshot = await getItemAt(itemId, "2026-07-01T12:00:00.000Z", {
  pmRoot,
});

await pm.historyRedact(itemId, {
  literal: ["private.example"],
  replacement: "[redacted-host]",
  dryRun: true,
});
await pm.historyRepairAll({ dryRun: true });
await pm.historyCompactBulk({
  scope: "closed",
  allOver: 500,
  dryRun: true,
});

getItemAt and restore share resolveHistoryTarget, applyHistoryPatch, and replayHistoryToTarget: every patch is normalized, applied strictly, and checked against the recorded before/after hashes. The read primitive never acquires a lock, rewrites an item, appends history, or mutates a derived index. It returns the canonical ItemDocument plus reconstructed, as_of_version, as_of_timestamp, normalized target metadata, and the current stream length. Invalid and future targets throw PmCliError with a structured context.valid_range.

The shared resolver accepts explicit caller policy: immutable reads reject timestamps after the final recorded entry, while restore preserves latest-entry-at-or-before semantics so a current wall-clock timestamp restores the latest available state. Restore also retains command-specific diagnostics.

Generic reads also expose two composable SDK facets:

  • GetResult.item.notes_count makes omitted note context explicit at brief and standard depth without paying for note bodies. Deep/full reads expose item.notes and omit the redundant count; GetOptions.fields accepts notes_count for narrow agent reads.
  • buildItemSchedule normalizes deadlines, reminders, and events into a stable schedule projection with convenient earliest-event start_at, end_at, and location aliases. pm get includes it at standard/deep depth and supports narrow fields such as schedule.start_at.
  • buildItemChildrenRollup accepts metadata supplied by the persistent derived index or a source scan and returns type-agnostic direct-child totals, active counts, status groups, a deterministic id-sorted sample, and explicit truncation/continuation metadata. The primitive caps input at MAX_CHILD_PROJECTION_ITEMS (one million) and never guesses which schema types are containers.

The CLI automatically computes that workspace projection for container-oriented built-ins and custom types. Built-in leaf reads stay constant-cost unless the caller explicitly projects children; SDK consumers remain fully type-agnostic.

History reconciliation results distinguish changed fields whose replayed value is still structurally present from fields that were genuinely overwritten. HistoryRepairReconciliationReport exposes reconciled_*, preserved_*, and legacy-compatible reverted_*/discarded_* partitions. Append-only collection unions and pure reordering are preserved context, so they do not emit discard warnings or recovery guidance; actual scalar replacement and removal remain loud with attributed events and authors.

Linked resources and dependency governance

Tracked: pm-jcvg, pm-2ler, pm-chyh, and pm-p9sc. Conflicting batch refusal is tracked by pm-c6urop.

Custom tools can use the same domain primitives as the CLI without dispatching a command action. The direct functions accept the typed command options plus a GlobalOptions tracker path, while PmClient remains the ergonomic façade for applications that want shared workspace/author defaults:

The example assumes the host application already provides id, pmRoot, items, and its schema-aware terminalStatuses set.

import {
  PmClient,
  collectDanglingDependencyReferences,
  runDeps,
  runDocs,
  runFiles,
} from "@unbrained/pm-cli/sdk";

await runFiles(
  id,
  { add: ["src/domain.ts"], note: "implementation" },
  { path: pmRoot },
);
await runDocs(id, { add: ["docs/domain.md"] }, { path: pmRoot });
const graph = await runDeps(id, { format: "graph" }, { path: pmRoot });

const pm = new PmClient({ pmRoot, author: "integration-agent" });
await pm.filesDiscover(id, { apply: true });

const references = collectDanglingDependencyReferences(items, (status) =>
  terminalStatuses.has(status),
);
// references.active gates work; references.legacy_terminal is historical debt.

Local file and documentation paths have one storage contract: relative inputs are resolved from the invocation directory and stored relative to the workspace that owns .agents/pm. Absolute paths and remote references stay absolute. Globs, discovery, removal, and validatePaths use the same anchoring rule, so a tool invoked from a nested package never records a path that changes meaning when another agent runs from the repository root. Root-layout trackers use the tracker directory itself as their workspace.

Add and remove sets are compared after invocation-root anchoring, slash normalization, glob expansion, and requested migrations. If the same path is in both sets, runFiles, runDocs, and runLinkedArtifacts throw PmCliError code linked_artifact_mutation_conflict before calling the item store. This contract deliberately refuses to guess whether replacement or removal should win and guarantees that no item or history write is published.

runDeps also projects missing parent and legacy blocked_by references as typed missing edges, alongside structured dependencies. Tree and graph payloads apply shared depth/node/edge/token bounds and expose deterministic truncation reasons, effective limits, full totals, and omitted counts; summary mode obtains the same unique-node/edge census without materializing the tree. All formats therefore share the same relationship-integrity view. Token accounting renders the selected transport first, so JSON, TOON, tree, graph, and context enforce their ceilings against emitted UTF-8 bytes rather than an internal-object approximation. Context packets report that exact transport cost in context.meta.usedTokens.

pm validate --check-lifecycle uses the same classification. Its dependency_references check warns only when active_dangling_reference_count is non-zero. Terminal-holder debt is reported under legacy_terminal_dangling_reference_count and receives no mutation hint, so an agent is never told to rewrite a closed item merely to silence validation. The backward-compatible dangling_reference_count remains the total of active and terminal-holder rows; use the explicit active field for gating. no_active_blocker_sentinel_count separately identifies the legacy scalar sentinel rather than presenting it as a mistyped pm id.

Agent output budgets and discovery

Package-defined packets can use fitOutputEnvelope from the query entrypoint or CommandHandlerContext.sdk.fitOutputEnvelope to fit complete JSON/TOON renderings, including self-accounting receipts and framing. See complete envelope fitting for policies, work ceilings and refusal contracts, tracked by pm-gh1567. Session receipts now advertise pm get <item-id> --depth brief; the restore_reference_with literal type changes accordingly. Hosts constructing ExtensionCommandSdk implementations must supply fitOutputEnvelope from the public SDK, plus readSettingsSection and writeSettingsSection bound to the invocation tracker; host-created command contexts already provide all three.

Tracked by pm-5t33or, pm-gmdzaa, and pm-dpqa3h.

PM_COMMAND_OUTPUT_BUDGET_CONTRACTS declares generated TOON and JSON estimated-token ceilings for every built-in command. Each row also carries its workload class, the stable ceil(UTF-8 bytes / 4) estimate, whether an explicit unbounded request is allowed, and the shared deterministic degradation ladder: full → compact → brief → summary → counts. These contracts are policy data, not renderer-specific code, so package authors can reuse createPmCommandOutputBudget, definePmCommandOutputBudget, resolvePmCommandOutputBudget, and estimatePmOutputTokens in custom transports.

Read-output, context-intent, recurrence and diagnostic string compaction retain Unicode-safe prefixes. A cutoff through a surrogate pair backs up one UTF-16 code unit before adding its suffix. Rendering and token receipts use the actual retained text; unbounded reads and preserved-string exceptions are unchanged. Tracked by pm-gh1478.

import {
  definePmCommandOutputBudget,
  parseMutationReceipt,
  estimatePmOutputTokens,
  resolvePmCommandOutputBudget,
} from "@unbrained/pm-cli/sdk/contracts";

const compactRead = definePmCommandOutputBudget({
  command: "get",
  budget_class: "read",
  default_max_estimated_tokens: 800,
  default_max_estimated_tokens_by_format: { toon: 800, json: 1200 },
  degradation_ladder: ["compact", "summary"],
  allows_unbounded_opt_out: false,
  token_estimate: "ceil(utf8_bytes / 4)",
} as const);

const builtIn = resolvePmCommandOutputBudget("contracts --summary");
const measured = estimatePmOutputTokens(
  new TextEncoder().encode(JSON.stringify(result)).byteLength,
);

const receipt = parseMutationReceipt(mutationStdout);
// receipt.id and receipt.changedFieldCount are typed; wrapped read envelopes
// throw instead of silently producing an undefined id.

PM_COMMAND_OUTPUT_ENVELOPE_CONTRACTS and resolvePmCommandOutputEnvelope declare whether the default structured result is a flat mutation receipt, wrapped entity, collection, diagnostic, or stream. The contracts command keeps summary bootstrap output compact and publishes complete budget/envelope pairs under command_output_contracts only in the explicit full projection. Active package commands receive deterministic fallback contracts, so extension authors can discover a bounded surface before they invoke it.

runActivity defaults to a 15-item digest over the last 24 hours. raw: true selects compact events (20 by default); raw: true, compact: false selects full history entries (five by default). CLI equivalents are --raw and --full. Results disclose total_count, count, page_start, omitted_count, has_more, next_cursor, and applied_bound. Continue with the same options and after: result.next_cursor (CLI/MCP: --after / after). Page size may change. omitted_count counts rows remaining after the current page.

Activity's window publishes the original evaluation anchor, inclusive from, and exclusive to. Bounded reads without an explicit upper bound pin to to that anchor. Relative bounds resolve against the same clock on every page. Later writes outside the window do not disturb continuation. Query, projection, workspace, or matching-content changes refuse the old cursor with invalid_query_cursor; start the query again without after. This is checked continuation over an unchanged matching set, not a persisted snapshot or an atomic cross-file read. Backdated writes within the window and current metadata changes affecting digest rows can require a restart. unbounded: true intentionally removes the row bound and cannot be combined with a limit.

Queries collect the matching rows and scan them on each page to detect changes. Content fingerprints hash canonical rows incrementally, bounding additional serialization storage to the largest row. Digest activity also hashes its full source events incrementally so hidden patch changes still invalidate a cursor.

runCalendar uses the same after contract and frozen clock. total_count counts matching expanded rows within the selected window and recurrence caps; count, page_start, omitted_count, has_more, and next_cursor describe the page. summary_scope: "returned_rows" labels summary and days as page-local. Exact-full and empty results have has_more: false. A zero limit returns counts without rows or a new cursor; repeat with limit: "1" or larger to fetch rows. Existing recurrence expansion limits remain separate from pagination, and calendar mutations affecting matching rows require a restart. Repository tracking (PM records are excluded from the npm package): pm-gh1554 and pm-gh1562.

The required quality:token-budget gate keeps discovery and answer cost separate. Help and contract discovery surfaces use ratcheted baselines; representative medium-workspace answers use the live default_max_estimated_tokens returned by pm contracts --command ... --summary. A full, unbounded activity read is the negative control: the gate fails unless that deliberately unsafe request exceeds the default contract.

Named read intents additionally return context_intent. The receipt separates the declaration (declared_token_budget) from the effective ceiling (token_budget) and reports their signed token_budget_override. Row-oriented reads clamp an explicit limit to the budget-derived ceiling and identify the binding constraint. declaration_feasible evaluates the useful projection against the declared ceiling independently of whether a lower or higher caller override is effective. within_budget evaluates that same projection against the effective token_budget, so it can legitimately differ from declaration feasibility. If even compacted useful output cannot fit, result_omitted is true, estimated_tokens retains the measured pre-omission size (and is therefore greater than token_budget), and budget_exceeded.restore_with is an executable bounded retry rather than a generic suggestion.

Unfiltered pm contracts now selects the summary projection by default. It returns canonical commands, terse intents, the most useful flags for the core agent loop, per-command ceilings, and the shared output policy. Use pm contracts --command <name> --flags-only for the complete flag surface, --schema-only for tool schemas, or the explicit --full escape for the historical complete payload. The SDK getContracts({ full: true }) uses the same opt-in.

The committed scripts/agent-token-surface-baseline.json covers root help, every advertised command help payload, all contracts projections, and MCP tools/list. pnpm quality:token-surface fails on a missing or exceeded surface; pnpm quality:token-surface:update intentionally refreshes the versioned baseline after a reviewed output change. The context-quality CI and release gates run the check, so a new command cannot silently expand the agent context surface.

Testing helper exports (also under @unbrained/pm-cli/sdk/testing):

  • createExtensionTestHarness
  • activateExtensionForTest
  • deactivateExtensionForTest
  • runRegisteredCommandForTest
  • runRegisteredHookForTest
  • runRegisteredParserOverrideForTest
  • runRegisteredPreflightOverrideForTest
  • runRegisteredCommandOverrideForTest
  • runRegisteredRendererOverrideForTest
  • runRegisteredServiceOverrideForTest
  • runRegisteredSearchProviderForTest
  • runRegisteredVectorStoreAdapterForTest
  • runRegisteredMigrationForTest
  • runRegisteredImporterForTest
  • runRegisteredExporterForTest
  • assertExtensionDeactivated
  • assertPackageManifest
  • assertRegisteredCommandContract
  • assertRegisteredFlags
  • assertRegisteredCommandOverride
  • assertRegisteredParserOverride
  • assertRegisteredPreflightOverride
  • assertRegisteredRendererOverride
  • assertRegisteredHook
  • assertRegisteredSearchProvider
  • assertRegisteredImporter
  • assertRegisteredExporter
  • assertRegisteredVectorStoreAdapter
  • assertRegisteredItemField
  • assertRegisteredItemType
  • assertRegisteredProfile
  • assertRegisteredServiceOverride
  • assertRegisteredMigration
  • assertExtensionCapabilityUsage
  • assertExtensionBlueprint (throwing preflight; pairs with lintExtensionBlueprint)
  • assertExtensionManifestMatchesBlueprint (strict manifest↔blueprint capability guard)
  • assertExtensionManifestCompatible (throwing version-bound guard; pairs with checkExtensionManifestCompatibility)
  • assertExtensionPreflight (one-line throwing capstone over preflightExtension; replaces chaining the three asserts above)
  • describeExtensionActivation
  • describeExtensionBlueprint / lintExtensionBlueprint (also surfaced here for the full author → describe → preflight → test loop)
  • renderExtensionSurfaceMarkdown (render the describe summary to a drift-free Markdown reference; powers describe --markdown)

createExtensionTestHarness(module, options) is the recommended entry point and the ergonomic capstone over every standalone helper below: it activates the module once and returns a fluent ExtensionTestHarness whose assert*/run* methods are pre-bound to the correct activation sub-registry, so a package author never threads activation.registrations vs activation.commands vs activation.hooks (etc.) by hand — picking the wrong one is a common footgun that surfaces as a confusing available: (none) error. Write const ext = await createExtensionTestHarness(module, { capabilities: ["commands"] }), then ext.assertCommandContract({ name: command }), await ext.runCommand({ command }), ext.activationSummary(), ext.renderMarkdown({ title: "My package" }), and await ext.deactivate(). activationSummary() returns the same ExtensionActivationSummary as describeExtensionActivation(ext.activation); renderMarkdown() feeds that summary through renderExtensionSurfaceMarkdown, with an optional extensionName filter for scoped package docs. The methods do not use this, so they remain safe to destructure (const { runCommand, renderMarkdown } = ext;), and the raw ext.activation stays public as an escape hatch to the standalone helpers for any surface a convenience method does not cover.

assertExtensionCapabilityUsage(activation, { declared }) is the least-privilege counterpart of the per-surface assertRegistered* helpers: pass the same capabilities as your manifest.capabilities and it fails the test when the manifest grants a capability the extension never registers against. Use allowUnused for capabilities a runtime registers only behind a config flag.

deactivateExtensionForTest(module, options) is the teardown counterpart to activateExtensionForTest: it runs pm's real deactivateExtensions engine (including the bounded per-hook timeout and best-effort failure capture) over the module and returns the ExtensionDeactivationResult, so a package can prove its deactivate releases the resources activate opened. assertExtensionDeactivated(result) asserts the single-extension happy path (one extension deactivated, none failed) by default; pass { deactivated, failed } to assert other counts. Forward the activation result and deactivateTimeoutMs to mirror real host teardown.

runRegisteredCommandForTest(activation.commands, { command, args, options, global, pmRoot }) is the "invoke" verb that completes the package-author testing loop — activateExtensionForTest → assertRegisteredCommandContract → run → deactivateExtensionForTest. It dispatches a registered command handler through pm's real engine and returns the CommandHandlerResult, so a test can assert behavior (result.result) rather than only that the command is wired. The CommandHandlerContext is built with agent-safe global defaults ({ json: true, quiet: true, noPager: true }) that callers may override. When commands comes from activateExtensionForTest, options pass through the same normalizer as CLI dispatch and PmClient.run: "status-filter" reaches the handler as statusFilter, declared defaults and coercion apply, and two spellings with different values are refused (see Handler keys and ownership). A hand-built registry has no declared flags, so its options pass through verbatim. A clean run yields { handled: true, result, warnings: [] }; a handler that throws a non-exit error yields { handled: false, warnings: [code], errorMessage } so the failure can be asserted. Returned objects may declare exit_code from 1 through 255 plus optional string code and remediation fields; the runner preserves the structured result and exposes those values as exitCode/errorCode/remediation. A thrown plain object preserves its code/remediation, while an error carrying a numeric exitCode propagates the throw. An unregistered command throws a descriptive error listing the available handler command paths. Because registerImporter/registerExporter register handlers under "<name> import" / "<name> export", the same helper exercises importer and exporter handlers too.

Production command, importer, and exporter contexts also expose sdk: a host-bound native-action PmClient, point-in-time getItemAt, and durable relationship-store factory. The host client deliberately reuses the current extension activation instead of recursively loading packages, preserving registered schema while avoiding activation-queue re-entry.

The remaining runtime surfaces an extension can register have matching invoke helpers, so the "invoke" verb covers the whole command pipeline — not just command handlers:

  • runRegisteredHookForTest(activation.hooks, { kind, context }) fires every registered lifecycle hook of a kind (before_command | after_command | on_read | on_write | on_index) through pm's real hook runner and returns the warnings array ([] = clean; a thrown hook contributes one extension_hook_failed:* warning while the others still run). The context is type-safe per kind.
  • runRegisteredParserOverrideForTest(activation.parsers, context) returns the rewritten ParserOverrideResult (args/options/global the override produces before dispatch).
  • runRegisteredPreflightOverrideForTest(activation.preflight, context) returns the PreflightOverrideResult (the migration/format gate decision).
  • runRegisteredCommandOverrideForTest(activation.commands, context) returns the CommandOverrideResult (the transformed command result payload).
  • runRegisteredRendererOverrideForTest(activation.renderers, context) returns the RendererOverrideResult (the custom string rendered for an output format); the same command/result ownership metadata enforced by the host determines whether the override is eligible before its callback runs.
  • runRegisteredServiceOverrideForTest(activation.services, context) returns the ServiceOverrideResult (how the override handles an internal service payload).

Each override helper guards that a matching override is registered for the target (command / format / service), so a typo surfaces as a descriptive error rather than a silent overridden: false / handled: false.

The executable registration surfaces — search providers, vector store adapters, schema migrations, importers, and exporters — also have invoke helpers, so every executable register* method has both an assertRegistered* and a runRegistered*ForTest counterpart. Each exercises the real registered behavior, not a re-implementation, but along two execution paths that mirror how the host runs them. Providers, adapters, and migrations are resolved through the same runtime resolver the host uses and invoked via their runtime_definition (the clone that preserves live functions). Importers and exporters have no standalone runtime_definition — registerImporter/registerExporter wrap their handler into a command path, so their helpers resolve by name and dispatch through the command runner instead, returning a CommandHandlerResult:

  • runRegisteredSearchProviderForTest(activation.registrations, { provider, operation, context }) resolves a registered provider by name (case-insensitive, last registration wins) and invokes one operation — query, embed, embedBatch, queryExpansion, or rerank — returning that operation's result. The context and return type are inferred from operation, and the camelCase / snake_case spellings the host accepts (embedBatch/embed_batch, queryExpansion/query_expansion) both resolve.
  • runRegisteredVectorStoreAdapterForTest(activation.registrations, { adapter, operation, context }) resolves a registered adapter by name and invokes query (returns VectorStoreQueryHit[]), upsert, or delete.
  • runRegisteredMigrationForTest(activation.registrations, { migration, extensionName?, pmRoot? }) resolves a registered migration by id and invokes its run with a context mirroring the host's (command: "migration", the registering extension's layer/name, the supplied pmRoot, and the migration's normalized status), returning whatever run returns. Unlike the host — which skips applied migrations and folds a throw into a warning — it always invokes run and lets a throw propagate, so both success and failure are assertable.
  • runRegisteredImporterForTest(activation, { importer, extensionName?, args?, options?, global?, pmRoot? }) and runRegisteredExporterForTest(activation, { exporter, ... }) resolve a registered importer/exporter by name, derive the "<name> import" / "<name> export" command path internally — so authors never hand-build it — and validate that the name is genuinely a registered importer/exporter before dispatching. They take the whole activation because resolution spans two sub-registries (registrations proves it exists, commands holds the wrapped handler), and they return the command runner's CommandHandlerResult verbatim, so handled/warnings/errorMessage semantics and exitCode propagation match invoking the importer/exporter as a command.

Each surface helper guards that the named provider / adapter / migration / importer / exporter is registered (and, for providers and adapters, implements the requested operation), so a typo surfaces as a descriptive error rather than a silent no-op. All invoke helpers are async, so a test always awaits them.

describeExtensionActivation(activation, { extensionName }) is the describe (enumerate-all) verb that complements the assertRegistered* (verify-one) and runRegistered*ForTest (invoke-one) helpers. The activation result already carries per-surface counts; this returns the names. It walks every sub-registry once and returns a flat ExtensionActivationSummary whose arrays are de-duplicated and locale-sorted (except hooks, emitted in canonical lifecycle order to mirror hook_counts) of every registered surface's identifiers — command paths, hook kinds, item-type / field names, migration ids, importer / exporter / provider / adapter names, overridden service names and renderer formats, flag target-commands, and the preflight-override count — plus scoped preflight_ownership command rows and renderer_ownership rows (format, normalized commands, and whether a result discriminator exists) and the capabilities those surfaces exercise. Two uses:

import { describeExtensionActivation } from "@unbrained/pm-cli/sdk/testing";

const summary = describeExtensionActivation(activation);
// Least-privilege check: assert the WHOLE registration surface in one deepEqual.
assert.deepEqual(summary.commands, ["greet hello"]);
assert.deepEqual(summary.hooks, ["after_command"]);
assert.deepEqual(summary.capabilities, ["commands", "hooks"]);

Without extensionName the summary unions every extension in the activation; with it (matched case-insensitively after trimming, like collectUsedExtensionCapabilities) only that extension's registrations contribute. The three command fields capture distinct dimensions and can overlap: commands lists definitions declared via registerCommand(definition), command_handlers lists every command path backed by an extension handler (a superset that also includes the synthesized "<name> import" / "<name> export" importer/exporter paths), and command_overrides lists built-in commands replaced via registerCommand(name, override). For agents, one call returns the entire surface instead of traversing fifteen-plus sub-registries — keeping the context window lean ("project management = context management").

The same verb is reachable from the CLI and MCP without writing a test: pm extension describe [name] / pm package describe [name] (and pm_run with action: "extension"/"package" and describe: true) activate the workspace's extensions and return each loaded extension's ExtensionActivationSummary under details.extensions[].surfaces, plus a deduplicated details.union. Omit the name to map every loaded package; pass one to scope to it. This is the agent-facing answer to "what does this installed package add to my context?" — distinct from pm package doctor (errors/policy) and pm package manage (update metadata), which report only command/action paths, not the full registration surface.

renderExtensionSurfaceMarkdown(summary, options?) is the render leg of the describe verb: it projects any ExtensionActivationSummary to a deterministic Markdown reference document — a title heading, a one-line capabilities summary, and a section per registered surface. Pipe describeExtensionBlueprint(blueprint) straight into it during a build or test step and embed the result in your README, and the "commands & capabilities" reference can never drift from the surface the loader actually registers ("project management = context management"). options.title / options.headingLevel (an integer in [1, 6], default 2; section headings render one level deeper) control nesting, and options.includeEmpty renders every section (as _None._) rather than omitting empty ones.

import {
  describeExtensionBlueprint,
  renderExtensionSurfaceMarkdown,
} from "@unbrained/pm-cli/sdk";

const reference = renderExtensionSurfaceMarkdown(
  describeExtensionBlueprint(blueprint),
  { title: "my-pkg", headingLevel: 2 },
);
// → "## my-pkg\n\nCapabilities: `commands`, `schema`\n\n### Commands\n\n- `greet hello`\n…"

The same renderer powers pm extension describe --markdown / pm package describe --markdown, which compose a per-extension section plus a union section across every loaded extension. Add --output docs/package-reference.md to write the generated Markdown directly to a file for README/reference-doc refreshes. --markdown is a presentation format (it cannot be combined with --json); MCP describe keeps returning the structured summary, which a caller can hand to renderExtensionSurfaceMarkdown itself.

Commander option contract exports:

  • CREATE_COMMANDER_OPTION_REGISTRATION_CONTRACTS
  • UPDATE_COMMANDER_OPTION_REGISTRATION_CONTRACTS
  • CREATE_COMMANDER_STRING_OPTION_CONTRACTS
  • CREATE_COMMANDER_REPEATABLE_OPTION_CONTRACTS
  • UPDATE_COMMANDER_STRING_OPTION_CONTRACTS
  • UPDATE_COMMANDER_REPEATABLE_OPTION_CONTRACTS
  • LIST_COMMANDER_STRING_OPTION_CONTRACTS
  • SEARCH_COMMANDER_STRING_OPTION_CONTRACTS
  • CALENDAR_COMMANDER_STRING_OPTION_CONTRACTS
  • CONTEXT_COMMANDER_STRING_OPTION_CONTRACTS
  • ACTIVITY_COMMANDER_STRING_OPTION_CONTRACTS
  • readFirstStringFromCommanderOptions
  • readStringArrayFromCommanderOptions

Extension runtime contract exports:

  • PM_EXTENSION_CAPABILITY_CONTRACTS
  • PM_EXTENSION_SERVICE_NAME_CONTRACTS
  • PM_EXTENSION_POLICY_MODE_CONTRACTS
  • PM_EXTENSION_POLICY_SURFACE_CONTRACTS
  • PM_EXTENSION_TRUST_MODE_CONTRACTS
  • PM_EXTENSION_SANDBOX_PROFILE_CONTRACTS

Least-privilege capability reconciliation exports (map declared capabilities to the registration surfaces a package actually exercises at activation):

  • EXTENSION_CAPABILITY_REGISTRATION_SURFACES
  • collectUsedExtensionCapabilities
  • reconcileExtensionCapabilityUsage

Common types:

  • ExtensionApi
  • ExtensionActivationSummary
  • ExtensionManifest
  • ExtensionManifestEngines
  • CommandDefinition
  • FlagDefinition
  • ImportExportRegistrationOptions
  • ServiceOverrideContext
  • ServiceOverrideDecision
  • PmCliExpectedError
  • CreatePmCliExpectedErrorOptions
  • SchemaFieldDefinition
  • SchemaItemTypeDefinition
  • SearchProviderDefinition
  • VectorStoreAdapterDefinition
  • GlobalOptions
  • ItemDocument
  • PmSettings

Static And Runtime Contracts

PM_TOOL_ACTIONS and PM_TOOL_PARAMETERS_SCHEMA describe the always-on static core action surface. They include core project-management primitives, package lifecycle actions, and upgrade. analyzeSdkActionCoverage() derives every native or alias dispatch route from the live runtime registries; the release test matrix combines it with PM_TOOL_ACTION_PARAMETER_CONTRACTS so a new action or parameter cannot silently lack an executable SDK route or schema property.

Package-owned actions such as beads-import, todos-export, calendar, and templates-save are intentionally not advertised as static core actions. Discover installed package actions with runtime contracts:

pm contracts --runtime-only --json
pm contracts --action calendar --runtime-only --schema-only --json
pm contracts --command templates --runtime-only --flags-only --json

The SDK owns template persistence independently of the presentation package. runTemplatesSave(name, options: TemplatesSaveOptions, global) accepts priority as an integer 0..4, a numeric string, or a named priority such as critical. It validates priority before writing and stores its normalized numeric string; 0 therefore survives save, show, and item creation. Invalid priority values refuse with exit code 2 and the accepted forms, preserving the template and workspace history. Other template keys retain the existing string/string-array storage contract, including extension-defined fields. Import the input type and function from @unbrained/pm-cli/sdk/runtime or the aggregate SDK. Tracked by pm-gh1561 (repository-only tracker record, excluded from npm).

Use static SDK contracts for baseline validation, then use runtime contracts in the target project before invoking package-provided commands or actions. Embedded SDK consumers can avoid subprocesses:

import { getContracts } from "@unbrained/pm-cli/sdk";

const contracts = await getContracts("/path/to/project/.agents/pm", {
  runtimeOnly: true,
  flagsOnly: true,
});

To execute pm from an embedded tool without spawning pm, use PmClient (or the lower-level runAction) from @unbrained/pm-cli/sdk/runtime. It uses the same compact, extension-aware dispatcher as the MCP pm_run tool:

import { PmClient } from "@unbrained/pm-cli/sdk/runtime";

const pm = new PmClient({ pmRoot: "/workspace/.agents/pm" });
const corpus = await pm.listAllComplete({ includeBody: true });
const context = await pm.context({ limit: "10" });
await pm.run("test", { id: "pm-example", options: { run: true } });

Mutation convenience methods default to compact changed-field output for agent efficiency. Pass fullChangedFields: true alongside the command options when an embedded SDK consumer needs the full changed_fields array.

Read convenience methods and the matching top-level read functions (get, list, search, context, next, aggregate, stats) are foundational SDK PM primitives: they return the same structured data the CLI/MCP surfaces use, but with stable TypeScript contracts exported from @unbrained/pm-cli/sdk and @unbrained/pm-cli/sdk/runtime. Use them instead of spawning pm get, pm list, pm search, pm context, pm next, pm aggregate, or pm stats when building a custom project-management tool, CI integration, or agent runtime. Presentation stays outside the SDK primitive: callers choose their own rendering while the data shape remains shared with the CLI.

The next result excludes the recommended item from the ready tail, assigns stable one-based ranks, reports foreign-owned work in held_by_others, and treats dangling dependency ids as unresolved blockers. Human-gated decisions remain visible in decision_needed but stay outside the agent-ready queue unless the caller explicitly opts in. PmClient.claimNext() and top-level claimNext() compose the same ranking result and atomic claim mutation, accept the next-work filters, and bound the race-loss candidate walk with maxAttempts (1 through 100 inclusive), so custom SDK tools do not need to reproduce CLI concurrency policy.

Lifecycle convenience methods and the matching top-level functions (create, update, reopen, close, claim, release, copy, deleteItem, restore, focus, startTask, pauseTask, and closeTask) use the same mutation paths as the CLI and MCP dispatcher. They are the baseline primitives for custom PM tools that need to own item state without spawning pm. reopen requires a recurrence reason, accepts only the workspace open or in-progress target, and returns the terminal evidence captured atomically before the update path clears it from active metadata.

Literal mutation data and runtime value admission are tracked by pm-gh1614 and pm-gh1606 (repository-only tracker records excluded from npm). SDK create, update, and append inputs are data: "-" remains text, "none" and "null" remain text where the field's domain permits them, and "@file" remains text. These calls do not implicitly consume stdin or read a file. This also applies to the corresponding runCreate, runUpdate, runAppend operations and update-many mutation values. Normal field validation, whitespace handling, structured collection syntax, and project schemas still apply. Clear a scalar field with unset: ["assignee"], or pass JavaScript null for any option the command's unset list covers: { parent: null } is unset: ["parent"], and tags: null clears the tags (tags takes a CSV string, not an array, and has no clearTags). The nullable keys are exactly the unset table's option keys (CreateNullClearableOptionKey, UpdateNullClearableOptionKey). rank is only an unset alias of order, so rank: null is refused (pass order: null), and create accepts unset: ["author"] but refuses author: null. null on any other option (title, author, collection options such as comment or dep) refuses by name; clear collections with their clear* options. When typed input stores a former clear sentinel ("none", "null" or "-") in a clearable field, the result warns literal_sentinel_stored:<field>:<value>: the value is now data (an assignee called none). The advisory appears only while the document pm committed and recorded in history still holds the value, so an item_store_write override that rewrites stored contents can drop it from storage while the advisory still reports it. Typed SDK and MCP refusals name the option key and the unset remedy both accept, for example parent must not be the placeholder "none". Pass unset: ["parent"] to clear it, or omit parent.; CLI refusals keep their flag wording. PmCreateActionOptions, PmUpdateActionOptions, CreateOperationOptions and UpdateOperationOptions type the clearable fields as nullable, so runCreate/runUpdate accept the same input as PmClient.create/update. A direct SDK runUpdateMany folds a null in its update record like runUpdate, but runAction("update-many") drops it (pm-w7clh6), so PmUpdateManyActionOptions stays non-nullable: use unset there and in MCP tools. CLI adapters retain --description -, append stdin, file flags, and legacy clear-token compatibility. Values inside CLI --stdin-json documents are literal data, including "none" and "null". Explicit CLI flags override document values and retain CLI interpretation, including legacy clear tokens. Use explicit clear/unset flags when the intent is to remove a field.

Native create/update/reopen values are checked against the SDK-owned option contracts before normalization and writes. A malformed append body or option value throws PmCliError with code: "invalid_argument_value", usage exit code 2, the affected context.field, corrective guidance, and context.changed: false. For example, estimatedMinutes requires "45", while priority accepts a string or finite number. JavaScript and JSON bridge callers must correct invalid types instead of relying on coercion. Integrations that previously used "-" to request stdin should read that input themselves and pass the resulting text; integrations that used "none" to clear must migrate to unset or null. Direct runCreate/runUpdate calls require the declared string arrays for repeatable fields. Generic action dispatch retains its documented scalar-to-array normalization before invoking those operations.

Adapter authors can import withCliMutationInput and withLiteralMutationInput from the SDK root or runtime entrypoint. Both return cloned option bags and collection values with an in-process input policy. Use the first only for explicit CLI parsing, and the second for decoded JSON or other literal data inside that adapter. Existing literal data and already-resolved stdin fields retain their policy through the clone; no marker is serialized into an item. Reusing a collection in a fresh CLI option bag does not inherit literal policy from another call or modify the caller's collection.

Create focus policy is tracked by pm-gh1577 (this tracker record is repository-only and excluded from npm). Programmatic create, PmClient.create, runCreate, and MCP pm_create ignore saved session focus unless inheritFocus: true is supplied. An explicit parent, a parent from the selected template, or unset: ["parent"] takes precedence. Create receipts report parent_source: "focus"; the effective parent remains in the item and immutable create event. Plan results expose the effective parent as plan.parent. pm create and pm plan create enable this convenience by default; --no-inherit-focus or --parent none suppresses it (pm create also accepts --unset parent). SDK planCreate and MCP plan creation follow the same explicit opt-in policy. SDK scheduling shortcuts follow the same opt-in policy; package scheduling commands retain their focused creation convenience.

Other create defaults still apply: an explicitly selected template supplies options before explicit caller values, workspace governance selects the default type/create mode, the type workflow selects its initial status, priority defaults to 2, tags to an empty array, and author identity comes from invocation/workspace policy. There is no implicit saved assignee or tag default outside the selected template. Importers should supply explicit type, status, priority, tags, assignee, and parent/unset policy when those values are part of their source contract.

Lifecycle handoff and receipt guidance are tracked by pm-gh1465 and pm-gh1464. Register a project-specific queue with client.schemaAddStatus("review", { role: ["active"] }), then call client.release(id, { handoff: "review", message: "Implemented; run linked verification" }). The SDK atomically changes status and clears ownership in one release history event, applying project ownership, type-transition, and workflow policies. ReleaseMutationOptions declares the handoff option; ReleaseResult.handoff identifies the source and destination states and evidence. client.list({ status: "review" }) selects the verifier queue; context preserves its status without counting it as in-progress work. The destination must already exist, be non-terminal, and differ from the configured in-progress status. Handoff needs a claimed source and evidence message, and cannot be combined with pause.

A successful warning-mode close with missing evidence returns optional follow_up: CloseEvidenceFollowUp. Its kind is complete_close_evidence and suggested_command_args selects an evidence-only update of the same item and tracker. Consumers migrating from successful-close recovery should read follow_up instead; a successful close never asks for a retry. Failed close commands retain failure recovery. See close evidence guidance.

Atomic workspace transactions

Tracked by pm-4e12, with the VCS acceptance story pm-8ngt and the structured SDK adapters tracked by pm-xm7c and pm-kipd, versioned batch-local references tracked by pm-o8z748, and atomic evidence-backed completion tracked by pm-cyn0y6.

commitWorkspaceTransaction is the public unit-of-work primitive for domain commands that must coordinate several SDK mutations. A plan supplies a stable transaction id plus ordered steps. Each step can inspect its durable state, apply an idempotent forward mutation, and append an idempotent compensation. The coordinator serializes transaction coordinators with one workspace lock and persists .agents/pm/transactions/sdk/<transaction-id>.json after every step. That lock does not exclude ordinary writers (GH-1620): update, close, pm comments --add and every other SDK, CLI or MCP mutation take per-item locks, so a peer can commit between a step's inspect() and apply(). A step that decides from what it read guards its write with an expected item revision: in inspect(), take both the decision data and item_hash from one get(), then run the write inside withExpectedItemRevisions(). The write then refuses with item_revision_conflict instead of overwriting the peer's change, and the transaction compensates. The coordinator invokes compensate(), including during recovery, without an expected revision. Built-in item compensations detect intervening writes. A custom adapter must fence or reconcile its own compensation, or it can restore a stale value over a peer's write. Before invoking a pending step, it also persists that step's durable ownership marker. Recovery and compensation therefore distinguish mutations begun by this transaction from matching domain state that existed before the attempt. Steps that must restore prior values implement prepareCompensation(); its JSON-safe result is stored atomically with that ownership marker before apply() begins and is passed back to compensate(data) after a restart. lockTtlSeconds (default 30) and lockWaitMs (default 3000) let callers size the writer lease above their longest expected attempt and choose their contention budget; both must be positive integers.

If a process stops between the domain write and the journal update, rerunning the same plan discovers the already-applied step and continues. An ordinary error switches the journal to compensation mode and runs applied steps in reverse order, limited to steps whose forward attempt was durably recorded. Adapters can opt into compensatePending: true when apply() may fail after an internal write while inspection still reports pending. Such compensators must handle absent and partial effects idempotently. The default preserves applied-only compensation; an inspected compensated step is skipped. If compensation refuses, the journal retains its started steps and recovery data in compensating state instead of claiming a rollback. Compensations are new item-history or relationship events: the coordinator never deletes or rewrites immutable history. A crash during compensation resumes compensation before a new attempt begins.

Guarded terminal updates are tracked by pm-pllp78. A bulk update's match guard is checked under each item lock and advances to the operation's own committed key value before its next internal lifecycle write. A stale starting value still refuses without item or history writes. Item updates opt into pending compensation and attribute every internal write in immutable history. New receipts bind the history fingerprint, integer prefix length and exact version, so writes sharing one timestamp remain distinguishable. Recovery restores exclusively owned effects and preserves peer-only changes. Mixed ownership, rewritten receipts, or a peer write before the restore lock refuse with transaction_partial_mutation; the surfaced AggregateError retains both failures and the journal keeps recovery data. Repeated compensation recognizes its durable marker even when a peer adds context afterward. Legacy timestamp receipts remain supported, with ambiguous partial effects refused.

Expected item revisions

Expected-revision preconditions are tracked by pm-gh1576.

Read-decide-write agents and packages guard a write with the revision their decision was based on. Every current pm get / PmClient.get read reports item_hash, the SHA-256 of the canonical item document and the same value as the item's latest history after_hash (a field-projected read includes it only when the selection names the bare item_hash, for example --fields title,tags,item_hash; the item-qualified item.item_hash still projects a custom metadata field of that name). readItemRevision(id) returns it alone. Take the hash from the same read your decision uses: a separate read could observe a newer revision than the data you decided on. Run the write inside withExpectedItemRevisions:

import { PmClient, isItemRevisionConflict } from "@unbrained/pm-cli/sdk";

const pm = new PmClient({ pmRoot });
// Decide from the same read that reports the revision, never from a separate one.
const read = await pm.get(id);
const reason = `Delivered by ${read.item.tags?.find((tag) => tag.startsWith("delivery-pr:"))}`;
try {
  const { result, unchecked } = await pm.withExpectedItemRevisions(
    [{ id, itemHash: read.item_hash! }],
    () => pm.close(id, reason),
  );
} catch (error) {
  if (!isItemRevisionConflict(error)) throw error;
  // error.context.current_revision and error.context.details.changes_since
  // describe what changed; re-read, decide again, retry with the new hash.
}

The check runs inside the locked item store, after the item lock is held and before that write. It therefore covers every item write the wrapped operation makes: lifecycle commands, annotations, linked files/docs/tests, dependency edits, restore, delete, history maintenance (history-redact, history-repair, history-compact, checked under the stream lock), bulk transactions and extension commands that mutate through the SDK. A delete advances the expectation to the item's absent (tombstone) revision, and a restore of a deleted item is judged under its own id, so a peer deletion between the read and the restore refuses instead of being resurrected over. A mismatch refuses that write with item_revision_conflict (exit 4); writes the operation already committed stay durable. The refusal carries item_id, expected_revision, current_revision and details with changes_since, the newest 10 history entries after the declared revision (op, author, ts, top-level fields), plus changes_since_omitted. expected_revision_recorded: false means no history entry ever had that hash. Its required, examples and nextSteps re-read the item with get and history, naming the tracker the caller selected (pm-gh1677; this tracker record is repository-only and excluded from npm). An explicit pmRoot or cwd on withExpectedItemRevisions(), and every PmClient.withExpectedItemRevisions() call, add --pm-path <tracker>, so the commands read the same tracker wherever the caller's process runs. After one of the operation's own writes commits, the expectation advances to that revision, so a multi-write composition such as claim --start does not refuse itself; an interleaved peer write still refuses the next step. The result lists checked ids and unchecked ids that the operation never wrote: an unchecked expectation proved nothing. Expectations bind to the item's embedded id, so every spelling that locates the item (a short id, or a case variant on a case-insensitive filesystem) is guarded. The scope is shared process-wide across the published SDK entrypoints (/sdk, /sdk/core, ...), and isItemRevisionConflict checks the refusal structurally, so mixing entrypoints neither drops a guard nor hides its conflict. Trackers are compared by physical path (realpath), so a symlinked spelling of the same tracker is guarded too. PmClient.readItemRevision and PmClient.withExpectedItemRevisions resolve targets inside the client's extension context, so extension item types bind exactly as get reads them, then run the operation outside it, so its own actions (extension commands included) activate and dispatch normally. The standalone readItemRevision / withExpectedItemRevisions functions use the registrations already active in the calling context. A deleted item with retained history binds too: its revision is the empty tombstone its delete recorded (the delete entry's after_hash), so a guarded restore or history maintenance of a deleted item can declare it. Bulk update-many and close-many keep their partial-success semantics: a stale row fails with error_code: "item_revision_conflict" plus its expected_revision, current_revision and the same details (changes_since, changes_since_omitted) a single-item refusal carries, while fresh rows proceed. The exported ItemRevisionConflictRowFields type declares those row fields; HistoryCompactBulkItemResult and HistoryRepairAllStreamResult extend it. When an item_store_write or item_store_delete override transforms, redirects or skips a guarded write, the scope advances to the revision read back from storage under the item lock: its own next write is admitted, a competing one refuses. If the item cannot be read back, or its embedded id was rewritten, the next guarded write refuses (fail closed). When a peer writes while restore or history maintenance waits for its lock, a declared revision turns the generic drift refusal into this typed conflict. Linked-test result recording (test --run, test-all with test_result_tracking enabled) stays best-effort, except that a stale declared revision refuses with item_revision_conflict instead of a warning. A guard cannot reach a --background test worker, which runs in another process, so test/test-all --background with an expected revision refuses with expect_hash_background_unsupported. Only the CLI invocation scope reads GlobalOptions.expectHash; direct run* SDK calls ignore it, so guard SDK writes with the functions above or runAction/MCP expectHash.

Transports use the same grammar. The CLI's global --expect-hash <item_hash> binds to the command's <id>; --expect-hash <id>=<item_hash> (repeatable or comma-separated) names targets explicitly and is required for bulk commands. MCP tools and PmClient.run accept expectHash (string or string array) on every item-writing action, at the top level or inside options; either placement is enforced. A native read action refuses the flag with expect_hash_requires_mutation, and a malformed, supplied-but-empty or null value (for example an unset shell variable) refuses with expect_hash_invalid rather than silently dropping the guard. An expectation no write reached is reported as the warning expect_hash_unchecked:<id>:no_locked_write_reached_this_item. This precondition guards the gap between an agent's read and its write; lease fencing (pm-gh1549) still guards the write itself against lock takeover, and the two compose.

Schema evolution and workspace history

Tracked by pm-dijg and pm-klo8.

Custom schema definitions can evolve without leaving existing items on stale type, field, or status values. Use a stable migrationId: it names the durable plan and transaction journal, so retrying after interruption resumes the same fingerprinted item set instead of planning against partially migrated state.

const preview = await pm.schemaRenameField(
  "severity",
  "impact",
  { migrationId: "severity-to-impact-v2", dryRun: true },
  "Issue",
);

if (preview.affected_item_count > 0) {
  await pm.schemaRenameField(
    "severity",
    "impact",
    { migrationId: "severity-to-impact-v2" },
    "Issue",
  );
}

rename-type, rename-field, and remap-status stage the target definition, mutate only affected items through the canonical item store, and retire the source definition. Field collisions and changed concurrent state fail before data is overwritten. Candidate selection uses the derived metadata index, including its metadata-key projection for field renames, and falls back to an authoritative scan when the index is absent or stale.

Workspace singleton mutations are recorded at .agents/pm/history/_workspace.jsonl. The stream uses the normal HistoryEntry patch and before/after hash contract, is protected by its own writer lock, and caches unchanged verification results during drift scans. The writeWorkspaceJsonWithHistory primitive holds that lock across the singleton snapshot, atomic write, history append, and any compensating restore, so competing SDK writers cannot split persisted state from its audit chain. The stream participates in the history merge driver, appears in pm activity, and can be checked with pm history _workspace --verify. Verification also replays the latest document map and compares every governed JSON singleton with disk; a valid chain is not reported healthy when the live document differs. Package authors that persist their own singleton JSON should call writeWorkspaceJsonWithHistory; use appendWorkspaceHistoryChange only when another primitive already owns the document write. Recovery code uses inspectWorkspaceHistoryState for the read-only classification, reconcileWorkspaceJsonHistory only when a terminal Decision explicitly authorizes adoption of the reviewed disk value, and restoreWorkspaceJsonFromHistory to reject drift and replace the singleton from a verified version. Reconciliation and restoration append new entries and compensate failed writes; neither edits historical rows.

import {
  PmClient,
  commitWorkspaceTransaction,
  type WorkspaceTransactionStep,
} from "@unbrained/pm-cli/sdk";

const pmRoot = "/workspace/.agents/pm";
const pm = new PmClient({ pmRoot, author: "billing-agent" });
const steps: WorkspaceTransactionStep[] = [
  {
    id: "approve-invoice",
    async inspect() {
      const invoice = await pm.get("invoice-42", { depth: "deep" });
      if (invoice.item.status === "approved")
        return { state: "applied", result: { status: "approved" } };
      if (invoice.item.status !== "open")
        throw new TypeError("Invoice must be open before approval");
      return { state: "pending" };
    },
    async apply() {
      await pm.update("invoice-42", { status: "approved" });
      return { status: "approved" };
    },
    async compensate() {
      await pm.update("invoice-42", {
        status: "open",
        message: "Compensate interrupted invoice approval",
      });
    },
  },
];

await commitWorkspaceTransaction({
  pmRoot,
  transactionId: "approve-invoice-42",
  author: "billing-agent",
  steps,
});

Logical atomicity is defined at the journal/replay boundary: callers publish a successful domain result only after the journal reaches committed, while failed attempts converge to compensated domain state. The filesystem adapter is not a database MVCC layer, so raw readers that bypass the coordinator are not snapshot-isolated during the short forward/compensation window. Use stable step ids, deterministic event ids, state-based inspection, and append-only inverse events. A step may return undefined when it has no journal result to retain. The optional transition observer exposes step_compensating immediately before each inverse mutation, which supports telemetry and semantic failure injection without coupling tests to storage call counts. WorkspaceTransactionInterruptedError is reserved for deterministic crash-boundary tests and leaves the journal resumable instead of triggering the normal in-process compensation path.

Extension commands receive the same coordinator as context.sdk.commitWorkspaceTransaction(...), already bound to the invoking tracker root. The bundled pm-vcs merge command is the reference implementation: it commits the changeset lifecycle transition and commits_to relationship together, compensates both streams on failure, and resumes a crash or a previously compensated retry without package-private file access.

For the ubiquitous "commit N item mutations atomically" case (bulk import, bulk sync), commitItemMutations wraps the coordinator so callers describe the mutations instead of hand-writing a step array. The helper wires the crash-consistency contract for you: creates use their resolved stable id as the idempotency key (exists-by-id inspection) and are compensated by closing the item (or deleting it with createCompensation: "delete"). A retry refuses with transaction_create_compensated while a creation's latest closure belongs to compensation, even if its close prose was edited. Review its immutable history and explicitly reopen it before retrying; the SDK preserves later lifecycle decisions instead of silently reopening the item or reporting it committed. A later explicit user close or status update takes precedence over the earlier compensation close and is preserved when the transaction resumes. Updates stamp a durable history marker for applied-detection and are compensated by restoring the captured pre-mutation version; closes treat an already-terminal target as applied and are likewise compensated by version restore; releases restore the prior claim when a later step fails. The journal step identity includes a canonical mutation fingerprint, so reusing a transaction id with changed payload fails before new work. A stable transactionId makes interrupted batches resumable across processes and agents.

When a direct SDK operation must observe extension-owned item types, fields, hooks, parsers, or services, wrap it with runWithActiveExtensions. The scope loads and tears down the workspace extensions once, serializes the global registries across concurrent requests, and is reentrant: SDK primitives called inside the callback reuse the active scope instead of attempting a nested activation cycle.

import {
  commitItemMutations,
  runWithActiveExtensions,
} from "@unbrained/pm-cli/sdk";

await runWithActiveExtensions({ cwd: projectRoot }, () =>
  commitItemMutations({ pmRoot, transactionId, author, mutations }),
);
import { commitItemMutations } from "@unbrained/pm-cli/sdk";

const result = await commitItemMutations({
  pmRoot,
  transactionId: "sync-jira-batch-2026-07-19",
  author: "jira-sync",
  mutations: [
    {
      op: "create",
      id: "jira-1042",
      options: { title: "Imported: fix login flow", type: "Issue" },
    },
    { op: "update", id: "pm-a1b2", options: { priority: "1" } },
    { op: "close", id: "pm-c3d4", reason: "Resolved upstream in Jira" },
  ],
});
// result.results is keyed by derived step id: { "1-create-jira-1042": { id, op }, ... }

commitWorkspaceTransaction remains the escape hatch for arbitrary domains (relationship events, foreign stores, mixed-step plans); commitItemMutations covers the item-mutation 90% case with correct-by-construction wiring. Update mutation options use the same lossless acceptance-criteria contract as the CLI: criteria are stored in one string with semicolons as boundaries, so each addAc/removeAc entry must be semicolon-free. Every removal must match or the complete mutation rejects with acceptance_criteria_remove_unmatched and structured unmatched recovery data. Whole-value acceptanceCriteria replacement is mutually exclusive with additive edits and returns an acceptance_criteria_replaced:<before-count>:<after-count> warning when it changes existing criteria.

Create and update mutation options also resolve every newly supplied local dependency target before persistence. Use source_kind: "external" for a cross-workspace endpoint. A staged local import must opt in with allowUnresolvedDeps: true; successful results then retain one dependency_target_unresolved:<id> warning per missing target. Bulk dry-runs perform the identical validation before producing a plan.

parseItemMutationBatch is the strict legacy JSON boundary for that primitive. It accepts either a non-empty mutation array or { "mutations": [...] }, derives allowed option names from the exported create/update/close command contracts, and rejects unknown row or option keys with a did-you-mean diagnostic before a workspace lock or write is attempted. This lets embedded tools expose the same safe batch format without duplicating validation:

import {
  commitItemMutations,
  parseItemMutationBatch,
} from "@unbrained/pm-cli/sdk";

const mutations = parseItemMutationBatch(batchJson);
await commitItemMutations({
  pmRoot,
  author: "sync-agent",
  transactionId: "upstream-sync-2026-07-20-001",
  mutations,
});

For coherent heterogeneous specifications, use resolveItemMutationDocument. It accepts the legacy array or a versioned { schema_version: 1, mutations } document. Create rows may declare a unique ref, omit id, and use exact @ref values in target ids, parent, blockedBy, and dependency id fields. Omitted ids are derived from the transaction id, alias, and workspace prefix; explicit ids are normalized. Malformed, duplicate, unknown, and cyclic references fail before tracker mutation. Commit the returned mutations and retain its alias-to-id receipt:

import {
  commitItemMutations,
  resolveItemMutationDocument,
} from "@unbrained/pm-cli/sdk";
import path from "node:path";

const pmRoot = path.join(process.cwd(), ".agents", "pm");
const specificationJson = JSON.stringify({
  schema_version: 1,
  mutations: [
    {
      op: "create",
      ref: "initiative",
      options: { title: "Initiative", type: "Epic" },
    },
    {
      op: "create",
      ref: "delivery",
      options: {
        title: "Delivery",
        type: "Feature",
        parent: "@initiative",
      },
    },
  ],
});

const resolved = resolveItemMutationDocument(specificationJson, {
  transactionId: "specification-2026-08-05-001",
  idPrefix: "pm-",
});
await commitItemMutations({
  pmRoot,
  author: "specification-agent",
  transactionId: "specification-2026-08-05-001",
  mutations: resolved.mutations,
});

For imports, use planItemMutationDocument (tracked by pm-gh1456). It reads the workspace schema so declared custom fields route through their option targets, accepts JSON Lines and op-less create documents (records shaped like pm create --stdin-json input, optionally wrapped as { ref, item }), synthesizes row-<n> aliases for records without ref/id, derives a stable transaction_id from the document bytes when none is supplied, and resolves upsertKey against existing items so matching create records become updates; core camelCase option names (such as acceptanceCriteria) and declared field keys or option targets resolve to the stored metadata key before candidate reads and lock-held guards. An explicitly supplied blank upsertKey is a usage error; omit it to select plain creation. Unrelated extension metadata keys retain their exact spelling, including camelCase. Item documents also accept known core camelCase option names, including inside { item } wrappers. Exact stored metadata keys win when both spellings appear. Records that match nothing carry a unique guard that commitItemMutations re-checks under the writer lock and refuses with upsert_key_conflict when a concurrent writer created the key value after planning; matched records become updates carrying a match guard checked against the locked target document, after acquiring its item lock. It refuses with upsert_match_changed when the target's key changed in between, including changes made by ordinary item updates. Successful update, close and release steps invalidate cached unique-key scans inside a mixed batch, so subsequent creates see the current persisted values, including changes to earlier creates in that batch. Every create also advances cached scans from its returned metadata, including unguarded creates and records guarded on a different key, without a new project scan. The returned rows receipt maps every input record, in order, to its resolved id and op:

import {
  commitItemMutations,
  planItemMutationDocument,
} from "@unbrained/pm-cli/sdk";

const plan = await planItemMutationDocument({
  pmRoot,
  input: [
    '{"title":"Imported A","type":"Task","external_id":"ext-1"}',
    '{"ref":"b","title":"Imported B","type":"Feature","parent":"@row-1","external_id":"ext-2"}',
  ].join("\n"),
  upsertKey: "external_id",
});
const result = await commitItemMutations({
  pmRoot,
  author: "import-agent",
  transactionId: plan.transaction_id,
  mutations: plan.mutations,
});
console.log(plan.transaction_id_source, plan.rows, result.recovered);

After a successful commit, reuse its transaction id and unchanged resolved mutation plan to replay it (recovered: true). A formatting-only edit changes the derived id; pass the original id explicitly to replay an unchanged plan. The planner excludes items created by that transaction from upsert matching, retaining those create rows. If document edits, external matches, or schema context change the resolved plan under that committed id, the coordinator refuses with transaction_plan_mismatch; use a new transaction id for the changed import. Corrupt journal JSON, stored schema version/transaction identity, lifecycle state or metadata instead refuses with transaction_journal_invalid. Preserve and inspect or restore the journal; starting a new id can duplicate work whose durable state cannot be verified.

buildItemCompletionMutations creates the ordered evidence/update, close, and release plan for inspection. commitItemCompletion executes that plan through the same durable coordinator, restoring annotations, linked artifacts, lifecycle fields, and claim ownership on failure. It is the SDK primitive behind pm item complete.

Host-bound batch and recovery APIs are tracked by pm-gh1598 and pm-gh1597 (tracker records excluded from npm). A PmClient acquired from an extension host supports the same batch coordinator without reconstructing its tracker root or overriding the host author policy:

const plan = { transactionId: "sync-42", mutations };
const preview = await client.previewItemMutations(plan);
await client.commitItemMutations(plan);
const receipt = await client.itemTransactions.status("sync-42");

Preview and status setup use configuration-only source reads, so activation does not open optional schema files before preview validates their storage.

Each mutation in a newly staged batch contributes a records row with its original index, canonical id, operation, and full public item projection including body after the complete batch. Repeated targets therefore show the same final staged state. execution distinguishes a newly applied mutation from adopted existing state; adopted values do not certify the unused create options. commit_time_fields lists JSON pointers to potentially regenerated clocks, conservatively including existing or explicitly supplied clocks. These preview values are observations, not reserved commit values. Compare normalized intent separately from those clocks; commit still evaluates current state, attribution, extension policy and concurrency. A committed replay returns an empty records array, not a reconstruction of its original write values. While the committed journal is retained, replaying the identical plan performs no item write; changing its ordered payload under the same id refuses with transaction_plan_mismatch. If transaction GC removes that committed journal, the coordinator can treat its id as new; replay guarantees do not survive that removal.

The workspace transaction lock serializes transaction coordinators. Ordinary SDK and CLI item writers can still interleave between a step's read and write; the lock does not provide a general read-decide-write critical section.

New item journals retain their original mutation plan and compensation policy. History redaction does not rewrite transaction journals: values removed from item history can remain in interrupted or abandoned journals. GC retains those recovery receipts. Do not include secrets in batch mutations. The status receipt contains the validated journal and a SHA-256 planDigest over its ordered payload-bound step identifiers. Started/completed lists report persisted coordinator progress: a crash after apply but before recording can leave an applied step listed only as started. Recovery re-inspects durable item history before compensation. No retained journal returns undefined; corrupt journals refuse instead of appearing absent.

await client.itemTransactions.supersede("interrupted-sync", "sync-43", {
  reason: "Re-plan against the current item revision",
});
// Read current items and build the replacement only after retirement succeeds.
await client.commitItemMutations({ transactionId: "sync-43", mutations: replanned });

abandon(id, { reason }) durably fences forward replay before compensating interrupted owned effects in reverse order, records a workspace audit event, and marks the identity abandoned. Retirement upgrades the journal to schema version 2 so older coordinators refuse the pending intent instead of silently resuming it. supersede(id, newId, { reason }) performs that retirement and records the replacement identity; it does not atomically commit the replacement plan or reserve its id. Repeating the same retirement is idempotent. Committed journals cannot be abandoned. A pending-retirement refusal keeps the transaction_abandoned code, reports retirement.state: "pending" and the recorded superseded_by value (null for abandonment without a replacement), and explains how to retry the original intent. Finish that retirement before re-planning. During compensation, a later peer edit refuses with transaction_partial_mutation and retains recovery data; it is never silently overwritten. Legacy journals without a retained item plan require the original plan and adapter for replay. Abandoned receipts remain retained by transaction GC, including pending retirement intents, so their identities cannot silently become reusable. Mutation inputs are copied before coordinator admission; later caller edits cannot change the payload whose identity and recovery plan were recorded.

previewItemMutations validates an ordered plan with the same transaction coordinator and lifecycle primitives as apply, using a disposable copy of the tracker. Earlier staged creates, evidence updates and closures participate in later validation. Existing journals retain the coordinator's replay and plan-identity behavior. The preview coordinator writes no source item, history, claim, lock or journal data. Active declarative extension registrations are preserved; executable extension guards, hooks, service overrides, and package command handlers remain disabled. Package-backed --template create input cannot be semantically previewed through its package handler; expand it into explicit core mutation options before preview.

Actionable previews are tracked by pm-ekf2gu, pm-fa9olc, and pm-jhkzk0. previewItemMutations returns warnings grouped by zero-based operation index, canonical id, and op. These are the original create lifecycle warnings, including advisory likely_duplicates and duplicate-recovery tokens. Earlier staged creates participate in later duplicate checks. Strict policy still refuses duplicates; preview never changes the configured policy.

For a document resolved with planItemMutationDocument, call previewItemMutationDocument({ pmRoot, author, plan }) from the aggregate SDK. It returns { validation, rows }; affected rows retain their source index and ref and gain warnings. Rows without warnings omit that property. The input plan stays unchanged. Both pm item mutate --dry-run and MCP pm_mutate use this same projection. A replayed_committed_plan has no newly evaluated warnings; an empty warning list in that state does not certify current policy.

Staging excludes derived search and locks content and unrelated runtime state, plus extensions/<directory>/node_modules dependency trees. These trees are excluded before entry inspection or traversal, including dependency links; configured schema files and registered item folders take precedence and retain the regular-file safety checks even inside a dependency tree. Extension source, manifests and managed-package metadata remain in the snapshot. The receipt declares derived_extension_dependencies_excluded among its unresolved commit constraints because executable package dependencies are not validated. Staging retains registered item folders, explicitly configured schema files, and runtime/session.json for core focus and inherited-parent semantics. Root-layout trackers copy only tracker-owned files and directories, including standard and custom item folders and configured schema files; unrelated project content is excluded. Preview accepts regular files and directories. Entry checks reject existing interior symbolic links and special files; an explicitly selected root link is resolved once to establish the tracker boundary. Absolute configured schema paths inside that resolved boundary are remapped only in the staged settings. Schema files configured outside the tracker fail with transaction_preview_external_schema; relocate them inside the selected tracker root before retrying.

transaction_preview_non_regular_file includes preview_storage in SDK error context and the CLI JSON error envelope. It reports a fixed storage_area, entry_redacted, and, when safe, relative_entry using / separators relative to the selected tracker root. Coordinates are limited to 160 ASCII characters from letters, digits, ., _, -, and separators. Absolute paths, traversal, control characters and overlong or otherwise unsafe names are withheld; the storage category remains available. Diagnostics never include link targets. Inspect that entry without following links, restore the intended regular file or directory, and retry the dry run. Native filesystem errors retain their existing diagnostics.

Preview requires trusted, stable directory entries and ancestors. Where Node exposes O_NOFOLLOW and O_NONBLOCK, descriptor opens request those POSIX guards. Windows retains entry checks, regular-file descriptor checks and snapshot comparisons without these flags. Portable Node APIs do not pin directory entries or ancestors, and later fingerprints cannot prevent hostile path redirection before a read. Use a caller-owned isolated copy when an untrusted actor can concurrently replace workspace paths.

Durable source bytes are fingerprinted before and after copying and compared with the staged copy. An inconsistent copy or a file disappearing during the scan fails with transaction_preview_snapshot_changed and can be retried. File contents are hashed in bounded streams so large history files do not require whole-file buffers. PmCliError failures from staged step inspection, compensation preparation and apply preserve their lifecycle classification and include a structured transaction_operation with the zero-based index, operation and requested target ID from the mutation plan, after any batch-local reference resolution. That ID need not be prefix-normalized. Native errors and compensation or recovery failures retain the coordinator's error semantics; if compensation also fails, its AggregateError preserves the decorated primary error.

CLI item mutate --dry-run, item complete --dry-run, and MCP pm_mutate with dryRun: true share this validation. CLI structured previews use runWithConfigurationOnlySettings(pmRoot, run, isEnabled?) across bootstrap, command execution and error cleanup. The optional predicate defaults to enabled. Scoped settings reads validate settings.json and merge inline configuration with defaults, without loading or scaffolding optional schema files, running settings-read hooks, or consulting or populating hydrated settings caches. Only selected bootstrap/action tracker roots, the actual global tracker root, and their resolved aliases are scoped.

Nested async-local scopes compose through new policy layers without modifying their ancestors. Matching-root reads evaluate live predicates, so returning false disables that layer while an enabled caller-owned ancestor can still protect the same root. Concurrent invocations keep separate policies, and scopes restore when the callback returns or throws. Staged and unrelated roots retain normal settings behavior, including schema-file hydration.

CLI bootstrap conservatively protects normalized invocations in the item namespace that contain a literal --dry-run, before extension option contracts are available. These settings policies cover command registration, invocation base-settings reads and parser-error cleanup. The earliest root preAction hook then settles invocation-owned policies from the actual selected command path and effective optsWithGlobals() values, before host-owned consent, migration and telemetry preparation. The real parser owns required-option values, -- boundaries, positional child selection and inherited option precedence. For example, an extension's required --package-note --dry-run consumes --dry-run as its value. Without a separate preview flag, ordinary apply disables its own policy layers and resumes normal source/global settings hydration, consent, identity initialization and migration preparation. Explicit caller-owned layers remain effective. Genuine previews remain scoped when extension options are present. Structured CLI previews skip host-owned extension migration execution, first-use telemetry consent persistence and installation-ID initialization. Pending migration effects on a later commit remain unvalidated. Existing consented telemetry with a persisted identity still enters derived local queues; preview schedules no flush worker, and later normal commands retain delivery. Normal CLI extension discovery and activation happen before staged execution and retain their existing policy. CLI parser/preflight overrides and before/after-command hooks are also outside staged suppression; --no-extensions disables those executable paths.

ItemMutationPreviewValidation reports validated: true, state: "staged_snapshot" when the coordinator executes the staged plan. An already committed journal instead returns validated: false, state: "replayed_committed_plan", matching idempotent apply without freshly executing or validating the supplied lifecycle steps. Both results disclose unresolved_commit_constraints: ["concurrent_tracker_changes", "extension_mutation_guards_and_hooks"]. Newly staged validation proves core semantic validity at that snapshot; commit still checks current state and runs extension guards and hooks. Preview cost is proportional to the tracker data staged, rather than unrelated root-layout project content.

itemDocumentToMutationOptions is the companion full-document adapter. It accepts either a direct ItemDocument or the envelope returned by pm get <id> --json, strips read-only metadata, maps canonical snake-case item fields to typed mutation options, and serializes linked facets through the same repeatable option forms as create/update. Explicit options win over document values. Pass resolveRuntimeFieldRegistry(settings.schema).definitions as the fourth argument to route workspace metadata keys through their declared SDK option names. CLI stdin automatically resolves the selected workspace schema. Schema aliases override document values through the same field registry. Declared extension fields use raw name=value assignments; explicit field entries override only matching names, preserving other document fields. Strings retain commas, equals signs, quotes, Unicode, and newlines; numbers, booleans, objects, and arrays retain their types. Unknown fields and near-miss built-in keys refuse before item or history writes.

Tracked by pm-stdin-schema-fields. Stored schema containers retain their decoded shape; repeated CLI container flags still select the final supplied value. Normal SDK item operations refuse ahead-of-runtime storage markers before schema or body validation and retain item identity/path in the structured error. Integrity diagnostics preserve future markers for inspection.

The built-in adapters stay deliberately thin:

  • pm item mutate --transaction-id <stable-id> --stdin-json resolves aliases, validates, and commits a versioned batch; --dry-run returns the concrete graph and alias receipt without writes.
  • pm item complete <id> <reason> --transaction-id <stable-id> records linked evidence, closes, and releases a claim as one compensating transaction.
  • pm create --stdin-json and pm update <id> --stdin-json accept a whole item document. This supports the lossless get → edit → update agent workflow without translating every field into argv.
  • MCP pm_mutate accepts the decoded mutation array plus the same transaction, compensation, dry-run, and lock controls. It calls the same validator and SDK primitive rather than spawning the CLI.

The batch is logically atomic at the durable journal boundary described above, not an MVCC snapshot. Use stable transaction and create ids, size the lock TTL above the longest mutation attempt, and keep the JSON batch deterministic so a different agent or process can safely resume it.

Package and extension lifecycle convenience methods are the SDK primitive layer for custom PM tools that need to manage their own package surface without shelling out. Use pm.packageList, pm.packageInstall, pm.packageUninstall, pm.packageDoctor, pm.packageManage, pm.packageDescribe, pm.packageReload, pm.packageCatalog, pm.packageActivate, pm.packageDeactivate, and pm.upgrade for package-mode automation; use pm.extensionList, pm.extensionActivate, and pm.extensionDeactivate when the UI vocabulary is explicitly extension-focused. The matching top-level helpers (packageLifecycle, packageList, packageInstall, packageUninstall, packageDoctor, packageManage, packageDescribe, packageReload, packageCatalog, packageActivate, packageDeactivate, extension, extensionList, extensionActivate, extensionDeactivate, and upgrade) construct a short-lived PmClient for one-off calls. Package helpers return PackageCommandResult and extension helpers return ExtensionCommandResult; both names describe the same lifecycle payload shape with vocabulary-appropriate SDK signatures. UpgradeResult is the same structured payload rendered by the CLI, so embedded tools can own their presentation layer while sharing pm's package/install/doctor semantics. packageCatalog is a read surface and accepts the universal output controls; the CLI-equivalent pm --output-budget unbounded package --catalog --json therefore composes with the same output contract while catalog-plus-mutation invocations fail before any package state can change.

Annotation and relationship convenience methods turn "project management = context management" into a typed SDK surface. Use pm.comments, pm.notes, pm.learnings, pm.files, pm.docs, pm.deps, and pm.append when an embedded agent, package, or custom UI needs to add durable rationale, link changed files/docs/tests, or inspect the item graph. pm.filesDiscover exposes the same file-candidate discovery used by the CLI, so a custom tool can present reviewable link suggestions instead of scraping git output itself. Comments, notes, and learnings share the exported annotation kernel, including plain/stdin/file input resolution for CLI and embedded SDK calls, one-based edit/delete semantics, ownership guidance, history mutation metadata, and stable list pagination. MCP tool actions intentionally omit file input to prevent host filesystem access. Package authors can build custom annotation presentation layers without importing CLI modules. CommentsCommandOptions.ifAbsent, NotesCommandOptions.ifAbsent, and LearningsCommandOptions.ifAbsent give retrying agents one explicit idempotent annotation append contract. Equality is evaluated under the item writer lock after author resolution and text normalization, using the resolved author plus exact stored text. The first append returns changed: true and mutation_receipt.changed_count: 1; an exact retry returns the existing entry, changed: false, and changed_count: 0 without changing the item, history, or derived search state. Different authors remain distinct, and the default without ifAbsent continues to append intentional duplicates. The option is valid only for append input and fails closed for list, edit, or delete modes. CLI comments|notes|learnings --if-absent and the corresponding MCP actions with options.ifAbsent: true are thin transports over this SDK behavior. PmClient.notes also accepts addJson for a validated structured context event. The persisted entry remains backward-readable through canonical text while exposing typed format: "json", data, and event_type fields. since, eventType, limit, and includeMeta form the bounded query contract; the collection continues to use field-aware union merge semantics for concurrent branches.

Customization convenience methods are the SDK baseline for project-specific pm tools. pm.init stages a tracker, pm.config reads/writes settings, pm.schema* methods manage types/statuses/fields/presets, and pm.profile* methods list, inspect, apply, and lint project archetypes. These helpers let a package or app construct an opinionated project-management experience while staying on the same schema/profile primitives the CLI and MCP use. The schema mutation and inspection engine is owned by src/sdk/schema.ts; the CLI schema module is a presentation-compatible re-export. Embedded runtimes can therefore compose schema operations without importing CLI code, while CLI, MCP, and PmClient continue to share the same validation, locking, persistence, and result contracts. pm.init accepts workspace to initialize <workspace>/.agents/pm; path-target calls retain tracker-root semantics. InitResult.target exposes the resolved mode, tracker root, optional workspace root, discovery provenance, invocation directory, and safe current-directory tracker suggestion, while every explicit-target next_steps command carries --pm-path so embedded tools can display runnable recovery without depending on the caller's current directory.

Use SDK Agent Session and Episode Context to declare role/topic provenance once, propagate it across SDK, CLI-child, and MCP boundaries, and reconstruct deterministic nested work episodes from immutable history.

Package install results include details.verification, a light doctor-equivalent projection of activation, registered commands/actions/item types, target tracker root, and health verdict. Treat ok: false or verification.status: "degraded" as an install failure even when files were copied successfully; use activation_diagnostics and command_discovery.next_steps for remediation. Registry dependency inputs are parsed through npm's package-spec grammar, reject option-leading names and shell syntax, and are passed after an explicit -- fence. Verification imports a temporary snapshot of the complete installed extension directory, including transitive ESM files, and reports module_graph_verification: "fresh_snapshot" so a same-process upgrade cannot pass against stale cached dependencies.

Governance and maintenance convenience methods expose the operational floor a custom PM host should run before it trusts or publishes tracker state. pm.validate checks resolution/history invariants, pm.health performs read-only diagnostics, and pm.gc runs dry-run or explicit cleanup paths through the same bounded maintenance engine as the CLI. Prefer these typed calls over shelling out when building CI, editor integrations, or long-running agent runtimes.

Health treats committed structural tracker directories (schema and history) as required. Clone-local search and locks, plus an empty extensions directory, are clone-optional because Git cannot preserve empty directories and runtime caches are rebuilt locally. Registered item-type folders follow the same rule; missing optional directories become warnings only with strictDirectories: true (CLI --strict-directories). A later item write or runtime operation recreates its directory.

Query execution

Tracked by pm-rjqr and the SDK boundary capstone pm-9x6e.

runList and runSearch are SDK-owned query engines, including filtering, projection, pagination, keyword and semantic ranking, extension-provider dispatch, and token-efficient response metadata. Their historical CLI module paths are identity-preserving compatibility exports, while runEval consumes the SDK search engine directly. Package authors can import these functions and their typed result contracts from @unbrained/pm-cli/sdk without importing CLI implementation modules.

Read projections also accept metadata names registered by active extensions with registerItemFields, including item.<name> selectors. For example, client.get(id, { fields: "id,probe_score" }) returns the declared numeric value without requiring a full item read. list and search use the same active field vocabulary; search retains the requested selector spelling in its row keys. Disabling the extension removes its selectors from validation and recovery. Unknown names still refuse, and recovery retains valid requested custom fields. Tracked by pm-gh1571.

Settings reads load optional schema files without creating missing files. Missing sections use configured or built-in defaults in memory, and later file creation invalidates the settings cache. Explicit pm init --defaults scaffolds missing sections while preserving existing schema definitions. This applies to shared SDK settings reads and CLI read commands; derived query caches and normal observability retain their own policies. Tracked by pm-gh1568.

The shared query cursor contract treats every published list, context, and search flag as query-semantic by default: it changes the matched or ordered result and therefore invalidates a cursor. Only page-size and rendering flags carry CliFlagContract.cursor_semantics: "presentation", keeping the public contract compact and fail-safe for newly added flags. Use selectCursorSemanticOptions before createQueryFingerprint for a package-authored query surface. encodeQueryCursor, decodeQueryCursorState, and paginateQueryRows remain the transport-neutral continuation primitives.

A flag with a closed value domain declares it in CliFlagContract.values. For example, get and context declare --depth brief|standard|deep|full, and plan declares --depth brief|standard|deep, so pm contracts --command <c> --flags-only --json shows the accepted spellings without parsing help prose. resolveReadCompletenessControls(command) combines that domain with the command's read-output projection modes. resolveReadOptionRetryArgs uses the result to correct a completeness spelling only into a flag the attempted command declares (pm-gh1626). A flag that accepts a comma-separated value list declares list: true. --unset on create, update and update-many does, so repeated flags and comma lists reach the parser as the same field set (pm-gh1633).

decodeQueryCursorEnvelope, decodeQueryCursorState, and resolveQueryCursorStart accept an optional final cursorFlag argument ("--after", the default, or "--cursor") so package-authored queries can name their actual producer flag in refusal guidance. Relationship packets use --cursor. The deps action additionally returns a scoped executable recovery.suggested_retry_args for a fresh query after a malformed or mismatched cursor; it preserves traversal and output policy while removing the rejected producer cursor and stale output continuation. See pm-ohgg1b.

At the storage boundary, the metadata cache maintains a rebuildable SQLite query projection under the same source cursor and cross-process writer lock as the JSON metadata/body/collection tiers. Bounded default-order light list pages consume this index directly. Heavy projections, extension read hooks, trees, custom runtime fields, dependency-aware filtering, unsupported filters, and stale/missing indexes take the canonical fallback path with identical result contracts. Custom hosts may call queryItemMetadataIndex directly, but must treat null as an instruction to rebuild or use authoritative storage. The separate history-event index is also optional. Recognized native SQLite failures while starting its write transaction invalidate that projection before appending authoritative history. Native lock contention retains its existing invalidation protocol; unrelated adapter exceptions and authoritative append failures propagate. A later index rebuild restores accelerated history reads. (Repository tracker: pm-0orupq; tracker records are excluded from npm.)

List results always expose total, has_more, truncated, and a nullable next_cursor, whether or not the page was truncated; unset filter echoes are omitted. This stable, lean envelope lets agents plan continuation without branching on key presence. Result rows are modeled by projection: ListFullResult contains complete ListedItem records, tree ordering may enrich them with ListTreeMetadata, and compact or fields projections return ListProjectedItem dictionaries. Use completeness.status to distinguish an authoritative complete source scan, a partial scan with unreadable item/directory counts, and an unchecked derived-index page. Set ListOptions.strictRead (CLI --strict-read) when an automation must fail instead of accepting omissions. Use full: true when an integration requires complete item metadata; the overload then returns ListFullResult without an assertion or cast.

When correctness depends on the entire workspace rather than a page, use the certifying primitive instead of rebuilding these conditions at every call site:

import {
  PmClient,
  certifyCompleteListResult,
  createCompleteListOptions,
} from "@unbrained/pm-cli/sdk/runtime";

const pm = new PmClient({ pmRoot: "/workspace/.agents/pm" });
const corpus = await pm.listAllComplete({ includeBody: true });
corpus.complete_list.source_complete; // true, otherwise the call throws

// Custom transports can apply the same request and certificate independently.
const candidate = await pm.list(createCompleteListOptions());
const certified = certifyCompleteListResult(candidate);

PmCompleteListValidationError.receipt lists every failed invariant and carries the exact recovery command pm list --all --full --strict-read --no-truncate --output-budget unbounded --json. The helper never infers completeness from a large limit: derived-index reads, terminal-state filtering, duplicate ids, cursor/session projection, compaction, and any omission receipt all prevent certification.

CLI JSON consumers can add --lean to compact null and empty values. On item-list envelopes, lean output also removes request echoes (filters, now, projection, and sorting) and removes next_cursor when has_more is not true. The normal JSON envelope remains unchanged, and an actionable cursor is always retained. SDK query functions continue returning their full typed results; lean projection belongs to the CLI presentation boundary.

Custom agent hosts can import projectPmBriefListEnvelope from @unbrained/pm-cli/sdk to apply the same list presentation without mutating the original result. It covers the brief default, --compact and --fields row projections alike (GH-1674), so a narrower --fields page never carries more envelope than the default. It retains unchecked source status and actionable continuation, and leaves partial, custom, or inconsistent envelopes intact. The projection echo is dropped because the row header already lists the fields; an empty --fields page keeps it, since no row shows them. A filters.fields echo equal to that projection is dropped too. projectPmBriefSearchEnvelope does the same for compact and --fields search pages. It drops truncated when it repeats has_more, a filters.limit echo of applied_limit, and on --fields pages the projection, now, and a mode_source other than settings. It replaces the single default projection_metadata or full_item_fields omission receipt with details: "--full". SDK runSearch results for a fields projection echo only the filters the caller set, like the compact default; --full keeps the complete resolved filter echo. For flat default lists, the list projection replaces the default ascending sort's legacy sort/order echo with one effective_keys comparator line, so the effective default order stays visible. Tree-mode lists carry no effective_keys, because hierarchy traversal overrides flat ordering; there the default sort echo is simply omitted. The CLI applies these projections only to TOON; ordinary JSON and SDK query results retain their complete contracts. The public encodePmTableRows and decodePmTableRows primitives distinguish explicit null and empty cells from missing keys. TOON rendering preserves those row facts before selecting the smaller expanded or tabular encoding.

Migration note: before this contract, total was present only when pagination omitted rows, a completed page omitted next_cursor, and verbose results emitted unset filter keys with null values. Consumers must use has_more or next_cursor != null for continuation, read total as the unconditional pre-pagination match count, and use key presence for filter diagnostics; "total" in result, strict next_cursor !== undefined, and filters.<key> === null checks do not express the stable envelope semantics.

Execution and diagnostics

Workspace assurance composes createAssuranceWorkspaceContext, evaluateMeasurement and evaluateAssuranceGate from the public governance surface. Strict and ordinary workspace reads both include item bodies alongside metadata, comments, notes and learnings. Prose relationship measurements match complete case-insensitive IDs, including numeric prefixes and multiple hyphens; a longer identifier never creates a gap for its shorter prefix. Pair deduplication, explicit/implicit partitions and exact exemptions share this same context. Tracked by pm-jprn58 and pm-axotea.

Tracked by pm-oslr, the SDK boundary capstone pm-9x6e, and quantitative test evidence pm-ygerpy.

Test execution, background-run supervision, search evaluation, telemetry inspection, and tracker statistics are SDK-owned primitives. A custom CI host or project-specific tool can compose the same sandboxing, context-preflight, deduplication, failure classification, progress, consent, and structured-result behavior as the CLI without spawning pm or importing src/core modules:

Filtered linked-test runs require positive execution evidence when failOnEmptyTestRun is enabled. Successful TestRunResult.execution_receipt records the recognized summary code and its stdout or stderr stream. Bun's positive pass count and executed-test summary are recognized, including summaries with filtered tests. Explicit zero-test output, nonzero exits, and failed configured assertions still fail. See pm-gh1418.

import {
  runEval,
  runTelemetry,
  runTest,
  runTestAll,
  runTestRunsStatus,
  type GlobalOptions,
} from "@unbrained/pm-cli/sdk";

const pmRoot = "/path/to/project/.agents/pm";
const itemId = "pm-example";
const runId = "test-run-example";
const global: GlobalOptions = { path: pmRoot };

const itemRun = await runTest(
  itemId,
  {
    run: true,
    autoPmContext: true,
    checkContext: true,
    failOnEmptyTestRun: true,
    measure: [
      "coverage=100,unit=percent,threshold=100",
      "p95_latency=42,unit=ms,threshold=50",
    ],
    metricBelow: "coverage=100",
    metricDiff: "p95_latency",
  },
  global,
);

const workspaceRun = await runTestAll(
  { status: "open", autoPmContext: true, failOnSkipped: true },
  global,
);
const background = await runTestRunsStatus(runId, global);
const relevance = await runEval({ mode: "keyword", k: 10 }, global);
const telemetry = await runTelemetry(
  { subcommand: "stats", limit: 20 },
  global,
);

runTest and runTestAll always execute linked commands in isolated project and global tracker roots. pm_context_mode, run-level overrides, automatic tracker context, assertion requirements, empty-run detection, and failure categories are part of the SDK result contract rather than presentation-layer behavior. Workspace results distinguish the run-level requested mode from the effective per-test mode. isolated executes from an empty disposable directory; snapshot executes from a filtered disposable workspace. Trust preflight failures report trust_refusal, while malformed or incomplete provenance uses the additive provenance_invalid storage marker and fail-closed invalid_provenance trust reason. Package mutation adapters can call attachLinkedTestMutationProvenance so Git inspection occurs only when commands are added; resolveLinkedTestSourceWorkspaceRoot keeps their source-ref lookup aligned with CLI execution and PM_SOURCE_WORKSPACE_ROOT overrides. When item test-result tracking is enabled, measure persists typed numeric evidence on the producing run. Each entry has a stable name, finite numeric value, optional unit and threshold, and the run timestamp. Run history remains bounded; measurements are sorted, de-duplicated by name within a run, and capped at 32 entries. metricBelow returns run-attributed values below a name=value budget, while metricDiff returns the newest value, previous value, and delta. Package authors can use parseTestRunMeasurements, queryTestRunMeasurementsBelow, and diffTestRunMeasurements directly. Background-run helpers return durable records and health/log projections; the host remains responsible for its own rendering and exit-code policy.

runTelemetry retains consent and storage ownership inside the primitive: status and stats are observational, flush uses the consent-aware queue runtime, and clear disables telemetry and removes runtime artifacts. runEval uses the same live search path and unrounded gate decision as pm eval, returning compact rounded report metrics for stable machine output.

Context relevance and evaluation

Context test health is tracked by pm-gh1535 (repository tracker records are excluded from npm). ContextResult.tests counts the complete matching active population, independently of the focus page and token selection. scope: "matching_active_items" and total_items declare its denominator, including active items without tests. Draft, blocked-lifecycle and terminal items are excluded unless the project schema classifies their status as active. The main summary still counts every matching lifecycle status. Consumers constructing TestHealthSummary values must supply the new scope and denominator fields; consumers reading results can use them directly. Markdown limits failing-item identities to the effective focus limit (or the default context limit) and native token budget, with items_failing_omitted when detail is withheld. Population counters remain exact. Structured SDK and MCP results retain the complete failing-item collection before their normal read-output budget handling.

Tracked by pm-4k6b, pm-h3no, pm-3hps, pm-801d, pm-atfm, and pm-qyc6.

pm context and pm next share the public deterministic relevance model. Candidate order is the structural baseline; normalized metadata signals add explainable weighted contributions. Package authors can call buildItemContextRelevanceCandidates(items, { statusRegistry, now, author, semanticSimilarity }) to derive the exact built-in metadata signals, then call scoreContextCandidates with a scorer callback, while installed extensions can register the governed context_relevance service override. A malformed or throwing override degrades to the deterministic default and emits an extension_context_relevance_invalid_result warning instead of breaking the read path.

For persistent derived signals, use ContextSignalStore with a caller-owned ContextSignalStoreAdapter, or use JsonFileContextSignalStoreAdapter for a same-directory atomic JSON snapshot. Each snapshot carries both a format_version and signal_set_version, the authoritative history or index source_cursor, a deterministic recency_evidence_fingerprint, the stable caller clock, and whether its items came from the derived_index or scan_fallback path. Item rows are deterministic and sorted; the snapshot never replaces item or history data.

readOrRebuild(items, options) reuses a snapshot only when its format and signal-set metadata are supported, its item source matches, and its cursor, complete item-id set, and substantive recency-evidence fingerprint match. Missing, stale, version-incompatible, or corrupt state is rebuilt from the authoritative items and atomically replaced. The result reports cache_status without a warning for a successful missing/stale refresh (pm-gh1581; this tracker record is repository-only and excluded from npm). Rebuilt signals are used in the current response; another read is unnecessary. With explainRanking, ranking.feature_store.cache_status exposes the refresh on context receipts. Corrupt/unreadable state and failed persistence retain actionable context_signal_store_invalid and context_signal_store_write_failed warnings. The exported stale-warning descriptor remains for compatibility with older receipts. A write failure still serves the in-memory rebuilt candidates because the snapshot is derived, never authoritative. Optional maps for activity density, graph proximity, claim/focus, knowledge density, author affinity, usage affinity, and semantic similarity let an index or application-specific host supply richer signals without changing the canonical scorer contract.

Large workspaces also receive a separate historical-memory tier. It groups completed items into deterministic calendar-quarter and nearest-epic rollups, retains only bounded item references and outcome strings, and shares the metadata source cursor. readWorkspaceMemory reports fresh/rebuilt/skipped; selectWorkspaceMemoryRollups applies an approximate token budget for context packets, while searchWorkspaceMemory matches rollup labels, outcomes, representative ids, and titles. This file is derived runtime state: it never replaces closed items or immutable history.

import {
  ContextSignalStore,
  JsonFileContextSignalStoreAdapter,
  readSettings,
  resolvePmRoot,
  resolveRuntimeStatusRegistry,
  scoreContextCandidates,
} from "@unbrained/pm-cli/sdk";

const pmRoot = resolvePmRoot(process.cwd());
const statusRegistry = resolveRuntimeStatusRegistry(
  (await readSettings(pmRoot)).schema,
);
const store = new ContextSignalStore(
  new JsonFileContextSignalStoreAdapter(
    ".agents/pm/runtime/context-signals.json",
  ),
);
const signalRead = await store.readOrRebuild(itemsFromDerivedIndex, {
  statusRegistry,
  now: "2026-07-21T12:00:00.000Z",
  source: "derived_index",
  sourceCursor: historyCursor,
  activityDensity,
  graphProximity,
  semanticSimilarity,
});

const report = await scoreContextCandidates(signalRead.candidates);

pm next applies a ranking policy after scoring: priority_floor (the default, from context.next_ranking_policy or the rankingPolicy option) resumes in-progress work, then orders by priority tier with relevance inside a tier; weighted keeps the blended score order. The response discloses it as filters.ranking_policy, and the recommendation reasons name the disagreement when the score leader differs from the recommendation (GH-1618).

Use --explain-ranking --json on pm context or pm next to include the model, available signals, baseline rank, final rank, score, and per-signal contributions. Explanation data is opt-in so default agent output stays bounded.

The opt-in explanation also carries a compact packing envelope: the derived token budget, estimated usage, included projection depths, omitted ids, active task-set profile, and completeness. Packing still governs every default call; only its accounting envelope is omitted to preserve the token budget. packContextCandidates exposes the same deterministic optimizer to SDK consumers. It admits required anchors first, penalizes redundant clusters, applies uncertainty-aware value, and buys identity → summary → full upgrades so relevant rows lose detail before they are omitted. Built-in context and next profiles select intent-appropriate diversity and uncertainty policies while required safety anchors remain mask-immune. An optional latencyBudgetMs bounds candidate comparisons; the report discloses selection_complete, termination_reason, evaluated rows, the active profile, and the full projection-degradation ladder.

The optional context-usage feedback loop is a derived runtime artifact under .agents/pm/runtime/context-usage.jsonl. Serving events retain only author, timestamp, surface, profile, item id, rank, and inclusion; subsequent reads and mutations record item id and command intent. The bounded ledger compacts by age and count, never enters item history, and feeds a decayed usage_affinity signal with an exploration floor. SDK hosts can use recordContextUsageServing, recordContextUsageTouch, recordContextUsageTouches, and readContextUsageAffinity directly. Set PM_CONTEXT_USAGE_DISABLED=1 for a zero-read/zero-write opt-out.

Custom tools can evaluate the same behavior without shelling out:

import {
  runContextEvaluationCorpus,
} from "@unbrained/pm-cli/sdk";

const report = await runContextEvaluationCorpus([{
  id: "returning-agent",
  surface: "context",
  options: { limit: "5" },
  judgments: { "pm-current": 3, "pm-support": 2 },
  required_ids: ["pm-current"],
  continuity_ids: ["pm-current", "pm-support"],
  token_budget: 1200,
  rationale: "Resume claimed work with its supporting context.",
}], pm, {
  ndcg: 0.9, reciprocal_rank: 0.9, required_recall: 1,
  continuity_coverage: 1, token_budget_adherence: 1,
});
if (!report.passed) throw new Error(report.failures.join(", "));

PmClient directly satisfies ContextEvaluationReader, including universal budget receipts. The evaluator preserves the complete packet and refuses whole-result omissions with context_evaluation_read_incomplete before scoring, including omissions caused by a shared session despite an unbounded per-read budget. Increase the budget or start a fresh session before retrying. Ordinary bounded row selection remains evaluable. Tracked by pm-i4kxx1. See SDK consumer receipts for recovery and package-generation contracts.

The evaluator calls only the public context() / next() reader contract. It scores nDCG, reciprocal rank, required-item recall, returning-agent continuity, and deterministic ceil(UTF-8 JSON bytes / 4) token-budget adherence. Reports retain attribution only for served items, while token accounting measures the normal packet without the opt-in ranking explanation.

PmClient and runAction isolate extension registries per asynchronous request. Clients that provide pmRoot can activate unrelated workspaces concurrently without registration leakage. Calls that explicitly override cwd remain serialized because Node's process.chdir is process-global; omit cwd when an explicit pmRoot fully identifies the workspace and parallel throughput matters.

PmClient convenience methods (list, create, update, and the rest) accept command options only. For per-call runtime overrides such as cwd, path, or noExtensions, use run or call runAction directly:

await pm.run("list", { cwd: "/path/to/project", options: { status: "open" } });
await pm.run("create", { title: "Capture SDK input", type: "Task" });

For create, PmClient.run also accepts the common structured top-level item keys (title, type, status, description, body, priority, tags, parent, createMode, and allowMissingParent) and maps them to command options. This keeps dynamic SDK callers ergonomic while the typed pm.create method remains the preferred fully documented path.

For item-type context, use the CLI inspection primitives before issuing custom-domain mutations:

pm schema list --json
pm schema show Experiment --json

schema list/show include built-in, persisted custom, and extension-provided item types. Extension-provided types include provenance (layer and package/extension name) in show --json, which helps agents decide whether a missing type should be registered persistently with pm schema add-type, added through pm init --type-preset, or provided by an installed package.

When a package-owned command is missing at runtime, CLI usage guidance now includes a deterministic install hint (for example pm install calendar or pm install search-advanced) so agents can recover in one retry.

Package installs currently activate only extension resources. Additional package resource kinds (docs, examples, assets, prompts) are metadata-first and available through package manifest/catalog inspection.

Package tests can assert the normalized manifest through the SDK without reimplementing resource sorting, alias normalization, or package.json parsing:

import {
  assertPackageManifest,
  readPmPackageManifest,
} from "@unbrained/pm-cli/sdk";

const manifest = await readPmPackageManifest(packageRoot);

assertPackageManifest(manifest, {
  packageName: "@acme/pm-incident-workflow",
  aliases: ["incident-workflow"],
  resources: {
    extensions: ["extensions/incident-workflow"],
    docs: ["README.md"],
    examples: ["examples/basic.md"],
    assets: ["assets/workflow-diagram.png"],
    prompts: ["prompts/triage.md"],
  },
});

Package tests can also assert extension registrations without importing private loader internals. Prefer createExtensionTestHarness — its assert*/run* methods bind to the right sub-registry for you:

import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing";

const ext = await createExtensionTestHarness(extensionModule, {
  capabilities: ["commands", "schema"],
});

ext.assertCommandContract({
  name: "incident triage",
  flags: ["--severity"],
});
ext.assertFlags({ name: "list", flags: ["--incident-filter"] });
const { result } = await ext.runCommand({
  command: "incident triage",
  options: { severity: "high" },
});
const summary = ext.activationSummary();
const reference = ext.renderMarkdown({ title: "incident package surfaces" });
await ext.deactivate();

For provider-safe schemas, use PM_PROVIDER_TOOL_PARAMETERS_SCHEMA. It is flat and avoids advanced schema constructs such as root oneOf.

Capability Requirements

Registration Manifest capability
registerCommand commands
inline command flags schema
registerFlags schema
registerItemFields schema
registerItemTypes schema
registerRelationshipKinds schema
registerMigration schema
registerProfile schema
registerImporter importers
registerExporter importers
registerParser parser
registerPreflight preflight
registerService services
registerRenderer renderers
lifecycle hooks hooks
registerSearchProvider search
registerVectorStoreAdapter search

Some override surfaces are single-winner: command overrides, parser overrides, preflight overrides, and output renderers. Preflight overrides can declare static command ownership, allowing disjoint packages to compose without false collisions or unrelated callback invocations:

const preflight = definePreflightOverride({
  commands: ["incident triage", "incident close"],
  run: (context) => ({
    enforce_mandatory_migration_gate:
      context.decision.enforce_mandatory_migration_gate,
  }),
});

api.registerPreflight(preflight);

Command paths are normalized. Two scoped overrides collide only when their ownership overlaps; an empty or omitted commands list retains legacy global behavior and collides with every other override. Activation summaries and persisted contribution inventories expose preflight_ownership, so doctor and static tooling can explain the decision. Keep handlers narrowly scoped and verify package combinations with:

pm package doctor --project --detail deep --trace
pm package doctor --project --isolated --detail deep --trace
pm health --check-only --brief

Use --isolated (alias --ignore-global) for hermetic package smoke tests: project diagnostics skip global registrations and avoid machine-local renderer/service overrides. The same isolation can be applied to a whole subprocess suite by setting PM_GLOBAL_PATH to a temporary directory.

Collision warnings are deterministic and include package names plus deactivation guidance. Renderer ownership is independently scoped inside that single-winner format: declare commands, a resultDiscriminator, or both so the host can skip the callback for unrelated output. Doctor warns only for unscoped legacy renderers; collision reporting still applies when packages compete for the same format. If extension code calls a register* API without declaring the matching manifest capability, activation fails with extension_capability_missing:<name>:<capability> in doctor triage. Run doctor with --trace to see the exact method, missing_capability, and manifest capability entry to add before publishing.

Minimal Command Extension

Read-only command handlers can opt into the host output contract with read_output: { row_keys: ["items"] }. This public declaration is tracked by pm-extension-read-output-controls. It supports outputInclude, outputLimit, outputBudget, outputFormat, outputSession, outputCursor, and outputRowContract through CLI flags, PmClient.run(action, { options }), and MCP pm_run. Inspect the active command's flags and schemas with pm contracts --command "<path>" --full.

api.registerCommand({
  name: "inventory rows",
  read_output: { row_keys: ["items"] },
  run: () => ({ items: [{ id: "asset-1", title: "Camera" }], count: 1, total: 1 }),
});

The handler must return a structured object. Each declared path must contain an array or object map; paths may be nested, unique and non-overlapping. Use row_keys: [] for a singleton result. Activation snapshots the declaration, and the host validates the returned collections before projecting them. Avoid reserved metadata and prototype property names. The declaration promises a read-only handler; writers and undeclared commands keep refusing read controls, and native commands/actions retain their existing contracts. A declaration cannot shadow native dispatch; choose a distinct package action.

Receipts and continuation cursors identify the canonical extension command. Resume with the returned cursor and the same semantic inputs; changed rows refuse stale continuation. A budget that cannot fit useful content returns the shared output_budget_exceeded branch, and CLI exits 2. SDK consumers should check isReadOutputBudgetExceeded before treating the payload as their result. Packages can describe their own omitted groups with the existing OutputProjectionDeclaration; the host also discloses reductions caused by its output controls. JSON and TOON estimates include their receipts. A scoped registerRenderer replacement or output_format service is measured too: the estimate, compaction and omission describe the string it returns, and an infeasible budget returns the native refusal rather than the renderer's output (see Read Output Contracts). For a read that declares read output, the host calls a scoped renderer once for every estimate it measures. That includes each compaction candidate as well as the final delivery, so such a renderer must be pure, idempotent and cheap: no counters, file writes or timestamps in its output, and no expensive work per call. If the final rendering costs more than its receipts measured, the host re-measures the read and delivers it natively instead. Read intents report an omitted result either through output_budget_exceeded or through budget_exceeded; isReadResultOmittedForBudget recognizes both.

import { defineExtension } from "@unbrained/pm-cli/sdk";

export default defineExtension({
  activate(api) {
    api.registerCommand({
      name: "hello",
      action: "hello",
      tier: "standard",
      family: "extensions",
      description: "Return a deterministic hello payload.",
      intent: "verify SDK extension activation",
      examples: ["pm hello"],
      failure_hints: [
        "Run pm package doctor --detail deep --trace on activation failures.",
      ],
      run: async () => ({ ok: true, message: "hello" }),
    });
  },
});

Manifest:

{
  "name": "hello",
  "version": "0.1.0",
  "entry": "./index.ts",
  "pm_min_version": "2026.5.31",
  "trusted": true,
  "sandbox_profile": "strict",
  "permissions": {
    "fs_read": false,
    "fs_write": false,
    "network": false,
    "env_read": false,
    "env_write": false,
    "process_spawn": false
  },
  "capabilities": ["commands"]
}

pm_min_version is an inclusive minimum pm CLI version. When the installed CLI is older than the manifest requires, discovery emits extension_pm_min_version_unmet:<layer>:<name>:required=<version>:current=<version> and does not load the extension. Use a plain numeric version such as 2026.5.31; >=2026.5.31 is accepted for compatibility with engines.pm, but ranges beyond an inclusive minimum are not interpreted.

Manifest typing also accepts optional engines metadata:

{
  "engines": {
    "pm": ">=2026.5.31",
    "node": ">=22.18"
  }
}

Use pm_min_version for the loader gate. Keep engines as package-manager and tooling metadata. For pure command packages, keep trusted: true, sandbox_profile: "strict", and all six permissions set to false; relax only the permission keys the package actually needs and verify the result with pm package doctor --project --detail deep --trace.

For a complete commands-capability package that combines registerCommand, registerFlags, and registerParser, see the first-party pm-command-kit exemplar.

Every command definition can declare its agent-surface tier (core, standard, full, or internal) and capability family. These are the same fields projected into CLI help, completion, MCP profiles, workspace contracts, and generated routing documentation. Omitted package values normalize to standard and extensions.

For a generated starter, use pm package init ./my-package. Pass --capability hooks to scaffold a command plus an afterCommand lifecycle reactor and a runnable node:test file that exercises activateExtensionForTest, assertRegisteredHook, runRegisteredHookForTest, and deactivateExtensionForTest. Pass --capability search to scaffold a command plus a deterministic search provider/vector-store adapter pair and a runnable node:test file that exercises assertRegisteredSearchProvider, assertRegisteredVectorStoreAdapter, runRegisteredSearchProviderForTest, and runRegisteredVectorStoreAdapterForTest. Pass --capability importers to scaffold paired import/export commands with example flag metadata and a runnable node:test file that exercises assertRegisteredImporter, assertRegisteredExporter, runRegisteredImporterForTest, and runRegisteredExporterForTest; the generated manifest declares both importers and schema because extension flag metadata is schema-governed. Pass --capability schema to scaffold a command plus a custom item type, item field, and migration (via registerItemTypes/registerItemFields/registerMigration) and a runnable node:test file that exercises assertRegisteredItemType, assertRegisteredItemField, assertRegisteredMigration, and runRegisteredMigrationForTest — a copyable starting point for modeling a project domain. Pass --capability profile to scaffold a command plus a complete project-profile archetype (item types, statuses, fields, a per-type workflow, config, a create template, and package recommendations via registerProfile) and a node:test file exercising the harness-bound assertProfile (the public assertRegisteredProfile); it omits activation.commands (granted by the same schema capability) so the contributed profile resolves through pm profile list/show/apply and pm profile apply <name> tailors a fresh tracker in one shot — the broadest customization primitive in one copyable starter.

The four override surfaces complete the matrix to one starter per SDK registration capability. Pass --capability renderers to scaffold a toon output renderer override (via registerRenderer, scoped to its own command so other output passes through) with a node:test file exercising assertRegisteredRendererOverride and runRegisteredRendererOverrideForTest; --capability parser for a parser override (via registerParser) that rewrites the command's parsed options — the starter command declares matching --shout/--upper flags (so the manifest also declares schema) and surfaces the normalized value, making the override runnable through pm <command> --shout — exercising assertRegisteredParserOverride and runRegisteredParserOverrideForTest; --capability preflight for a preflight override (via registerPreflight) over pm's pre-run migration/format gate decision, exercising assertRegisteredPreflightOverride and runRegisteredPreflightOverrideForTest; and --capability services for an output_format service override (via registerService, scoped to its own command), exercising assertRegisteredServiceOverride and runRegisteredServiceOverrideForTest.

--capability is repeatable so layered shell/config invocations compose predictably. Repeating one value is idempotent. Supplying two distinct starter capabilities fails with a usage error instead of silently choosing the last value; select one starter and add further blueprint surfaces after scaffolding.

Every command-bearing variant's generated manifest.json also declares activation.commands — the exact command paths the starter registers — so pm activates the package lazily, importing and running activate only when an invoked command matches. This mirrors every first-party bundled package and is the contract authors keep in sync with their registrations: an omitted or stale entry means the matching command will not dispatch from the CLI (globally-scoped surfaces such as hooks and search providers for built-in search commands still activate regardless). The schema starter is the deliberate exception: it omits activation.commands so its custom item type — a global contribution that built-in commands like pm create <type> must see — activates conservatively for every command rather than gating on the package's own commands.

Each --capability starter authors an imperative activate body. To scaffold the declarative composeExtension form instead, pass --declarative to pm package init / pm package scaffold (it is an init/scaffold flag, package-mode only — every --capability variant emits its blueprint form, since composeExtension is a runtime SDK value import that only package-mode authoring links) — see Declarative Authoring. See EXTENSIONS.md for the manifest-field reference.

Self-Identity and Lifecycle

activate(api) receives a read-only api.extension describing the extension it was created for, so authors can emit self-identifying logs, gate on their own version, and build better error messages without re-reading the manifest:

export default defineExtension({
  activate(api) {
    // api.extension: { name, layer, version, capabilities, pm_min_version?, pm_max_version?, source_package? }
    if (api.extension.version.startsWith("0.")) {
      api.hooks.afterCommand(() => {
        // ...pre-1.0 behaviour, labelled with api.extension.name
      });
    }
  },
});

api.extension.capabilities is filtered to the canonical capability set, and both the object and its capabilities array are frozen.

Modules may also export an optional VS Code-style deactivate teardown hook. The host runs it on shutdown/reload — the long-running MCP server invokes it between native-action requests — so an extension can close connections, clear timers, and release buffers opened during activate. deactivate runs only for extensions that activated successfully (a failed activate never fully initialized), and teardowns run concurrently. Teardown is best-effort: a throwing deactivate is recorded as a warning, never propagated, and each hook is bounded by a host timeout so one extension cannot block another's cleanup or a host reload. Hosts that call deactivateExtensions directly may pass deactivate_timeout_ms: 0 or Infinity only when they intentionally want to wait indefinitely.

export default defineExtension({
  activate(api) {
    /* open resources */
  },
  async deactivate() {
    /* close connections, flush sinks, clear timers */
  },
});

Flag Contracts

FlagDefinition (used by registerFlags and inline command flags) supports the same list/default semantics as core flags:

  • Flag and schema authoring interfaces are closed contracts: misspelled properties fail TypeScript excess-property checks instead of surviving until runtime activation. Use only documented top-level fields and translate package-private metadata before registration.
  • value_type is the canonical coercion kind (string | number | boolean; the aliases int/integer/float and bool are also accepted). The deprecated type alias is still read, but value_type wins when both are set (value_type ?? type). An unrecognized value type is rejected at registration.
  • list: true makes a repeated, comma-joined flag accumulate into an array — parity with core list flags such as --tags. --scope a,b --scope c resolves to ["a", "b", "c"], with each element coerced by value_type. Long and short aliases share one accumulator, so -s a --scope b,c --scope=d preserves command-line order as ["a", "b", "c", "d"].
  • default (a scalar, or an array of scalars for a list flag) is applied when the flag is omitted; for a list flag the default is flattened into the accumulated array exactly like a provided value — comma-joined strings (e.g. default: "a,b" or default: ["a,b", "c"]) are split into elements. A default that would not cleanly coerce under the declared value_type (e.g. value_type: "number", default: "abc") is rejected at registration. Declared defaults are discoverable without running the command: text help shows (default: ...), JSON help uses default_value, and runtime flag contracts and command schemas use default, preserving strings, numbers, booleans, empty strings and arrays. These are declaration values; runtime coercion and list flattening still apply during execution. Explicit values replace omitted-flag defaults, including list defaults. Tracked by pm-gh1560 (repository-only tracker record, excluded from npm).
  • value_validation ("coerce" | "strict", tracked by pm-extension-scalar-validation) decides what happens to a number/boolean token that does not coerce. coerce is the compatible default: the raw token reaches the handler for its own validation (--limit abc arrives as "abc"). strict refuses the invocation before dispatch with extension_flag_value_invalid (exit 2), naming the flag, the command, the expected grammar, and the received token; list entries are checked one by one, including attached values on boolean list switches. CSV boolean lists accept true,false; repeatable boolean lists keep each occurrence as one value. Switch presence (--verbose, --no-verbose), the legacy bool optional-value grammar (--enabled alone is true), declared defaults, and string flags are unaffected. strict on a string or untyped flag is rejected at registration because there is nothing to validate.
api.registerFlags("report", [
  {
    long: "--scope",
    short: "-s",
    value_type: "string",
    list: true,
    default: "all",
  },
  { long: "--limit", value_type: "number", default: 20, value_validation: "strict" },
]);

Dynamic extension commands follow the same end-of-options contract as core commands. Put -- before variadic content that begins with a dash or resembles a pm flag: pm query run -- RETURN -h --json. Everything after the separator is delivered to the registered positional arguments unchanged.

Handler keys and ownership

A declared flag reaches the handler in context.options under one handler key: the long flag in camel case (--status-filter becomes statusFilter; a legacy underscore spelling such as --include_history keeps it). Every caller gets the same key, defaults, and coercion, because CLI argv dispatch, PmClient.run, MCP tool calls, and runRegisteredCommandForTest share one definition-driven normalizer (pm-gh1665, tracker record excluded from npm). A direct caller should pass the handler key. The flag's own spellings (status-filter, --status-filter, or its short letter) fold into the same key, and undeclared keys pass through unchanged. Two spellings of one flag with different values are refused with extension_option_spelling_conflict (exit 2) instead of silently picking one. CLI argv gets the same refusal: pm demo-brief --status-filter open -s closed exits 2 instead of running with whichever value Commander kept last. Equal values after the declared coercion (--count 1 -c 1.0), repeats of one spelling, and list flags keep their accumulation (pm-wlxf8f, tracker record excluded from npm):

const client = new PmClient({ pmRoot });
await client.run("demo-brief", { options: { statusFilter: "closed" } }); // handler key
await client.run("demo-brief", { options: { "status-filter": "closed" } }); // same result

pm contracts --command <command> --flags-only --json publishes the key as sdk_option on every extension-declared row, and marks the row owner: "extension". The same entry lists the host flags the command inherits under inherited_flags, each marked owner: "host" because it arrives in context.global, not in context.options (pm-gh1666, tracker record excluded from npm):

{
  "command": "demo-brief",
  "flags": [{ "flag": "--status-filter", "owner": "extension", "sdk_option": "statusFilter", "default": "open" }],
  "inherited_flags": [
    { "flag": "--json", "owner": "host", "reservation_scope": "inherited", "value_arity": "none" },
    { "flag": "--pm-path", "aliases": ["--path"], "owner": "host", "reservation_scope": "inherited", "value_arity": "required", "value_name": "path" }
  ]
}

pm help <command> --json carries the same split as options and inherited_options, and text help prints an "Inherited host flags" section. The inherited set is derived from the reserved host-flag table: read-output controls (--output-include, --output-limit, --output-budget, --output-session, --output-cursor, --output-row-contract) appear only on commands that declare read_output, --no-extensions never appears because it removes the command itself, and root-only flags (--version, --all, --explain) are refused as unknown unless the command declares them.

On the CLI, a command with declared flags refuses any other option with unknown_option (exit 2), and the refusal lists exactly what the command accepts: its declared flags, then its inherited host flags, never --no-extensions or, on a command without read_output, the read-output controls (pm-4of199, tracker record excluded from npm). A command that declares no flags still receives undeclared options in context.options, because released packages read options they never declared; pm-4of199 tracks refusing them once those packages declare their flags. Declare every option a handler reads with api.registerFlags or inline flags, so the command keeps working when that refusal lands.

Output Ownership

Unicode output policy (pm-gh1624, tracker record excluded from npm): the built-in JSON and TOON formatters refuse unpaired UTF-16 surrogates in structured output values or object keys with text_unpaired_surrogate and a field path. Custom toJSON() values and boxed primitives are materialized once per render using their native JSON semantics; the resulting snapshot is validated and shared by both formatters. Valid astral characters and caller data are preserved. Extension producers can call assertWellFormedOutputText(value) before returning JSON-compatible data. When replacing malformed external text is intentional, use toWellFormedText(text) from @unbrained/pm-cli/sdk/core (also available from @unbrained/pm-cli/sdk); it replaces lone surrogates with U+FFFD without splitting valid pairs. Apply normalization before constructing object keys so the producer can handle possible key collisions.

Command handlers should normally return structured data and let the host select TOON, JSON, service, or registered renderer output. A renderer override returns a string when it owns the matching payload and null to fall back to native rendering. Prefer declarative ownership so the host never calls it for unrelated output:

api.registerRenderer("toon", (result) => `archive: ${JSON.stringify(result)}`, {
  commands: ["archive show"],
});

commands are normalized command paths. resultDiscriminator can additionally recognize a package-owned result shape; when both are present, both must match. defineRendererOverride accepts the equivalent { commands, resultDiscriminator?, run } object for declarative blueprints. Unscoped callbacks remain source-compatible, but pm package doctor reports them as globally owning the format.

When a command must write directly — for example, a streaming export, binary response, or already-rendered protocol — return suppressHostOutput() so the CLI does not append a second payload:

import { suppressHostOutput } from "@unbrained/pm-cli/sdk";

api.registerCommand({
  name: "archive stream",
  async run() {
    await writeArchiveToStdout();
    return suppressHostOutput({ records: 42 });
  },
});

The optional structured result remains available to command-result hooks, telemetry, and embedded hosts, while the CLI writes no host-rendered output. The marker is structural rather than identity-based, so separately installed packages and custom SDK-built hosts can exchange it safely.

Service overrides should return an explicit ServiceOverrideDecision when claiming or declining a payload:

api.registerService("output_format", (context) =>
  context.command === "archive show"
    ? { handled: true, result: JSON.stringify(context.payload) }
    : { handled: false },
);

For compatibility, null and undefined still decline output_format. Legacy overrides that return the exact context.payload object now also decline and emit a deprecation warning instead of accidentally claiming the payload. Migrate those callbacks to { handled: false } (or the exported declineServiceOverride() helper) so intent does not depend on object identity.

registerItemFields and persisted runtime schema fields share one exported type union (string, number, boolean, string_array, array, object). The string_array kind is a repeatable string collection; array and object preserve validated JSON containers. Extension declarations are compile-time closed for TypeScript authors and validated again at activation for JavaScript. A typo fails activation with a did-you-mean hint (e.g. type: "strnig" → Did you mean "string"?) instead of silently passing and failing opaquely at use time.

Package Error Provenance

Expected package errors retain their structured context across separately bundled SDK copies. Use createPmCliExpectedError to declare an expected refusal. A custom exception carrying only code, exitCode: 1 and remediation remains an unexpected fault. Sentry error attribution uses the same bounded source context as telemetry without requiring telemetry delivery or command tracing, with host version and the registered command owner's manifest version. Noninteractive traffic uses automation; explicit test/dogfood contexts retain their own environments. Error events and usage logs tagged user use production, including when CI was inferred at initialization. Early errors retain --json and --quiet automation attribution. Original extension causes remain available locally through the SDK; Sentry's extra error data integration does not copy cause payload fields into diagnostic contexts. Explicit SENTRY_ENVIRONMENT, consent and test isolation remain authoritative. Sequential commands clear previous extension attribution. Tracked by pm-gh1625 (tracker record excluded from npm).

resolveTelemetrySourceAttribution(options?, env?, interactive?) exposes that same pure attribution policy to custom SDK hosts without reading configuration, creating an installation identity, or sending data. Expected refusals use createPmCliExpectedError; unexpected handler failures retain their original non-enumerable cause in CommandHandlerResult and become ExtensionCommandError in the CLI host. The host preserves structured diagnostics and reports these faults as exceptions. An exception name alone cannot declare an expected refusal when the original error violates the public contract. SDK extension actions and delegated template reads retain the same failure kind.

Expected CLI Errors

A package that echoes caller input in its own refusals or recovery commands should remove URL credentials with the rule that the CLI and telemetry use (pm-nhauur; tracker record excluded from npm). Apply redactUrlCredentialsInToken(token) to each argv token. Apply redactUrlCredentialsInText(text) to free text. Both replace the span between a token's first : and its last @ with URL_CREDENTIAL_PLACEHOLDER ([redacted]). The token rule is complete for every spelling a URL parser normalizes, and it is idempotent. The text rule sees only whitespace-delimited words, so a URL whose userinfo contains whitespace is complete only when you redact the original token before interpolating it into text. Do not echo a value you extracted from a raw token, such as a parsed password or a fragment cut at a delimiter. Build diagnostics from redacted tokens, or omit the extracted value. When a retry would lose credentials this way, withhold the retry instead of emitting one that runs a different command.

resolveLifecycleVerbRecovery(verb, availablePaths) returns explicit argument templates and next steps for unsupported block and unblock verbs. It checks the SDK's update flag contracts and declines installed verbs, unknown paths, and runtimes without update. Templates require actual identifiers and never execute a mutation. Unblocking uses --unset blocked-by to clear all blocker edges, followed by stale-reason cleanup and an optional explanatory note (pm-gh1607; tracker record excluded from npm).

Packages and adapters can derive explicit retries for misplaced linked-resource values with the public SDK:

import { resolveLinkedResourcePositionalRecovery } from "@unbrained/pm-cli/sdk";

const recovery = resolveLinkedResourcePositionalRecovery("docs", [
  "docs", "pm-example", "design, notes.md", "--json",
]);
// recovery.retryArgs retains the item and presentation flags, with an explicit
// --add value that preserves the literal comma through structured parsing.

The helper returns undefined for ambiguous input. When a positional test value contains metadata without its actual command, the receipt retains itemId and value and omits retryArgs. Its templateArgs retains the selected tracker and output options while requiring the actual-command placeholder to be filled. The adapter can request that command using the receipt. Explicit command= or cmd= entries retain their structured metadata; empty commands receive templates. Suggested test retries pass the same command-admission guard as registration; assignment/comment-only candidates receive command-required templates. runTestAll() retains these legacy configuration refusals as per-test results and continues valid tests and later items. Refused entries retain their owning item and result position; aggregate and per-item tracking still report failure. Linked tests containing only assignments or shell comments fail with linked_test_command_not_executable before registration or execution; quoted environment assignments and later-line real commands remain supported. The guard also recognizes assignment-only lists separated by ;, &&, ||, | or &, including a trailing semicolon. Quoted and escaped separators stay inside assignment values; lists containing executable commands remain supported.

Package commands should throw expected user/action errors with the public SDK shape so the CLI can preserve exit codes and Sentry can filter expected retry failures:

import { EXIT_CODE, createPmCliExpectedError } from "@unbrained/pm-cli/sdk";

throw createPmCliExpectedError("hello requires --name", {
  exitCode: EXIT_CODE.USAGE,
  context: {
    code: "missing_name",
    why: "The command needs a target name.",
  },
});

The helper returns an Error whose public name is PmCliError and whose exitCode is structural. That makes it safe for bundled, linked, and separately installed package code even when class identity is not shared with the running CLI.

For example, the SDK command layer settles relationship token counts after alias counts, read disclosure, encoding and optional lean/token-accounting policies. It converts an impossible rendered context budget into an expected invalid_argument_value usage error. The public PmTokenBudgetRecovery type and isPmTokenBudgetRecovery guard describe context.recovery.token_budget: requested tokens, a verified feasible retry value, and an exact minimum only when every smaller positive budget has been checked. Larger searches report feasible_only; query cost limits, exhausted searches or independent output constraints can report unavailable. Both states leave the unproved minimum null. The bounded search uses the real packer and output policy, including alias disclosure. The public type is a discriminated union: checking minimum_status narrows the verified budgets and requires a reason for unavailable. Package authors cannot declare unavailable advice with a numeric budget or omit its cause. The runtime guard additionally checks positive safe integers and equal budgets for exact; those numeric invariants cannot be expressed by the declaration alone. suggested_retry_args preserves the selected tracker, query and output options with the verified feasible budget so it works from another workspace. Retry probes consume canonical summary/full include modes before packing and retain only residual field selectors, matching the emitted CLI command. Recovery work estimates include repeated dangling-reference renders; summary probes count only their single rendering pass. Expensive queries return explicit unavailable budget advice and retain the compact inspection fallback. next_best_command retains a separate compact tree-summary inspection, which also supplies the fallback when no working context budget is established. CLI JSON and MCP preserve that recovery. The lower-level buildRelationshipContext primitive retains its TypeError validation contract; unrelated runtime errors still propagate. (Repository tracker: pm-odw3j8, pm-0bbc53, pm-k6uglj, pm-8jzcwv; tracker records are excluded from npm.)

For policy, lint, or verification commands that should render their full result and report failure without throwing, return a structured object instead:

return {
  ok: false,
  findings,
  exit_code: EXIT_CODE.CONFLICT,
  code: "policy_findings",
  remediation: "Resolve the reported findings and rerun the command.",
};

The CLI renders this payload unchanged, sets the process exit status, and propagates code and remediation through the command-handler result contract. Invalid exit_code values are rejected rather than truncated or silently treated as success.

Producer-owned diagnostics (context.details)

Tracked by pm-gh1631 (tracker record excluded from npm). Host fields such as code, required and recovery describe the refusal in the host's own vocabulary. Facts that belong to the package go in context.details, a plain JSON object the host never interprets:

import { createPmCliExpectedError } from "@unbrained/pm-cli/sdk/runtime";

throw createPmCliExpectedError("No changelog section for 2026.11.1", {
  exitCode: 1,
  context: {
    code: "release_notes_missing",
    details: {
      selection_report: { filters: { version: "2026.11.1" }, excluded: { status: 1 } },
    },
  },
});
  • Shape. Values are null, booleans, finite numbers, well-formed strings, arrays and plain objects, nested at most PM_CLI_ERROR_DETAILS_LIMITS.max_depth (8) containers deep. __proto__ keys and accessor properties are refused; getters are never run. createPmCliExpectedError throws a TypeError whose message names only the reason (unsupported_value, non_finite_number, malformed_unicode, reserved_key, depth_limit, not_plain_object); the offending path is on its non-enumerable path property, so crash reporting of that fault never carries your keys. A well-formed value is kept as a detached copy, so later changes to your object do not alter a raised refusal; an oversized one is replaced at construction by the host-set receipt context.details_omitted: { reason: "byte_limit", byte_limit }, so a later change cannot alter the refusal either. isPmCliErrorDetails(value) performs the same check.
  • Rendering. --json errors carry the object as details, text output renders it as an indented JSON Details: block, and MCP tool errors carry it inside the forwarded context. Diagnostics are rendered whole or not at all: above PM_CLI_ERROR_DETAILS_LIMITS.max_bytes (4,096 bytes of compact UTF-8 JSON), or when a separately bundled error carries a malformed shape, the envelope carries details_omitted: { reason, path?, byte_limit } instead. Admitted details that would push the pretty-printed --json envelope past the error diagnostic budget are replaced the same way, with reason diagnostic_budget. The size is counted while walking and the walk stops at the ceiling, so shared or repeated structures cannot amplify the work; path is capped at 160 bytes. The CLI and MCP read a separately bundled error's context once as plain data (snapshotPmCliErrorContext): getters are never run, and a context that cannot be inspected becomes empty. MCP admits details from that snapshot with the same bounded walk before serializing anything, then serializes the remaining host fields once (a context-level toJSON is ignored), so no serializer can expand or replace the diagnostics; a context JSON cannot encode (for example a bigint member) is forwarded as { code, context_unserializable: true } so the response is still written. When the text output exceeds its token ceiling, the text renderer drops the explanation (why) first and then the Details: block (together with the example and next-step limits). A block dropped this way gets no Details omitted: line, only the generic notice that points to --json, which keeps the details. Custom SDK hosts apply the same bound with projectPmCliErrorDetails(value) (returns { details }, { details_omitted } or undefined) or, for a context forwarded as-is, projectPmCliErrorContextDetails(context).
  • Privacy. Telemetry and Sentry never receive details. The telemetry classification is built without it. A crash report carries an error context only as its top-level primitive fields, read without running getters or toJSON, so diagnostics cannot reach it under another key: this applies to error_context and to the error-data entry, which is rebuilt whole from the original exception (its own primitive fields plus the safe context), because the integration serializes the error, including any error-level toJSON, before beforeSend. Serialized copies whose original cannot be recovered are discarded: embedded contexts in console-capture data, breadcrumbs and transactions are sent as empty objects, and complete serialized console arguments are sent as empty arrays, because an error's own toJSON can move diagnostics out of any named slot. The custom-host projections drop a context-level toJSON for the same reason.

Package Runtime Imports

Third-party packages should import from stable public SDK subpaths:

import { defineExtension } from "@unbrained/pm-cli/sdk";
import { createPmCliExpectedError } from "@unbrained/pm-cli/sdk/runtime";

PM_CLI_PACKAGE_ROOT is reserved for first-party packages bundled inside this repository. External packages must not depend on PM_CLI_PACKAGE_ROOT, dist/ paths, or src/core/...; declare @unbrained/pm-cli as a dependency or peer dependency and import the public SDK subpaths instead. Every copied extension is linked to the exact running host package at node_modules/@unbrained/pm-cli, so standard imports from @unbrained/pm-cli/sdk, /sdk/runtime, and /sdk/testing resolve without downloading a second CLI copy into the project.

Authoring Builders

Tracked: pm-12tj (design rationale: ADR pm-3mph); exactness and literal inference: pm-gh1539. These repository-only tracking records are excluded from npm.

The define* builders are the authoring half of the author → register → test loop: they type a registration definition where you write it, before it ever reaches api.register*. Each is a zero-cost identity function (it returns its argument unchanged), exactly like defineExtension and the wider defineConfig/defineComponent ecosystem convention — the value is entirely at the type level.

pm packages are authored and loaded as TypeScript (ADR pm-2c28 / pm-m1uz). A bare const cmd = { ... } satisfies the registration types only structurally and widens its literals; wrapping it in a builder checks the object against the contract and preserves the narrow literal types, while inferring the nested handler's context parameter from the builder signature — the same ergonomics defineConfig gives a Vite config. It also lets you colocate, export, reuse, and unit-test a definition apart from activate:

import { defineCommand, defineAfterCommandHook } from "@unbrained/pm-cli/sdk";
import type { ExtensionApi } from "@unbrained/pm-cli/sdk";

// `context` is inferred from the builder signature; the literal name/action are preserved.
export const greetCommand = defineCommand({
  name: "greet hello",
  action: "greet-hello",
  description: "Say hello.",
  run: (context) => ({ greeting: `hi ${context.args[0] ?? "world"}` }),
});

export const auditHook = defineAfterCommandHook((context) => {
  if (!context.ok) return;
  // react to context.affected — "project management = context management"
});

export function activate(api: ExtensionApi): void {
  api.registerCommand(greetCommand);
  api.hooks.afterCommand(auditHook);
}

Object-definition builders (defineExtensionManifest, defineCommand, defineFlag, defineItemType, defineItemField, defineMigration, defineSearchProvider, defineVectorStoreAdapter, defineProjectProfile) use const type parameters to preserve the narrow literal type. defineExtensionManifest additionally contract-checks the in-module manifest mirror where it is authored and pairs with deriveExtensionCapabilities (see Declarative Authoring). Function-definition builders (defineCommandOverride, defineParserOverride, definePreflightOverride, defineServiceOverride, defineRendererOverride, defineImporter, defineExporter, and the five hook builders defineBeforeCommandHook / defineAfterCommandHook / defineOnWriteHook / defineOnReadHook / defineOnIndexHook) are non-generic so a bare arrow's parameter is contextually typed instead of falling back to any. The assertRegistered* helpers below verify these same definitions once registered.

The object builders reject unknown top-level keys, including command, manifest, and profile definitions and separately authored variables. This catches misspellings without changing the runtime registry's storage boundary. Keep package-specific annotations beside the registration definition. Existing values already typed as a registration contract remain usable; a builder cannot recover literal information that was widened before the call. Flag builders support repeatable and value_validation; schema builders include their supported default/values and item-type description/default-status fields. Scoped renderer and preflight object builders also retain literal command ownership while contextually typing their callbacks.

Declarative Authoring

Tracked: pm-iqq0.

composeExtension is the capstone of the author → register → test loop. Instead of hand-wiring each api.register* call inside an imperative activate(api) body — calling the right method, in the right order, without forgetting one — describe what to register as a plain ExtensionBlueprint object and let the SDK generate the activate for you. Every field is optional; populate the surfaces you use (ideally with define*-authored definitions) and leave the rest out:

import {
  composeExtension,
  defineCommand,
  deriveExtensionCapabilities,
} from "@unbrained/pm-cli/sdk";
import type { ExtensionBlueprint } from "@unbrained/pm-cli/sdk";

const echo = defineCommand({
  name: "command-kit echo",
  action: "command-kit-echo",
  description: "Echo a message as structured output.",
  run: (context) => ({ message: context.args.join(" ") }),
});

const blueprint: ExtensionBlueprint = {
  commands: [echo],
  parsers: { "command-kit echo": (context) => ({ options: context.options }) },
  flags: {
    list: [{ long: "--kit-note", value_type: "string", value_name: "text" }],
  },
};

// The generated `activate` registers commands → overrides → flags → parsers →
// renderers → services → preflights → item types → item fields → migrations →
// search providers → vector store adapters → importers → exporters → hooks, then
// awaits any imperative `activate` you also pass (an escape hatch run last).
export default composeExtension(blueprint);

deriveExtensionCapabilities(blueprint) returns the exact least-privilege capability set the blueprint exercises (sorted, de-duplicated), so you can author manifest.json capabilities with zero declared-but-unused or used-but-undeclared drift. It is the author-time inverse of the runtime reconcileExtensionCapabilityUsage check, and the set it returns is the set composeExtension's generated activate requires — they agree by construction:

deriveExtensionCapabilities(blueprint); // ["commands", "parser", "schema"]

The blueprint's record-keyed fields (commandOverrides, flags, parsers, renderers, services) map a routing key to its handler, mirroring the two-argument api.register* overloads; hooks groups the five lifecycle kinds. Renderer entries may be a legacy callback or a scoped { commands, resultDiscriminator?, run } definition. The composed activation passes that ownership to the host, and describeExtensionBlueprint reports the same renderer_ownership metadata as the live activation summary. The array-valued relationshipKinds field registers application-defined graph semantics and contributes the schema capability just like itemTypes, itemFields, and profiles. composeExtension is a pure assembler: it does not validate definitions — per-surface contract enforcement stays in api.register* and the loader, so a malformed definition surfaces the same activation diagnostic as a hand-written activate. The bundled first-party packages intentionally keep import-free hand-written activate bodies so they load in extension-only installs; reach for composeExtension in npm package-mode authoring where the SDK is a dependency.

For a generated starting point, pm package init <path> --declarative scaffolds this loop end to end for any --capability: an index.ts that authors a defineExtensionBlueprint blueprint (the capability's surfaces wired through the define* builders) and exports composeExtension(blueprint), plus an index.test.ts that guards it with the author-time assertExtensionPreflight capstone and exercises the composed module through createExtensionTestHarness. It is package-mode only (composeExtension is a runtime SDK value import, so it belongs in package-mode authoring where the SDK is a linked dependency, not the import-free extension-only starters). The generated test imports manifest.json with JSON import attributes and calls assertExtensionManifestMatchesBlueprint, while its emitted TypeScript config enables resolveJsonModule; a capability edit therefore cannot leave the checked-in manifest stale.

Modular blueprints

Tracked: pm-high, pm-nvgy.

A large extension's surface does not have to live in one object. mergeExtensionBlueprints(...blueprints) combines several partial blueprints into one — a commands module, a search module, a hooks module — so each concern is authored (and tested) in its own file and assembled at the entry point. Wrap each fragment in defineExtensionBlueprint(...) so it is contract-checked at its own definition site (with editor completion) — the blueprint-level companion to defineExtension (a whole module) and defineExtensionManifest (a manifest):

// commands.ts
import { defineExtensionBlueprint } from "@unbrained/pm-cli/sdk";
export const commandsModule = defineExtensionBlueprint({
  commands: [{ name: "kit run", action: "kit-run", run: () => ({ ok: true }) }],
});
// index.ts — the manifest entry; import sibling .ts modules by their real extension (loaded directly via native type stripping).
import {
  composeExtension,
  mergeExtensionBlueprints,
} from "@unbrained/pm-cli/sdk";
import { commandsModule } from "./commands.ts";
import { searchModule } from "./search.ts";

export default composeExtension(
  mergeExtensionBlueprints(commandsModule, searchModule),
);

The merge is pure, deterministic, and never mutates an input. Each surface combines the way its api.register* call composes: array surfaces (commands, itemTypes, migrations, searchProviders, importers, …) concatenate in order; flags concatenates the flag arrays of a shared target command; single-handler records (commandOverrides, parsers, renderers, services) take last-defined-wins precedence on a key collision; hooks concatenate per lifecycle kind; imperative activate hatches chain forward (acquisition order) while deactivate hooks chain in reverse (LIFO teardown); the manifest mirror is last-defined-wins. Because the result is an ordinary blueprint, every downstream helper (deriveExtensionCapabilities, describeExtensionBlueprint, lintExtensionBlueprint, preflightExtension) reads it exactly as it would a hand-written one — a command two modules both define survives as a duplicate and lintExtensionBlueprint flags it. Merging zero blueprints returns an empty blueprint ({}).

Generate the manifest (author once)

Tracked: pm-u5le.

deriveExtensionCapabilities gives you only the capability set; every other manifest field is still yours to hand-write. synthesizeExtensionManifest(blueprint, identity) closes that gap — it is the generate verb that completes the declarative loop (compose → derive → describe/lint → synthesize). Supply the identity fields a blueprint cannot determine (name, version, entry, priority, plus any optional engines/permissions/version floors/etc.) and it returns a complete ExtensionManifest with capabilities derived, sorted, and de-duplicated. Write the blueprint once; never hand-sync capabilities again:

import { synthesizeExtensionManifest } from "@unbrained/pm-cli/sdk";

const manifest = synthesizeExtensionManifest(blueprint, {
  name: "command-kit",
  version: "1.0.0",
  entry: "./index.ts",
  priority: 0,
});
manifest.capabilities; // ["commands", "parser", "schema"] — derived, not hand-written

Where defineExtensionManifest only types a manifest you wrote by hand, this generates it. For the rare surface registered through the imperative activate escape hatch (invisible to static derivation — e.g. a renderer wired in activate), pass additionalCapabilities and they are unioned in (legacy-alias resolved, unknown names dropped). Use the result as the on-disk manifest.json content or the in-module manifest mirror; guard a hand-maintained manifest against drift with assertExtensionManifestMatchesBlueprint (below).

Ship both halves (author once)

Tracked: pm-cn0c.

composeExtension produces the runtime module; synthesizeExtensionManifest produces the manifest. composeExtensionPackage(blueprint, identity) is the author-once capstone that returns both halves of a shippable package from one call, with the synthesized manifest set as the module's authoritative in-module mirror — so the runtime module and the on-disk manifest.json are generated from one source and cannot drift:

import { composeExtensionPackage } from "@unbrained/pm-cli/sdk";

const { module, manifest } = composeExtensionPackage(blueprint, {
  name: "command-kit",
  version: "1.0.0",
  entry: "./index.ts",
  priority: 0,
});
export default module; // the package entry's default export
// write `manifest` verbatim as manifest.json — capabilities derived, never hand-synced

It is a pure assembler (no validation, loading, or filesystem access), exactly like the two functions it composes; pair it with preflightExtension / assertExtensionPreflight for the author-time verify step. Combined with mergeExtensionBlueprints, the full declarative loop is: author each concern with define*, assemble them modularly with mergeExtensionBlueprints, then ship both halves with composeExtensionPackage.

Author-time introspection and preflight

Tracked: pm-tlpv, pm-9ect, pm-4oio.

Two pure, no-activation helpers complete the loop, so a blueprint is fully inspectable and verifiable before it is ever loaded — the author-time inverse of the runtime guardrails (the same discipline as deriveExtensionCapabilities inverting reconcileExtensionCapabilityUsage):

import {
  describeExtensionBlueprint,
  lintExtensionBlueprint,
} from "@unbrained/pm-cli/sdk";

// describeExtensionBlueprint returns the same ExtensionActivationSummary shape as
// the runtime describeExtensionActivation — but from the blueprint data alone, no
// activation. It is to the named surfaces what deriveExtensionCapabilities is to
// the capability set.
describeExtensionBlueprint(blueprint).command_handlers; // ["command-kit echo", ...]

// lintExtensionBlueprint preflights for the footguns activation would otherwise
// surface late: a capability a surface exercises but the manifest omits is an
// `error` (the loader throws extension_capability_missing); a declared-but-unused
// capability, a duplicate command, a command/override conflict, and a present-but-
// empty surface are `warning`s. Pass declaredCapabilities or set manifest.capabilities.
const report = lintExtensionBlueprint(blueprint, {
  declaredCapabilities: ["commands", "parser", "schema"],
});
report.ok; // false if any error-severity finding
report.findings; // [{ code, severity, message, capability?/command?/field? }, ...]

Both read only the declarative data, so the imperative activate escape hatch is invisible to them — a blueprint that registers everything through that hatch summarizes as empty and lints clean. In a package test, assertExtensionBlueprint (below) turns the lint into a one-line CI guard.

Testing Helpers

Use @unbrained/pm-cli/sdk/testing for package assertions and runtime invocation. The aggregate SDK also exports these helpers. Assertions normalize names, return the matched entry, and throw with the available registrations when a match is missing. Named expectations use { name }; historical surface-specific aliases remain supported. Optional extensionName limits a match to one extension. The published surface snapshot contains the complete helper signatures.

Output codecs are tracked by pm-gh1462. decodeToon and encodeToon use the exact TOON implementation shipped by the host. They are exported from /sdk/testing and /sdk, with ToonDecodeOptions, ToonEncodeOptions and ToonJsonValue types. Compare decoded values instead of matching spaces or quoted scalars, and retain strict decoder errors for malformed output:

import assert from "node:assert/strict";
import { decodeToon, encodeToon } from "@unbrained/pm-cli/sdk/testing";

const stdout = "status: open\n"; // Replace with captured host output.
assert.deepEqual(decodeToon(stdout), { status: "open" });
assert.deepEqual(decodeToon(encodeToon({ enabled: false })), { enabled: false });

Prefer createExtensionTestHarness when testing a package. Its bound methods select the correct registry, preventing accidental use of a command registry for hooks or services:

import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing";

const ext = await createExtensionTestHarness(extensionModule, {
  capabilities: ["commands", "schema"],
});
try {
  ext.assertCommandContract({ name: "hello", action: "hello", flags: ["--name"] });
  const invocation = await ext.runCommand({
    command: "hello", options: { name: "ada" },
  });
  // invocation.handled and invocation.result are the real dispatch receipt.
} finally {
  await ext.deactivate();
}

activateExtensionForTest is the lower-level in-memory activation primitive. It uses the real capability guardrails, without filesystem discovery or package installation. Keep pm package doctor and runtime contract checks in integration tests: registration shape alone does not prove an install works.

Author-time checks run without activation:

  • assertExtensionBlueprint returns lintExtensionBlueprint findings and throws on errors; undeclared capabilities block, while unused capabilities remain advisory.
  • assertExtensionManifestMatchesBlueprint requires the manifest capability set to match exactly, failing for both missing and unused declarations. It reconciles capabilities only, the field the blueprint determines.
  • checkExtensionManifestCompatibility returns per-bound findings against a target pmVersion. assertExtensionManifestCompatible throws for malformed bounds, an unmet minimum, or a maximum exceeded in block mode; unchecked and exceeded-warning outcomes remain advisory.
  • lintExtensionManifestSchema inspects vocabulary alone, with stable manifest_unknown_key and no_version_bounds_declared findings.
  • preflightExtension composes blueprint lint, optional identity-based least-privilege manifest synthesis, and optional target compatibility. Its report retains the original stage results plus source-tagged findings. assertExtensionPreflight throws for every blocking finding and returns the report otherwise. Without an identity, target checks use the blueprint's manifest mirror.
import { assertExtensionPreflight } from "@unbrained/pm-cli/sdk/testing";

const report = assertExtensionPreflight(blueprint, {
  identity: { name: "command-kit", version: "1.0.0", entry: "./index.ts", priority: 0 },
  target: { pmVersion: "2026.10.11" },
});

When a custom harness needs individual registries, use this ownership map:

Surface Registry from activateExtensionForTest Assertion / invocation
Commands, schema types/fields, migrations, importers/exporters, search/vector providers registrations assertRegisteredCommandContract, corresponding assertRegistered*
Command overrides commands assertRegisteredCommandOverride, runRegisteredCommandOverrideForTest
Parser overrides parsers assertRegisteredParserOverride, runRegisteredParserOverrideForTest
Preflight overrides preflight assertRegisteredPreflightOverride, runRegisteredPreflightOverrideForTest
Renderers renderers assertRegisteredRendererOverride, runRegisteredRendererOverrideForTest
Services services assertRegisteredServiceOverride, runRegisteredServiceOverrideForTest
Lifecycle hooks hooks assertRegisteredHook, runRegisteredHookForTest

runRegisteredCommandForTest uses the real dispatcher and returns CommandHandlerResult. Importer/exporter invocation helpers accept the whole activation plus a registration name, constructing the command path themselves. Override invocation helpers first require a matching registration, then return the real runner result. Hook expectations use lifecycle kind; a clean hook run returns no warnings, while a thrown hook contributes extension_hook_failed:* and other hooks continue. Preflight overrides are global. Renderer expectations use format and service expectations use a supported service name. assertRegisteredMigration matches migration id and can require mandatory; an unset flag is non-mandatory. Schema and vector-adapter assertions prove registrations without private imports, generated schema reads or live services.

Project profiles (registerProfile)

Tracked: pm-08sv.

A project profile is the broadest customization primitive a package can ship: one declarative ProjectProfileDefinition that bundles item types, custom statuses, fields, per-type workflows, config knobs, create templates, and package recommendations into a single archetype pm profile apply stages idempotently. The three core archetypes (agile/ops/research) are baked in; a package adds its own with api.registerProfile(profile) under the schema capability:

import { defineProjectProfile, type ExtensionApi } from "@unbrained/pm-cli/sdk";

export const kanbanProfile = defineProjectProfile({
  name: "kanban",
  title: "Kanban continuous flow",
  summary: "WIP-limited flow with a verifying stage.",
  types: [{ name: "Card", folder: "cards" }],
  statuses: [{ id: "doing", roles: ["active"] }],
  fields: [
    { key: "wip_limit", type: "number", commands: ["create", "update"] },
  ],
  workflows: [{ type: "Card", allowed_transitions: [["open", "doing"]] }],
  config: [
    {
      key: "search_provider",
      value: "bm25",
      summary: "Offline lexical search.",
    },
  ],
  templates: [{ name: "card", options: { type: "Card" } }],
  packages: [{ spec: "templates", reason: "Reusable card shapes." }],
});

export function activate(api: ExtensionApi): void {
  api.registerProfile(kanbanProfile);
}

Once the package is active, the profile resolves by name through pm profile list (labelled with its source package), pm profile show <name>, and pm profile apply <name> — exactly like a core archetype, with no consumer code. Built-in names are reserved: a registered profile that collides with a core name (or another package's profile) is ignored with a warning rather than shadowing it. Profiles flow through the declarative loop too — composeExtension({ profiles: [...] }) auto-wires registerProfile, and deriveExtensionCapabilities maps a profiles surface to schema. Prove a profile registered with assertRegisteredProfile:

import { assertRegisteredProfile } from "@unbrained/pm-cli/sdk/testing";

const { profile } = assertRegisteredProfile(activation.registrations, {
  name: "kanban",
});
// profile is the normalized ProjectProfileDefinition

Together these complete the SDK assertion surface: every extension register* method (including registerProfile) now has a matching assertRegistered* helper, so packages can prove any registration without importing private registry internals.

The three executable registration surfaces add runRegistered*ForTest invoke helpers on top of those assertions, so a package can exercise the real behavior of a custom provider, adapter, or migration:

import {
  runRegisteredSearchProviderForTest,
  runRegisteredVectorStoreAdapterForTest,
  runRegisteredMigrationForTest,
} from "@unbrained/pm-cli/sdk/testing";

// Invoke a registered provider's semantic query (or embed / embedBatch /
// queryExpansion / rerank); the result type follows `operation`.
const hits = await runRegisteredSearchProviderForTest(
  activation.registrations,
  {
    provider: "semantic-local",
    operation: "query",
    context: {
      query: "calendar",
      mode: "semantic",
      tokens: ["calendar"],
      options: {},
      settings,
      documents,
    },
  },
);

// Invoke a registered adapter's upsert / query / delete.
await runRegisteredVectorStoreAdapterForTest(activation.registrations, {
  adapter: "pinecone",
  operation: "upsert",
  context: { points: [{ id: "pm-1", vector }], settings },
});

// Invoke a registered migration's run with a host-shaped context.
await runRegisteredMigrationForTest(activation.registrations, {
  migration: "backfill-severity",
  pmRoot,
});

The bundled pm-lifecycle-hooks package is the first-party hooks exemplar. It declares only the hooks capability and registers a default-inert afterCommand hook, so package authors can copy a lifecycle pattern that does not write files, produce output, or alter command behavior.

The bundled pm-governance-audit package is the governance hook exemplar. It combines package-owned commands with onRead and onWrite hooks, declares the hooks capability, and only writes a compact JSONL sidecar when PM_GOVERNANCE_AUDIT_HOOK_LOG is set. Use that pattern for audit/cache/telemetry packages that need file-level context without storing item bodies by default.

afterCommand receives the command outcome plus an optional affected array for item mutations. Each affected entry is a compact command context: id, op, item_type, previous_status, status, changed_fields, and partial previous/current item metadata snapshots. Use this for transition-aware packages such as notifications; do not parse the untyped result payload when the transition fields are available.

onWrite receives { path, scope, op } for every observed write. When the write is tied to an item mutation, the context also includes item_id, item_type, before, after, and changed_fields, so sync packages can mirror the exact item change without reparsing files. Non-item writes omit those item fields. changed_fields lists mutated fields for updates and uses lifecycle sentinels for item lifecycle writes: ["imported"] for package imports, ["restored"] for restores, and ["deleted"] for deletes.

Custom Item Type

import { defineExtension } from "@unbrained/pm-cli/sdk";

export default defineExtension({
  activate(api) {
    api.registerItemTypes([
      {
        name: "Incident",
        folder: "incidents",
        aliases: ["incident"],
        required_create_fields: ["title", "description", "severity"],
        options: [
          {
            key: "severity",
            values: ["critical", "major", "minor"],
            required: true,
          },
          { key: "service", values: ["api", "web", "worker"] },
        ],
      },
    ]);

    api.registerItemFields([
      { name: "severity", type: "string" },
      { name: "service", type: "string", optional: true },
    ]);
  },
});

Manifest capability: schema.

Declared item fields are first-class create/update inputs. Agents and importers can persist extension provenance without description markers:

pm create "Import issue" --type Incident --field service=api --field severity=critical
pm update pm-1234 --field service=worker

--field accepts only fields declared by active registerItemFields registrations and coerces values using the declared field type.

Importer / Exporter

registerImporter(name, importer) and registerExporter(name, exporter) register a data adapter and automatically create a <name> import / <name> export command path that invokes it. The handler receives an ImportExportContext (registration, action, command, args, options, global, pm_root, portable source_workspace_root / repo_root / pm_root_rel coordinates, and the host-bound sdk service bundle). Importers can therefore compose native PM actions and relationship stores without spawning the CLI or recursively activating packages.

By default the auto-created command only has a handler. Pass an optional third ImportExportRegistrationOptions argument to make it a first-class command with a description, flags, intent, examples, failure hints, and positional arguments — surfaced in --help and runtime contracts exactly like registerCommand:

When a package also registers a rich canonical nested command such as csv export, the compatible flattened adapter path (csv-export export) inherits that canonical description, arguments, and flags. Both help surfaces and option parsing therefore stay identical without duplicating metadata.

import { defineExtension } from "@unbrained/pm-cli/sdk";

export default defineExtension({
  activate(api) {
    api.registerImporter(
      "jsonl",
      async (context) => {
        // context.options.file, context.global, context.pm_root, ...
        return { ok: true, imported: 0 };
      },
      {
        action: "jsonl-import",
        description: "Import JSONL records into pm items.",
        intent: "ingest external task records",
        examples: ["pm jsonl import --file source.jsonl"],
        failure_hints: ["Verify the JSONL source path exists."],
        flags: [
          {
            long: "--file",
            value_name: "path",
            value_type: "string",
            description: "Path to the JSONL source file.",
          },
        ],
      },
    );

    api.registerExporter("jsonl", async () => ({ ok: true }), {
      description: "Export pm items to JSONL.",
    });
  },
});

Manifest capability: importers (and schema when supplying flags). The two-argument form remains supported; supplying the options object never produces a command-handler collision because the definition and handler share the same command path and extension.

Importers and exporters read their source/destination through flags (e.g. --file, --folder) and take no positional argument unless one is declared via arguments. An unexpected positional (such as pm jsonl import data.jsonl instead of pm jsonl import --file data.jsonl) is rejected with a usage error rather than being silently ignored, and any failure_hints you register are appended to that error so an agent is steered to the correct flag. Flags declared via flags render once, as first-class options in the standard Options: section of --help.

The bundled pm-beads and pm-todos packages are first-party importer/exporter exemplars that use this registration path and expose runtime contracts for their generated commands.

Search Provider

import { defineExtension } from "@unbrained/pm-cli/sdk";

export default defineExtension({
  activate(api) {
    api.registerSearchProvider({
      name: "example-search",
      async query(context) {
        return context.documents
          .filter((doc) =>
            doc.metadata.title
              ?.toLowerCase()
              .includes(context.query.toLowerCase()),
          )
          .map((doc) => ({
            id: doc.metadata.id,
            score: 0.5,
            matched_fields: ["title"],
          }));
      },
    });
  },
});

Manifest capability: search.

Core search invokes the registered query when settings.search.provider matches the provider name. The bundled pm-search-advanced package ships a working first-party exemplar: searchAdvancedLocalProvider() registers a deterministic, dependency-free local lexical ranker named search-advanced-local (enable with pm config set search.provider search-advanced-local). Authors building embedding-backed providers (for example Ollama or a hosted model) implement embed/embedBatch on the same SearchProviderDefinition shape, and may also registerVectorStoreAdapter for a custom vector store.

Optional advanced relevance hooks:

  • queryExpansion (or query_expansion) for search.query_expansion.provider
  • rerank for hybrid rerank candidates when search.rerank.enabled=true

Both hooks are best-effort. If a hook throws or returns an invalid shape, core search degrades gracefully and emits warning codes instead of hard-failing.

Completion-aware reporting and migration

Close provenance is tracked by pm-gh1579 (a repository-only tracker record excluded from npm). closeItem, PmClient.close, CLI/MCP close receipts and successful closeMany rows return completion with the persisted timestamp and source: asserted means this close supplied completedAt, existing preserves an earlier metadata value, and close_time means completion was inferred from the tracker close time. Timestamp and source are sealed in the close event's context.completion; the local correction hint remains in the response. Existing records without it have unknown provenance; existing does not assert how an earlier value was obtained. No prior events are rewritten. Inferred receipts include a tracker-bound update --completed-at correction hint. Pass completedAt (CLI --completed-at) when closing delivered work retroactively; core never extracts a delivery date from reason prose.

resolveCompletionTimestamp(item) gives reports one canonical timestamp plus a source and fallback disclosure. It prefers completed_at (when the work actually finished), then legacy closed_at, then updated_at.

planCompletedAtBackfill(items, historyById, terminalStatuses) returns only legacy terminal items whose latest transition into a terminal status is proven by a valid history event. It never guesses from updated_at; items without history evidence stay explicitly absent. Apply returned candidates through the normal typed update action with completedAt so every correction writes item history and retains mutation governance.

Robust Automation Pattern

Native PmClient.create, update, and their generic run routes reject unknown option names at runtime, including JavaScript callers and injected extension clients (pm-fl3phm, a repository-only tracker record excluded from npm). A typo such as priorty refuses the whole request with PmCliError.code = "invalid_argument_value", context.reason = "unknown_mutation_option", a nearest-name hint when available, and context.changed = false. Put mutation fields under options when using run; existing declared top-level aliases remain accepted. Workspace schema option identities remain available through run, while typed convenience methods support extension item fields through field. MCP retains its existing transport-specific admission contract. The no-change receipt applies to these preflight refusals; an executed operation's outcome and evidence must be assessed separately.

MCP create/update normalize their advertised mutation fields through the SDK and refuse malformed values atomically; see MCP option conformance. Embedded hosts can use readRuntimeFieldOptionValue(options, definition) with definitions from resolveRuntimeFieldRegistry(settings.schema) to recognize the same project-field aliases as persistence. These helpers are exported from the aggregate SDK, so hosts can preserve customization without copying key mappings.

  1. Read PM_TOOL_ACTIONS or PM_TOOL_PARAMETERS_SCHEMA for baseline static validation.
  2. Load runtime contracts with getContracts(pmRoot, { runtimeOnly: true }) or run pm contracts --runtime-only --json inside the target project.
  3. Verify the action appears in actions and has action_availability[].invocable: true.
  4. Validate required fields with PM_TOOL_ACTION_PARAMETER_CONTRACTS for static actions or the runtime schema for package actions.
  5. Execute only after preflight passes.

Runnable examples:

CLI Simplification Migration

The conservative full-surface simplification pass updated invocation parsing and error envelopes. Integration details are documented in CLI Simplification Migration.

For SDK and automation consumers, the key runtime change is the optional recovery object in CLI usage/error JSON payloads:

  • attempted_command
  • normalized_args
  • provided_fields
  • missing
  • allowed_values
  • candidate_commands
  • candidate_commands_total
  • candidate_commands_truncated
  • suggested_retry
  • suggested_retry_args (pass these argument tokens to the pm executable instead of reparsing display text)

Treat recovery.suggested_retry as the first-choice deterministic replay command when present. Generic missing-option retries preserve every original argv token, insert only absent real flags before the first -- argument terminator while leaving that terminator in place, and derive placeholders from the declared flag domain (<off|warn|strict> for enum values and no placeholder for booleans). It is emitted only from structured recovery metadata or flags declared on the invoked command; prose that merely mentions another flag is not interpreted as a missing input. Strict close validation reports missing resolution fields first and, when replay is possible, displays the same close with evidence placeholders. Replace those placeholders with actual evidence so one write records both the evidence and closure.

recovery.allowed_values declares a complete string domain, including empty or whitespace-bearing choices. The text renderer quotes empty, whitespace-bearing or separator-containing choices and escapes embedded control characters. If an untyped producer supplies malformed entries or a non-array domain, the host reports usable strings in next_steps with an explicit incomplete-domain notice; it does not advertise that malformed domain as complete. Package authors must fix the producer metadata. Inferred choices and retries from message prose are withheld whenever the producer already declares its domain. Text compaction retains the original domain count when rendering fewer choices. A close retry too large for text keeps the producer's bounded fallback or identifies the complete original SDK retry; the host does not invent an equivalent command. Budgeted CLI JSON can compact duplicate recovery values while retaining refusal.legal_domain when the complete domain fits once. Larger domains remain available in the original SDK error context; the CLI diagnostic ceiling is independent of the ordinary command output budget.

Recovery help examples resolve their command through resolveRecoveryCommandName, which uses the same bootstrap grammar as the CLI. Global value flags such as --pm-path <path> may appear before or after the subcommand; their values are never exposed as inferred command names or help targets.

Unknown-option envelopes rank command paths that accept the rejected flag by shared vocabulary and include explicit total/truncation metadata. They are contract-discovery hints, not permission to change the requested operation. Unknown positional subcommands expose the complete allowed_values vocabulary and use suggested_retry only when a deterministic nearby value exists.

Tracked by pm-f05lsg and pm-test-measure-prerequisite: resolveMisnestedCommandPath derives a canonical path from the installed command registry only when an exact suffix under a known namespace has one owner. For example, pm item update remains refused and suggests pm update --help. Nested refusals retain every registered namespace segment and prefer the longest matching suffix. A shorter suffix is considered only across registered prefixes; an ambiguous longer match never falls back to a guessed shorter command. Inspection does not execute the guessed mutation, and ambiguous or unavailable extension routes receive the usual bounded discovery guidance. Canonical help retry arguments and the first displayed example retain an explicitly selected --pm-path and --no-extensions, so inspection uses the same tracker and extension policy from any working directory. In-process callers of runPmCli(args) do not need to replace process.argv: usage and help recovery use the supplied invocation for command selection, tracker scope, extension policy and both text and JSON formatting. Eval hosts with no process.argv[1] also work: the CLI supplies an internal script label and preserves the host's argument array and exit status (pm-mor9pl).

runTest(id, { measure: [...] }, global) requires run: true. A missing run returns test_measure_requires_run before linked-test mutation, with an executable retry retaining the tracker, measurements, selectors, runtime directives, and explicit actor. It adds no ownership or trust override. When the request also acknowledges trust, recovery opens help and supplies next_steps: review the linked commands, acknowledge them in a separate non-executing operation, then run the measurement without the acknowledgement flag. Foreground and background requests use the same prerequisite guard; background recovery retains --background. Package authors can call the exported assertTestMeasurementRun(id, options, global, resolvedPmRoot, background?) before dispatching their own linked-test runner. It validates only this prerequisite and performs no I/O; runTest applies the guard automatically before other execution and trust validation.

runTest also verifies execution before recording measurements. Empty or entirely skipped results, pre-execution refusals, failed process creation and recognised empty test-runner output return test_measure_requires_execution with a scoped read-only list retry. Empty summaries in either output stream refuse even when the command exits unsuccessfully, preserving stored measurements and history. A mixed run with an executed command can retain measurements; an executed failing command records a failed run alongside its measurements. Requested link edits remain separate from the resulting measurement evidence.

Authoring Pattern

  • Keep handlers deterministic and JSON-like.
  • Return data, not pre-rendered terminal text, unless implementing a renderer or output service.
  • After directly writing streaming, binary, or pre-rendered output, return suppressHostOutput() to make output ownership explicit and prevent duplicate CLI rendering.
  • Keep service, renderer, and preflight overrides narrow. For output_format, return { handled: false } or declineServiceOverride() for unrelated commands; for renderers, return null when the payload should fall back to native rendering.
  • Declare only capabilities in use.
  • Set pm_min_version when the package requires SDK or runtime behavior added after older pm releases.
  • Declare @unbrained/pm-cli as an ordinary stable peer range (the scaffold uses >=<pm_min_version>). Do not pin or target historical YYYY.M.D-N ordinals: SemVer treats those as prereleases, so normal first-party stable ranges deliberately exclude them while accepting later daily YYYY.M.D releases.
  • Include examples and failure hints in dynamic commands.
  • Add pm package doctor diagnostics to testing instructions.

From docs/SDK.md in pm 2026.11.13. View on GitHub