Architect workbench for ProvenMap boards — explore the board graph, review insights, and author work items from Claude Cowork, Claude Code, or Codex. Connects…

Architect workbench for ProvenMap boards, running in Claude Code. Explore the board graph, review insights, and author work items — living specs — with the platform keeping governance: writes gather in your working copy, and committing generates a reviewable plan (one work item per governed app) in ProvenMap — never direct truth.
Where the ProvenMap code plugins serve developers (analyze a codebase or document set → push boards → implement work items), this plugin serves architects: the people reasoning over boards, deciding what the architecture should become, and turning insights and documents into work.
Add the ProvenMap marketplace once, then install the plugin:
/plugin marketplace add provenmap/pmap-claude
/plugin install pmap-architect@provenmap
Restart Claude Code so the commands load. Scope the install with --scope user (default), --scope project, or --scope local. Claude Cowork installs from the same marketplace.
The plugin talks to the ProvenMap MCP server with a workspace-scoped bearer token (pmap_mcp_live_…) — no repo binding, no project files:
/pmap-architect:login — sign in through the browser, pick workspace, scope (read or read_write) and an optional board-subtree restriction there. The token is stored for you and wired into this host's MCP config; it never transits the chat./pmap-architect:status.No browser, or no admin permission to approve? Mint a token in ProvenMap (workspace command center (hub) → Architect access), set it as PMAP_MCP_TOKEN in your environment, and run /pmap-architect:configure.
| Command | What it does |
|---|---|
/pmap-architect:start [ask] | Start here — the surfaces card, the ranked next step, and a menu to run it; routes any open-ended ask |
/pmap-architect:setup-workspace | Bootstrap an empty workspace: estate interview → landscape → app boards → binding handoff |
/pmap-architect:new-app <idea> | Plan a new system on the landscape: grill, place, sketch the target, draft the founding work item |
/pmap-architect:prepare-app | Take a new app from placed to build-ready — spec work items + skills, resumable any time |
/pmap-architect:author-work-item [slug] | Guided work item authoring: context pull, the grill, a well-grounded draft work item |
/pmap-architect:adopt-adr | Adopt a decision: durable record + compliance review + per-app remediation work items |
/pmap-architect:work-items | Turn anything into governed, well-anchored work; manage the queue |
/pmap-architect:ask-board <question> | Ask the architecture a question — slug-grounded answer or highlighted subgraph |
/pmap-architect:assess | Structured review: frame, sweep, defend the insights, record the batch |
/pmap-architect:insights | Review insight batches; promote reviewed insights into draft work items |
/pmap-architect:board [slug] | Work a board conversationally — portfolio view on the landscape, canvas elsewhere |
/pmap-architect:hub | The command center, attention-first: what needs you, then the portfolio |
/pmap-architect:login :configure :status :logout | Connection lifecycle (MCP token) |
/pmap-architect:help :update | Command reference · plugin update |
Reads are unrestricted within the token's workspace (and board restriction, if set). The token acts as you: writes join your one workspace working copy — the same session the ProvenMap web app shows — where they stay undoable until you decide. Committing the working copy generates a reviewable plan: one board_diff work item per governed app, named by your commit message; discarding reverts everything since the last decision. Deleting a draft work item removes it; the board keeps what you committed. Architects propose; the platform review decides.
Drop a PRD, RFC, or design doc into the session and ask for it to become board work: the plugin reads it directly, drafts work items anchored to the right board elements, and everything still gathers in your working copy for a reviewed commit. Documents bound to a board are also readable server-side.
hooks/pmap-guard.js 188 lines1// ProvenMap guard: a Claude Code mod (hooks.json "modules"), shipped as authored.
2//
3// It enforces two rules the shipped content only states in prose, at the tool call:
4// 1. Secrets never enter the chat. No Read, Grep or shell command touches the
5// ProvenMap credential files or the PMAP_* secret variables, and any ProvenMap
6// secret in what a tool returns (a credential pasted into config.json, an MCP
7// entry in a host config) reaches Claude masked to its prefix.
8// 2. Script-owned state is written only by the scripts. No Edit, Write or shell
9// write touches the element and evidence stores, the board manifest or the
10// tree plan, whose hashes and records the scripts own.
11//
12// Claude Code only: Codex and Cursor have no mods, so there the prose is the rule.
13// A user-scope plugin's mod runs in every session, ProvenMap repo or not, so each
14// rule needs a `.provenmap/` path or a PMAP_ name before it fires. Every deny tells
15// Claude what to run instead. A rule that throws lets the call through (fails open),
16// like the settings-hook scripts; the output is masked either way (see `recover`).
17
18const SECRET_FILE =
19 /(^|\/)\.provenmap\/(credentials\.json|login-state\.json|login-state(\/|$)|architect-mcp\.json)/;
20const SECRET_ENV = /\bPMAP_(API_SECRET|BINDING_TOKEN)\b/;
21// Every ProvenMap secret names its family up front: pmap_<kind>_<env>_<body> (cp, mcp,
22// sess, share, app, …), or the older ck_[<kind>_]live_<body>. This is the platform's
23// own redaction pattern (its credential registry): loose enough for a truncated
24// secret, while a bare prefix (`pmap_cp_live_…`) or a short fixture stays readable.
25const SECRET_VALUE = /\b((?:pmap|ck)_(?:[a-z]+_)?(?:live|test)_)[0-9A-Za-z_-]{16,}/g;
26const SCRIPT_OWNED =
27 /(^|\/)\.provenmap\/(boards\/manifest\.json|boards\/stores\/[^/]+\.(store|evidence)\.json|tree-plan\.json|plan-run\.json)$/;
28
29// A Grep or shell read rooted at these directories (or a glob in them) reads the
30// credential files inside them.
31const SECRET_DIR = /(^|\/)\.provenmap(\/login-state)?\/?(\*[^/]*)?$/;
32
33// Programs that can name a credential path without printing the file.
34const SAFE_PROGRAM = /^(ls|stat|test|\[|chmod|find|mkdir|cd|touch|echo|printf|true|false)$/;
35const SECRET_NAME = /credentials\.json|login-state|architect-mcp\.json/;
36
37function normalise(p) {
38 return typeof p === "string" ? p.replace(/\\/g, "/") : "";
39}
40
41function unquote(word) {
42 return normalise(word.replace(/^["']|["']$/g, ""));
43}
44
45function shellSegments(command) {
46 return command.split(/&&|\|\||[;|\n]/);
47}
48
49function secretDeny(target) {
50 const architect = /architect-mcp\.json/.test(target);
51 return [
52 `ProvenMap guard: ${target} holds a ProvenMap credential, and credentials never enter the chat.`,
53 architect
54 ? "Run /pmap-architect:status to check the token, or /pmap-architect:login to replace it."
55 : "Run /pmap-code:configure to check the credentials (its script reports each field's shape and the masked secret), or /pmap-code:login to replace them. The user edits the file by hand, never through the chat.",
56 ].join(" ");
57}
58
59function scriptOwnedDeny(target) {
60 const store = /\.(store|evidence)\.json$/.test(target);
61 return [
62 `ProvenMap guard: ${target} is written only by the ProvenMap scripts, and a hand edit breaks the record they verify.`,
63 store
64 ? "Run /pmap-code:sync to update the element store, or /pmap-code:ground for the evidence store."
65 : "Change the board files instead and let the scripts rewrite it; run /pmap-code:analyze to rebuild the plan (--clean starts over).",
66 ].join(" ");
67}
68
69function mentionsSecret(text) {
70 return (
71 (/\.provenmap/.test(normalise(text)) && SECRET_NAME.test(text)) ||
72 text.split(/[\s;&|<>()`]+/).some((w) => SECRET_DIR.test(unquote(w)))
73 );
74}
75
76// A shell command that could print a credential: a substitution around one, or a
77// segment naming one whose program reads files (or reads it through `<`). Once the
78// command names .provenmap, a bare file name counts too (`cd .provenmap && cat …`).
79function shellReadsSecret(command) {
80 if (!mentionsSecret(command)) return false;
81 if (/`|\$\(/.test(command)) return true;
82 return shellSegments(command).some((segment) => {
83 if (!SECRET_NAME.test(segment) && !mentionsSecret(segment)) return false;
84 const [program, ...args] = segment.trim().split(/\s+/);
85 if (program === "git") return args[0] !== "check-ignore";
86 return !SAFE_PROGRAM.test(program) || segment.includes("<");
87 });
88}
89
90// A shell write into a script-owned file: a redirect target, or an argument of a
91// program that changes the files it names (cp only writes its last one).
92function shellWriteTarget(command) {
93 for (const m of command.matchAll(/>>?\s*["']?([^\s;&|"'<>]+)/g)) {
94 if (SCRIPT_OWNED.test(normalise(m[1]))) return normalise(m[1]);
95 }
96 for (const segment of shellSegments(command)) {
97 const [program, ...args] = segment.trim().split(/\s+/).map(unquote);
98 const inPlace = /^(sed|perl)$/.test(program) && args.some((a) => /^-[a-zA-Z]*i/.test(a));
99 const targets =
100 program === "cp" ? args.slice(-1) : ["rm", "mv", "tee", "truncate"].includes(program) || inPlace ? args : [];
101 const hit = targets.find((w) => SCRIPT_OWNED.test(w));
102 if (hit) return hit;
103 }
104 return null;
105}
106
107/**
108 * The deny text for one tool call, or null to let it through. Exported for the
109 * plugin repo's tests; the engine only calls `register`.
110 */
111export function guard(tool, input) {
112 if (!input || typeof input !== "object") return null;
113 if (tool === "Read") {
114 const p = normalise(input.file_path);
115 return SECRET_FILE.test(p) ? secretDeny(p) : null;
116 }
117 if (tool === "Grep") {
118 const p = normalise(input.path);
119 return SECRET_FILE.test(p) || SECRET_DIR.test(p) ? secretDeny(p) : null;
120 }
121 if (tool === "Edit" || tool === "Write" || tool === "NotebookEdit") {
122 const p = normalise(input.file_path ?? input.notebook_path);
123 if (SECRET_FILE.test(p)) return secretDeny(p);
124 return SCRIPT_OWNED.test(p) ? scriptOwnedDeny(p) : null;
125 }
126 if (tool === "Bash" || tool === "PowerShell") {
127 const command = typeof input.command === "string" ? input.command : "";
128 if (SECRET_ENV.test(command)) return secretDeny(command.match(SECRET_ENV)[0]);
129 if (shellReadsSecret(command)) return secretDeny((command.match(SECRET_NAME) ?? [".provenmap/"])[0]);
130 const written = shellWriteTarget(command);
131 return written ? scriptOwnedDeny(written) : null;
132 }
133 return null;
134}
135
136function redactText(text) {
137 return text.replace(SECRET_VALUE, "$1****");
138}
139
140function redactDeep(value) {
141 if (typeof value === "string") return redactText(value);
142 if (Array.isArray(value)) return value.map(redactDeep);
143 if (value && typeof value === "object") {
144 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v)]));
145 }
146 return value;
147}
148
149/**
150 * What a tool call resolved to, with every ProvenMap secret masked, or the same
151 * object when it holds none (so core keeps its own rendering). A rewritten result
152 * drops `ref` and `text`, which name the unmasked output, and core re-renders it.
153 */
154export function redact(outcome) {
155 if (!outcome || outcome.deny !== undefined) return outcome;
156 const seen = typeof outcome.text === "string" ? outcome.text : JSON.stringify(outcome.result) ?? "";
157 if (!seen.match(SECRET_VALUE)) return outcome;
158 if (outcome.isError) return { deny: redactText(seen) };
159 return {
160 result: redactDeep(outcome.result),
161 ...(outcome.context ? { context: outcome.context.map(redactText) } : {}),
162 };
163}
164
165/**
166 * When the hook fails, the call goes through (a broken rule must not block every
167 * tool in every session), but its output is still masked, and output that cannot
168 * be masked is masked as text or withheld. `next` here replays a call already made.
169 */
170export async function recover($, e, next) {
171 const outcome = await next(e);
172 try {
173 return redact(outcome);
174 } catch {
175 return typeof outcome?.text === "string"
176 ? { deny: redactText(outcome.text) }
177 : { deny: "ProvenMap guard could not check this output for ProvenMap secrets, so it was withheld." };
178 }
179}
180
181/** @type {import('claude-code').Register} */
182export const register = (on) => {
183 on("tool.call", async ($, e, next) => {
184 const deny = guard(e.tool, e);
185 return deny ? { deny } : redact(await next(e));
186 }).catch(recover);
187};
188