DART Claude Code plugin: MCP write-back + directive surfacing (ADT-237/238/239) - #41
Conversation
…ve surfacing Make DART drive the main tool instead of only recording intent. A new MCP control-plane server (dart-mcp/) exposes the existing control-plane mutations — advance, set-gate, comment, set-label, assign, require-gate, consume-directive — as typed stdio tools that delegate 1:1 to the single guarded handler. There is no second writer; the engine's safety invariants (no routing past a safety-override gate, settable_by, compare-and-swap) hold through the tool path, and the server's import graph executes no code. It is bound to one project at spawn; a tool argument is a value, never a path. The SessionStart digest now surfaces the engine's pending directives — rendered as fence-escaped quoted data, never interpolated into an instruction — and the stage's permitted labels in parity with the engine, with a guarded, derived, idempotent mark-consumed. A .claude-plugin manifest packages the team, workflow, hooks, and the MCP server as a /dart:*-namespaced, opt-in plugin (defaultEnabled false) that augments the user's setup without clobbering it and ships no secrets (env var names only). install.sh stays as the fallback. Refs: ADT-237, ADT-238, ADT-239
There was a problem hiding this comment.
Pull request overview
This PR adds a bidirectional integration layer between DART’s existing hub control plane and Claude Code: a stdio MCP write-back server that delegates to hub/lib/api.handle, plus deterministic digest/state projections that surface pending directives and permitted labels, packaged as an opt-in Claude plugin with accompanying security/negative tests and sprint documentation.
Changes:
- Add
dart-mcp/stdio MCP server exposing typed read/write tools that delegate 1:1 tohub/lib/api.js. - Extend hub state + digest rendering to derive/surface pending directives and per-ticket permitted labels, plus a guarded
directive/consumewrite path. - Add plugin packaging + extensive negative tests and sprint approval/review documentation.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| hub/test/directives.test.js | New hub-level tests for directive derivation, escaping, permitted-label parity, and consume idempotence. |
| hub/lib/write.js | Extends comment writer to persist ref for consumed-directive markers. |
| hub/lib/state.js | Adds pendingDirectives, permittedLabelsFor, and exposes aggregated directives in the projection. |
| hub/lib/digest.js | Renders pending directives as fenced/escaped data and surfaces permitted labels in the digest. |
| hub/lib/api.js | Adds directive/consume route + markDirectiveConsumed() wrapper. |
| docs/sprints/sprint-07-plugin/TICKETS.md | Sprint 07 behavioral tickets/ACs for ADT-237/238/239. |
| docs/sprints/sprint-07-plugin/reviews/rev-plugin.md | Captures /rev review evidence and matrix results for the change set. |
| docs/sprints/sprint-07-plugin/README.md | Sprint overview + invariants/gates narrative for the bidirectional bridge. |
| docs/sprints/sprint-07-plugin/DECISION_LOG.md | Decision log entries D-001..D-009 for transport, writer reuse, packaging, etc. |
| docs/sprints/sprint-07-plugin/approvals/secops-plugin.md | /secops HARD gate conditions + required negative tests for the sprint. |
| docs/sprints/sprint-07-plugin/approvals/arch-plugin.md | /arch approval and guardrails for MCP + surfacing + packaging. |
| dart-mcp/test/handlers.test.js | MCP handler security negatives (single-writer scan, no-exec scan, CAS conflict, bound-project, etc.). |
| dart-mcp/src/tools.js | Defines the nine-tool surface and input descriptors (SDK-free). |
| dart-mcp/src/server.js | Stdio MCP server wiring over the tool surface. |
| dart-mcp/src/handlers.js | Pure tool handlers delegating to hub/lib/api + hub/lib/state. |
| dart-mcp/src/bind-project.js | Spawn-time project binding (cwd/absolute arg) without exec. |
| dart-mcp/package.json | Declares the MCP server package and SDK dependency. |
| dart-mcp/package-lock.json | Locks transitive dependencies for @modelcontextprotocol/sdk. |
| claude/memory/test/core.test.ts | Adds deterministic digest tests for directive surfacing + fence escaping. |
| claude/memory/src/digest.ts | Adds comment-log parsing + pending-directive derivation + fenced rendering in deterministic digest. |
| .claude-plugin/test/negatives.test.js | Plugin packaging negative tests (no clobber, opt-in inert, secret grep, etc.). |
| .claude-plugin/test/manifest.test.js | Manifest shape/invariant tests for plugin wiring (skills/commands/hooks/mcp). |
| .claude-plugin/README.md | Plugin usage/opt-in/namespace/security posture documentation. |
| .claude-plugin/plugin.json | Claude plugin manifest (namespaced, opt-in, hooks + MCP wiring). |
| .claude-plugin/hooks/hooks.json | SessionStart/PreCompact hook wiring for restore/save context scripts. |
Files not reviewed (1)
- dart-mcp/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| comments: readComments(project, id), | ||
| comments, | ||
| pendingDirectives: pendingDirectives(comments), | ||
| permittedLabels: permittedLabelsFor(assignee || owner, wf.labels), |
There was a problem hiding this comment.
Resolved in c6b32c5 — permittedLabels now consistently means the ACTING agent's settable labels (assignee, else stage owner), with docs/tests aligned and a distinguishing test (an assigned non-owner surfaces its own labels, parity with engine.labelSettableBy for that agent); a directive with a missing/empty/non-string id is skipped in BOTH the hub projection and the digest (unusable/unconsumable); zod is declared in dart-mcp (4.4.3, audit clean); and the no-secret test now scans the full shipped tree (.claude-plugin, claude/skills, claude/commands, dart-mcp/src) with high-entropy/known-prefix matching so doc placeholders like password123 don't false-positive but a real key fails.
| for (const c of comments || []) { | ||
| if (!c || c.kind !== 'directive' || consumed.has(String(c.id))) continue; | ||
| const target = Array.isArray(c.target) ? c.target.map(String) : (c.target != null ? [String(c.target)] : []); | ||
| out.push({ id: c.id, target, prompt: String(c.body == null ? '' : c.body), at: c.at || c.ts || null }); | ||
| } |
There was a problem hiding this comment.
Resolved in c6b32c5 — permittedLabels now consistently means the ACTING agent's settable labels (assignee, else stage owner), with docs/tests aligned and a distinguishing test (an assigned non-owner surfaces its own labels, parity with engine.labelSettableBy for that agent); a directive with a missing/empty/non-string id is skipped in BOTH the hub projection and the digest (unusable/unconsumable); zod is declared in dart-mcp (4.4.3, audit clean); and the no-secret test now scans the full shipped tree (.claude-plugin, claude/skills, claude/commands, dart-mcp/src) with high-entropy/known-prefix matching so doc placeholders like password123 don't false-positive but a real key fails.
| for (const c of comments) { | ||
| if (c.kind !== "directive" || (c.id && consumed.has(String(c.id)))) continue; | ||
| const target = Array.isArray(c.target) ? c.target.map(String) : c.target != null ? [String(c.target)] : []; | ||
| out.push({ id: String(c.id ?? ""), target, prompt: String(c.body ?? "") }); | ||
| } |
There was a problem hiding this comment.
Resolved in c6b32c5 — permittedLabels now consistently means the ACTING agent's settable labels (assignee, else stage owner), with docs/tests aligned and a distinguishing test (an assigned non-owner surfaces its own labels, parity with engine.labelSettableBy for that agent); a directive with a missing/empty/non-string id is skipped in BOTH the hub projection and the digest (unusable/unconsumable); zod is declared in dart-mcp (4.4.3, audit clean); and the no-secret test now scans the full shipped tree (.claude-plugin, claude/skills, claude/commands, dart-mcp/src) with high-entropy/known-prefix matching so doc placeholders like password123 don't false-positive but a real key fails.
| function readShipped() { | ||
| return shippedFiles().map((f) => ({ file: f, text: fs.readFileSync(f, 'utf8') })); | ||
| } |
There was a problem hiding this comment.
Resolved in c6b32c5 — permittedLabels now consistently means the ACTING agent's settable labels (assignee, else stage owner), with docs/tests aligned and a distinguishing test (an assigned non-owner surfaces its own labels, parity with engine.labelSettableBy for that agent); a directive with a missing/empty/non-string id is skipped in BOTH the hub projection and the digest (unusable/unconsumable); zod is declared in dart-mcp (4.4.3, audit clean); and the no-secret test now scans the full shipped tree (.claude-plugin, claude/skills, claude/commands, dart-mcp/src) with high-entropy/known-prefix matching so doc placeholders like password123 don't false-positive but a real key fails.
…, declare zod, widen the secret scan permittedLabels + its docs/tests now consistently describe the acting agent's settable labels (assignee, else stage owner); a directive with no usable id is skipped in both the hub projection and the digest; zod is a declared dart-mcp dependency; and the no-secret test scans the full shipped tree, not just the manifest dir.
| function renderDirectiveSection(ticket, lines) { | ||
| const directives = ticket.pendingDirectives || []; | ||
| const permitted = ticket.permittedLabels || []; | ||
| if (!directives.length && !permitted.length) return; | ||
| if (permitted.length) { | ||
| lines.push(` - labels you may set: ${permitted.join(', ')}`); | ||
| } |
There was a problem hiding this comment.
Resolved in 67cfc88 — the digest's permitted-label section now renders each label's routing consequence ('NAME → routes to STAGE') derived from the engine's actual contract (the label's routes_to or a rule's route_to_stage keyed on that label — parity, not hardcoded), with st.labels + st.rules threaded from the renderText call site; a label with no routing renders plainly. The writeHandlers comment now distinguishes the CAS-forwarding mutating handlers from the append-only comment/consume handlers.
| if (pending.length) flags.push(`pending: ${pending.join(', ')}`); | ||
| const tail = flags.length ? ` — ${flags.join('; ')}` : ''; | ||
| lines.push(`- **${t.id}** · ${t.stage}${who} · ${t.status} — ${t.title}${tail}`); | ||
| if (!done) renderDirectiveSection(t, lines); |
There was a problem hiding this comment.
Resolved in 67cfc88 — the digest's permitted-label section now renders each label's routing consequence ('NAME → routes to STAGE') derived from the engine's actual contract (the label's routes_to or a rule's route_to_stage keyed on that label — parity, not hardcoded), with st.labels + st.rules threaded from the renderText call site; a label with no routing renders plainly. The writeHandlers comment now distinguishes the CAS-forwarding mutating handlers from the append-only comment/consume handlers.
| // Each write handler forwards expectedRev verbatim so the existing CAS writer governs | ||
| // the write: a stale revision is a conflict that changes nothing. No handler invents, | ||
| // defaults, or drops expectedRev. |
There was a problem hiding this comment.
Resolved in 67cfc88 — the digest's permitted-label section now renders each label's routing consequence ('NAME → routes to STAGE') derived from the engine's actual contract (the label's routes_to or a rule's route_to_stage keyed on that label — parity, not hardcoded), with st.labels + st.rules threaded from the renderText call site; a label with no routing renders plainly. The writeHandlers comment now distinguishes the CAS-forwarding mutating handlers from the append-only comment/consume handlers.
…ler comment The SessionStart digest now shows what each permitted label routes to (from the engine's actual rule/label contract), not just the label name; the dart-mcp write-handler comment now distinguishes the CAS-forwarding mutating handlers from the append-only comment/consume handlers.
| for (const rule of rules || []) { | ||
| if (!rule || !rule.when || rule.when.label !== name) continue; | ||
| for (const action of rule.do || []) { | ||
| if (action && typeof action.route_to_stage === 'string' && action.route_to_stage) return action.route_to_stage; | ||
| } | ||
| } |
There was a problem hiding this comment.
Good catch — resolved in acf12f7. Confirmed engine.js:401 evaluates when.label as 'ticket currently HAS this label' (a precondition AND-ed with the rule's other predicates), so inferring 'setting label X routes to STAGE' from such a rule is unsound. labelRoutesTo() now renders a route ONLY from the label's own declared routes_to (the field isDeclaredBackwardRoute/state.js trust); a label referenced only by a rule's when.label, with no direct routes_to, renders as a bare name — proven by an explicit negative test. The unused rules param was removed from the chain.
…routing A label's routing in the digest now comes solely from its direct routes_to declaration; the unsound inference from a rule's when.label (which means "currently has", possibly with other conditions) is dropped, so a label only renders a route when it unambiguously declares one.
| "skills": "./claude/skills", | ||
| "commands": "./claude/commands", | ||
| "hooks": "./.claude-plugin/hooks/hooks.json", | ||
| "mcpServers": "./.claude-plugin/.mcp.json" |
There was a problem hiding this comment.
Resolved in 950f365 — a scoped negation '!/.claude-plugin/.mcp.json' (right after the blanket .mcp.json ignore) tracks the shipped plugin config so the manifest reference resolves on a fresh checkout, while users' local .mcp.json elsewhere stays ignored (verified via git check-ignore); it carries env var names only (the no-secret scan still passes), and a new guard test asserts the manifest's mcpServers path is git-tracked. Separately, the MCP server now reads its version from dart-mcp/package.json (load-once, with a safe fallback) instead of a hardcoded duplicate.
| function buildServer(projectDir, { McpServer, z }) { | ||
| const server = new McpServer({ name: 'dart', version: '0.1.0' }); | ||
| for (const tool of TOOLS) { |
There was a problem hiding this comment.
Resolved in 950f365 — a scoped negation '!/.claude-plugin/.mcp.json' (right after the blanket .mcp.json ignore) tracks the shipped plugin config so the manifest reference resolves on a fresh checkout, while users' local .mcp.json elsewhere stays ignored (verified via git check-ignore); it carries env var names only (the no-secret scan still passes), and a new guard test asserts the manifest's mcpServers path is git-tracked. Separately, the MCP server now reads its version from dart-mcp/package.json (load-once, with a safe fallback) instead of a hardcoded duplicate.
…n from package.json A scoped gitignore exception tracks the plugin's shipped .mcp.json (env names only, no secret) so the manifest reference resolves on a fresh checkout, while the blanket .mcp.json ignore still protects users' local configs; the MCP server reads its version from package.json instead of a hardcoded duplicate.
| const { id, directiveId, by, note } = data; | ||
| if (!findTicket(id)) return bad('unknown ticket'); | ||
| if (typeof directiveId !== 'string' || !directiveId) return bad('directiveId required'); | ||
| const comment = w.appendComment(project, id, { | ||
| author: by || 'hub', | ||
| kind: 'directive-consumed', | ||
| body: note || 'directive consumed', | ||
| ref: directiveId, | ||
| }); | ||
| return ok(st(), { comment }); |
There was a problem hiding this comment.
Resolved in 2bbcbb5 — directive/consume now checks the directiveId names a real directive on the ticket (via the same comment log pendingDirectives reads) and refuses with 404, nothing appended, when it doesn't; a real directive stays idempotent (already-consumed → harmless 200, since pending is derived). The toToolResult comment is corrected to state it returns the hub's response payload verbatim (for routes like comment, the persisted record echoing the caller's body — the hub's confirmation of what was written, no credentials in these payloads).
| // Shape a hub `{ code, payload }` result into an MCP tool result. The payload is returned | ||
| // as quoted JSON data; an error/refusal/conflict is surfaced as text, never thrown into an | ||
| // execution path. No tool argument is echoed back. |
There was a problem hiding this comment.
Resolved in 2bbcbb5 — directive/consume now checks the directiveId names a real directive on the ticket (via the same comment log pendingDirectives reads) and refuses with 404, nothing appended, when it doesn't; a real directive stays idempotent (already-consumed → harmless 200, since pending is derived). The toToolResult comment is corrected to state it returns the hub's response payload verbatim (for routes like comment, the persisted record echoing the caller's body — the hub's confirmation of what was written, no credentials in these payloads).
…ool-result comment directive/consume now validates the directiveId names a real directive on the ticket and refuses (404) otherwise instead of appending an orphan marker and reporting success; consuming a real directive stays idempotent. The dart-mcp toToolResult comment now accurately states it returns the hub's response payload verbatim.
The fence-break-neutralizing ZWSP is now an explicit escape in both digest renderers instead of an invisible literal, so it is reviewable and editor-safe; the rendered output is the identical code point.
| labels: wf.labels || {}, | ||
| rules: wf.rules || [], | ||
| tickets, | ||
| directives: tickets.flatMap((t) => t.pendingDirectives.map((d) => ({ ticket: t.id, ...d }))), | ||
| ticketCount: tickets.length, |
Phase 1 — make DART drive the main tool. The conditional-workflow engine already records intent (labels, routing,
instructdirectives); this is the bidirectional bridge. (arch + HARD secops gated.)What
dart-mcp/): typed stdio tools (advance / set-gate / comment / set-label / assign / require-gate / consume-directive + read-state / pending-directives) that delegate 1:1 to the single guardedapi.handle— no second writer, engine safety invariants hold through the tool path (no routing past a safety-override gate, settable_by, CAS), import graph executes no code, bound to one project at spawn.instructdirectives as fence-escaped quoted data (never an instruction) + the stage's permitted labels in parity with the engine; guarded, derived, idempotent mark-consumed; deterministic file fallback..claude-pluginmanifest —/dart:*namespaced, opt-in (defaultEnabled:false, inert otherwise), env-var names only (no secrets), augments-not-clobbers;install.shfallback kept.Security (HARD gate, verified)
Single-writer (import scan), engine invariants through MCP, no code-exec, stdio-only/bound-project, directives inert + injection-safe, plugin no-clobber/opt-in/no-secret. Conditions C237-* / C238-* / C239-* / negatives N237-* / N238-* / N239-* all green and mutation-verified load-bearing.
Tests
324 hub + 19 MCP + 23 plugin + 30 memory; tsc clean;
claude plugin validatepasses; npm audit 0. Internal /rev passed (0 blocking; 1 noted test-coverage warning to widen before VERIFIED).🤖 Generated with Claude Code