Deterministic product-surface and capability mapping for software repositories. pmap extracts what a repository exposes today, accepts planned manifests from a design process, and derives maps, diffs, Markdown summaries, and reconciliation guidance without inventing relationships.
The model is (surface | capability) × (existing | planned | derived). Canonical JSON manifests live under docs/reference/product-map/, carry content hashes, and can be byte-checked for freshness in CI.
Point it at shadcn-ui/ui and pmap digest prints the repository's product surface in a few hundred tokens, leading with paths — a live extraction, with no setup in the target repository:
# shadcn-ui — product map (pmap 0.1.1 @ 4baadbc6, clean)
3 surfaces · 24 capabilities · 22 bound · 2 capability-unbound · 1 surface-unbound
SURFACES
surface:cli:shadcn packages/shadcn live -> 14 bound
surface:dashboard:v4 apps/v4 live -> 8 bound
surface:mcp:shadcn packages/shadcn live UNBOUND
CAPABILITIES
command shadcn add, shadcn apply, shadcn build, shadcn diff, shadcn docs, shadcn eject … (13) packages/shadcn (1)
package @shadcn/helpers, @shadcn/react, shadcn (3) packages/helpers, packages/react, packages/shadcn (3)
route /api/search, /init, /init/md, /init/v0, /llm/slug-catchall, /r/registries.json … (8) apps/v4 (1)
GAPS
capability-unbound cap:package:helpers
capability-unbound cap:package:react
surface-unbound surface:mcp:shadcn
pmap all writes the manifests that digest summarises, plus this index (full output):
# shadcn-ui — product map
Extracted at commit `4baadbc65170` (clean tree) by pmap@0.1.1.
- **3 surfaces** across 3 types — inventory
- **24 capabilities** across 3 kinds — inventory
- **Map**: 22 bound · 2 capability-unbound · 1 surface-unbound — details, gaps report
- **Planned stance**: not yet authored — run the Claude Design prompts in this repo's prompts/ (or product-map's) to produce surfaces.planned.json
The gaps report is the part you act on. It names what is unaccounted for and what to do about it, and it never guesses:
## 2. Unbound surfaces — declare what powers them
These surfaces have no capability bindings. Either the extractor cannot see the wiring (emit curated binds from `extract.local.mjs`) or the surface genuinely fronts nothing.
- `surface:mcp:shadcn` (mcp, live). Likely matches: `cap:command:shadcn.mcp` (0.5).
That similarity score is a proposal, not a binding. Nothing in this toolchain converts a guess into a recorded fact.
"I want an agent to understand this repo." The manifests are deliberately verbose — provenance, evidence paths, content hashes — which is right for tooling and wrong for a context window. The digest is the same extraction budgeted for one. It does not replace reading code for a specific task; it replaces the expensive discovery phase before that.
pmap digest"I want to see what we ship." A terminal table of every surface and capability, from a live extraction, with no committed manifests required.
pmap show"My map looks wrong." Which adapters ran, which stayed silent, which one failed and why the rest carried on without it, what extraction skipped and why, and which surface types came out suspiciously thin — each finding paired with the remedy that fixes it.
pmap doctor
pmap explain surface:cli:pmap"I want to catch drift in CI." check-fresh regenerates in memory and byte-compares against what is committed, so a code change nobody reflected in the map fails the build. The GitHub Action is one step.
pmap check-fresh"I want to run a design engagement." bundle assembles repo reality plus the design prompt to paste into a hosted Claude Design project; ingest validates what comes back and writes it canonically, bouncing anything malformed with the reason.
pmap bundle > handoff.md
pmap ingest surfaces.planned.json"I have thirty repositories." fleet rolls them into one derived manifest and matrix. --scan extracts each one live, so it works across repositories that have never adopted product-map.
pmap fleet ../service-a ../service-b --scan --out ./estatepmap is opinionated about what it reads: its extractors understand JavaScript and TypeScript repositories — package manifests and bins, Next.js and Hono apps, tRPC routers, Prisma and Drizzle schemas, Supabase functions and migrations, MCP servers, language servers, editor extensions, GitHub Actions, Electron and XcodeGen targets, and Claude Code plugins. Point it at a Go or Django repository and it will find very little.
That floor is deliberate rather than permanent. Everything a repository knows about itself that filesystem conventions cannot express — a command registry, a generated OpenAPI document, an operations table — is emitted by a repository-local extract.local.mjs, which supersedes the generic extractors and is schema-validated on the way in. This repository maps its own CLI that way.
Installing the CLI is all you need — @product-map/spec comes with it automatically as a dependency:
pnpm add -D product-map
pnpm exec pmap init
pnpm exec pmap all
pnpm exec pmap check-freshInstall @product-map/spec directly only if you want the contract on its own — schema validation, canonical serialization, and content hashing without the extraction engine.
product-map— the CLI, installed under both names:pmapandproduct-maprun the same binary. Internally it is a one-directional stack of layers (runtime, discovery, extract, derive, emit, engine) bundled into one published package; see docs/ARCHITECTURE.md, which is generated from the package manifests and gates the layering in CI.@product-map/spec— theproduct-map.v1Zod contract and emitted JSON Schemas. Installed automatically with the CLI.docs/SPEC.md— normative format and behavior documentation.packages/pmap/prompts/— design handoff and reconciliation prompt templates, vendored into a consuming repo bypmap init.plugins/product-map— a Claude Code plugin for adopting and maintaining product maps.integrations/github-action— a composite Action that runspmap check-freshas a CI gate.examples/— product maps extracted from pinned MIT-licensed public repositories, committed exactly aspmapwrote them.pnpm examples:setupclones the pinned commits into a gitignoredfixtures/,pnpm examples:generaterebuilds the snapshots, andpnpm examples:checkvalidates them offline.
pmap — product-map.v1 toolchain
Usage: pmap <command> [--repo <path>] [--dry-run|-n] [--no-repo-code]
Commands:
LOOK — read-only, writes nothing
show Print what this repo ships as a terminal table (no committed manifests needed)
digest Print a dense, token-budgeted, path-first summary for an agent entering the repo
explain <id> Explain one item: which adapter emitted it, from what evidence, at what confidence
doctor Report which adapters ran, what was skipped, and what looks suspiciously thin
ADOPT & MAINTAIN
init Vendor the prompt templates + JSON Schemas into the repo's product-map directory
all extract + map + diff + render in one pass
check-fresh Regenerate in memory and byte-compare against disk (read-only, exit 1 on drift)
validate Validate every manifest under docs/reference/product-map/ (read-only)
DESIGN LOOP — bundle sends, ingest receives
bundle Assemble the repo-reality bundle and design prompt for a Claude Design project
ingest <f..> Finalize + validate raw planned manifests (e.g. pasted Claude Design JSON) and write them canonically
MULTI-REPO
fleet <r...> Roll up several repos into fleet.json + fleet.generated.md in --out (--scan extracts live)
PRIMITIVES — individual pipeline stages
extract Extract surfaces.existing.json + capabilities.existing.json from the repo
map Derive maps/ from the manifests on disk
diff Derive diffs/ (requires planned manifests)
render Render generated/*.generated.md from the JSON manifests
Flags:
--repo <path> Target repo root (default: cwd)
--out <path> Output directory for fleet (default: cwd)
--dry-run, -n Report every file that WOULD be written; write nothing
--no-repo-code Do not import product-map.config.mjs or extract.local.mjs
--allow-partial-local Write partial output if the repo-local extractor fails
--config <path> Read product-map.config.mjs from an explicit path (not with --no-repo-code)
--quiet, -q Suppress progress diagnostics; leave errors and output
--format <f> Output format for the LOOK commands: text|json
--json Shorthand for --format=json
--max-tokens <n> Budget for digest on stdout (the committed digest uses config)
--scan fleet only: extract each repo live instead of reading committed maps
--version, -v Print the pmap version
--help Show this help
Four rungs, in order. Most repositories never leave the first.
- Nothing. Zero-config on a conventional repository.
- Flags. One-off behavior:
--format,--max-tokens,--no-repo-code,--config. Flags change what reaches your terminal; they never change committed bytes. product-map.config.mjs. Declarative rules, at the repository root.extract.local.mjs. Imperative, for truth no convention can express. It supersedes the generic adapters and is schema-validated on the way in.
/** @type {import('@product-map/spec').RepoConfig} */
export default {
repoName: "my-product",
nonProductDirs: ["scratch"],
adapters: { exclude: ["email"] }, // silence a misfiring adapter
ignore: ["packages/legacy/**"], // hide paths from every adapter
binds: [{ surface: "surface:cli:tool", capability: "cap:command:tool.run" }],
renames: [{ fromId: "cap:command:tool.old", toId: "cap:command:tool.run" }], // declared lineage for the diff
canonicalPlacements: [{ where: "^apps/", canonical: "packages/core", kinds: ["service"] }],
overrides: { "surface:cli:tool": { status: "partial" } },
outputs: { markdown: true, digest: true },
mapping: { candidateThreshold: 0.5, maxCandidates: 3 },
render: { descriptionLimit: 160, collapseThreshold: 15 },
digest: { maxTokens: 1200 },
concurrency: 4,
};adapters.exclude throws on a name no adapter provides, and a binds or overrides entry naming an item that was not extracted is reported by pmap doctor rather than silently dropped — the reason to reach for any of these fields is that the map is already wrong, so none of them may fail quietly. renames is the diff's only source of rename lineage: a declared pair is honored when fromId exists on one side and toId on the other and neither appears on both, and a declaration that does not line up warns on stderr and falls back to added/removed.
pmap fleet <repo...> --out <dir> reads each repository's committed manifests and writes fleet.json — a derived product-map.v1 manifest with one entry per repository — plus a rendered fleet.generated.md. A repository that has not run pmap all appears as a row with a state, never as an omission. Pass --scan to extract each repository live instead; rows say which source they came from, so the matrix cannot claim a committed map that is not there. With no paths it reads a roster from product-map.fleet.json:
{ "name": "my-estate", "repos": ["../service-a", "../service-b"] }Repository-local product-map.config.mjs and extract.local.mjs extensions are supported. They are executable repository code; use --no-repo-code when inspecting an untrusted checkout — see SECURITY.md.
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm examples:checkSee CONTRIBUTING.md, SECURITY.md, and docs/PUBLISHING.md. Licensed under the MIT License.