SLOPSHOPPER

ProvenMap Architect

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

newguard
A shopper browsing a rack in a slop shop
README

ProvenMap Architect

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.

Install

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.

Connect

The plugin talks to the ProvenMap MCP server with a workspace-scoped bearer token (pmap_mcp_live_…) — no repo binding, no project files:

  1. Run /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.
  2. Restart the session so the MCP server loads.
  3. Verify with /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.

Commands

CommandWhat 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-workspaceBootstrap 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-appTake 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-adrAdopt a decision: durable record + compliance review + per-app remediation work items
/pmap-architect:work-itemsTurn 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:assessStructured review: frame, sweep, defend the insights, record the batch
/pmap-architect:insightsReview 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:hubThe command center, attention-first: what needs you, then the portfolio
/pmap-architect:login :configure :status :logoutConnection lifecycle (MCP token)
/pmap-architect:help :updateCommand reference · plugin update

How governance works

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.

Working with documents

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.

Source 1 files
hooks/pmap-guard.js 188 lines
1// 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