kcp-harness 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,7 +88,7 @@ kcp-harness init # creates harness.yaml
88
88
  ### 2. Generate agent integration
89
89
 
90
90
  ```bash
91
- kcp-harness integrate claude-code # or: cursor, copilot, windsurf, cline, continue, crush, openclaw
91
+ kcp-harness integrate claude-code # or: pi, cursor, copilot, windsurf, cline, continue, crush, openclaw
92
92
  ```
93
93
 
94
94
  ### 3. Start coding
@@ -107,6 +107,7 @@ Your agent now routes knowledge access through the harness. Every decision is lo
107
107
  | **Continue** | `.continue/mcpServers/*.yaml` | `kcp-harness integrate continue` |
108
108
  | **Crush** | `crush.json` + PrepareStep | `kcp-harness integrate crush` |
109
109
  | **OpenClaw** | `openclaw.json` + plugin hooks | `kcp-harness integrate openclaw` |
110
+ | **Pi** | `.pi/mcp.json` + project skills | `kcp-harness integrate pi` |
110
111
 
111
112
  Each agent has its own MCP config format, rules file, and quirks. The `integrate` command handles
112
113
  them all — one governance layer, any agent.
package/dist/cli.js CHANGED
@@ -29,14 +29,14 @@ Usage:
29
29
  kcp-harness --version Show version
30
30
  kcp-harness --help Show this help
31
31
 
32
- Agents: claude-code, cursor, windsurf, cline, continue, copilot, copilot-cli, crush, openclaw
32
+ Agents: pi, claude-code, cursor, windsurf, cline, continue, copilot, copilot-cli, crush, openclaw
33
33
 
34
34
  The harness is an MCP proxy that enforces deterministic knowledge
35
35
  governance for any agent. It intercepts tool calls, classifies them
36
36
  as knowledge-navigation or pass-through, and routes governed calls
37
37
  through the kcp-agent planner before execution.
38
38
  `;
39
- const VERSION = "0.4.1";
39
+ const VERSION = "0.5.1";
40
40
  const TEMPLATE = `# kcp-harness configuration
41
41
  version: "1.0"
42
42
 
package/dist/config.d.ts CHANGED
@@ -49,6 +49,11 @@ export interface AuditConfig {
49
49
  /** Path to the append-only JSONL audit log. */
50
50
  path: string;
51
51
  }
52
+ /** Optional dashboard telemetry sink for decision traces. */
53
+ export interface DashboardConfig {
54
+ /** Full URL of the kcp-dashboard /trace endpoint (e.g. http://localhost:7734/trace). */
55
+ url?: string;
56
+ }
52
57
  /** Top-level harness configuration. */
53
58
  export interface HarnessConfig {
54
59
  version: string;
@@ -58,6 +63,8 @@ export interface HarnessConfig {
58
63
  };
59
64
  downstream: DownstreamConfig[];
60
65
  audit: AuditConfig;
66
+ /** Opt-in decision-trace telemetry sink (absent = disabled). */
67
+ dashboard?: DashboardConfig;
61
68
  }
62
69
  /** Default governance policy. */
63
70
  export declare const DEFAULT_POLICY: GovernancePolicy;
package/dist/config.js CHANGED
@@ -31,13 +31,21 @@ export function parseConfig(text) {
31
31
  const policy = parsePolicy(governance?.["policy"]);
32
32
  const downstream = parseDownstream(raw["downstream"]);
33
33
  const audit = parseAudit(raw["audit"]);
34
+ const dashboard = parseDashboard(raw["dashboard"]);
34
35
  return {
35
36
  version: String(raw["version"] ?? "1.0"),
36
37
  governance: { domains, policy },
37
38
  downstream,
38
39
  audit,
40
+ ...(dashboard ? { dashboard } : {}),
39
41
  };
40
42
  }
43
+ function parseDashboard(raw) {
44
+ if (!raw || typeof raw !== "object")
45
+ return undefined;
46
+ const d = raw;
47
+ return { url: d["url"] === undefined ? undefined : String(d["url"]) };
48
+ }
41
49
  function parseDomains(raw) {
42
50
  if (!Array.isArray(raw))
43
51
  return [];
@@ -20,19 +20,99 @@ export function generateClaudeCode(options) {
20
20
  hooks: {
21
21
  PreToolUse: [
22
22
  {
23
- matcher: "Read|Edit|Write|Glob|Grep",
23
+ matcher: "Bash|Read|Edit|Write|Glob|Grep",
24
24
  hooks: [
25
25
  {
26
26
  type: "command",
27
27
  command: "node",
28
28
  args: ["-e", `
29
- const args = JSON.parse(process.env.TOOL_INPUT || '{}');
30
- const path = args.file_path || args.path || '';
31
- const governed = ${JSON.stringify(paths)}.some(p => path.startsWith(p) || path.includes('/' + p));
32
- if (governed) {
33
- console.log(JSON.stringify({ decision: "block", reason: "Use kcp_load to access governed knowledge at " + path }));
34
- process.exit(2);
35
- }
29
+ const deny = (reason) => {
30
+ console.log(JSON.stringify({
31
+ hookSpecificOutput: {
32
+ hookEventName: "PreToolUse",
33
+ permissionDecision: "deny",
34
+ permissionDecisionReason: reason,
35
+ },
36
+ }));
37
+ process.exit(0);
38
+ };
39
+ // Path matching mirrors src/classifier.ts so native file tools can't slip past
40
+ // with a Bash command, a Glob/Grep pattern, a case variant, or a backslash
41
+ // separator. Plain string ops only — this runs as an inline node script.
42
+ const BS = String.fromCharCode(92);
43
+ const GOV = ${JSON.stringify(paths)}.map((p) => {
44
+ let b = String(p).split(BS).join("/").toLowerCase();
45
+ while (b.slice(-1) === "/") b = b.slice(0, -1);
46
+ return b;
47
+ }).filter(Boolean);
48
+ const norm = (p) => {
49
+ let n = String(p == null ? "" : p).split(BS).join("/");
50
+ while (n.indexOf("//") >= 0) n = n.split("//").join("/");
51
+ if (n.slice(0, 2) === "./") n = n.slice(2);
52
+ const parts = n.split("/"), out = [];
53
+ for (let i = 0; i < parts.length; i++) {
54
+ const s = parts[i];
55
+ if (s === "..") { if (out.length && out[out.length - 1] !== "..") out.pop(); }
56
+ else if (s !== "." && s !== "") out.push(s);
57
+ }
58
+ return out.join("/").toLowerCase();
59
+ };
60
+ const isGoverned = (t) => {
61
+ if (!t) return false;
62
+ const n = norm(t);
63
+ return GOV.some((b) => n === b || n.indexOf(b + "/") === 0 || n.indexOf("/" + b + "/") >= 0);
64
+ };
65
+ const globPrefix = (pattern) => {
66
+ const s = String(pattern || "");
67
+ let idx = -1;
68
+ for (let i = 0; i < s.length; i++) { if ("*?[{".indexOf(s[i]) >= 0) { idx = i; break; } }
69
+ if (idx <= 0) return "";
70
+ const pre = s.slice(0, idx), ls = pre.lastIndexOf("/");
71
+ if (ls > 0) return pre.slice(0, ls);
72
+ if (pre.length > 0 && pre.indexOf(".") < 0) { let r = pre; while (r.slice(-1) === "/") r = r.slice(0, -1); return r; }
73
+ return "";
74
+ };
75
+ const tokens = (cmd) => {
76
+ const s = String(cmd || ""), seps = " " + String.fromCharCode(9) + String.fromCharCode(10) + ";|&<>()" + String.fromCharCode(34) + String.fromCharCode(39);
77
+ let cur = "", out = [];
78
+ for (let i = 0; i < s.length; i++) { const ch = s[i]; if (seps.indexOf(ch) >= 0) { if (cur) { out.push(cur); cur = ""; } } else cur += ch; }
79
+ if (cur) out.push(cur);
80
+ return out;
81
+ };
82
+ const bashTarget = (cmd) => {
83
+ const toks = tokens(cmd);
84
+ const readCmds = ["cat","head","tail","less","more","vi","vim","nano","code","cp","mv","ln","tar","zip","scp","rsync"];
85
+ for (let i = 0; i < toks.length; i++) {
86
+ if (readCmds.indexOf(toks[i]) >= 0) {
87
+ for (let j = i + 1; j < toks.length; j++) { if (toks[j][0] !== "-" && isGoverned(toks[j])) return toks[j]; }
88
+ }
89
+ }
90
+ if (String(cmd || "").indexOf(">") >= 0) { for (let j = 0; j < toks.length; j++) { if (isGoverned(toks[j])) return toks[j]; } }
91
+ return "";
92
+ };
93
+ let raw = "";
94
+ process.stdin.on("data", (d) => { raw += d; });
95
+ process.stdin.on("end", () => {
96
+ let input;
97
+ try {
98
+ input = JSON.parse(raw);
99
+ } catch {
100
+ // Fail-closed: an unreadable payload means we cannot prove the target is
101
+ // ungoverned, so we must not let it through.
102
+ deny("kcp-harness: could not parse PreToolUse input — failing closed");
103
+ return;
104
+ }
105
+ const tool = input.tool_name || "";
106
+ const a = input.tool_input || {};
107
+ let targets;
108
+ if (tool === "Glob" || tool === "Grep") targets = [a.path || "", globPrefix(a.pattern)];
109
+ else if (tool === "Bash") targets = [bashTarget(a.command)];
110
+ else targets = [a.file_path || a.path || ""];
111
+ const hit = targets.filter(Boolean).find(isGoverned);
112
+ if (hit) {
113
+ deny("Use kcp_load to access governed knowledge at " + hit);
114
+ }
115
+ });
36
116
  `.trim()],
37
117
  },
38
118
  ],
@@ -3,6 +3,7 @@
3
3
  // Each agent has its own MCP config format, rules file, and setup pattern.
4
4
  // The generator takes a harness config and produces the files needed to
5
5
  // integrate the harness with a specific agent.
6
+ import { generatePi } from "./pi.js";
6
7
  import { generateClaudeCode } from "./claude-code.js";
7
8
  import { generateCursor } from "./cursor.js";
8
9
  import { generateWindsurf } from "./windsurf.js";
@@ -12,6 +13,7 @@ import { generateCopilot } from "./copilot.js";
12
13
  import { generateCrush } from "./crush.js";
13
14
  import { generateOpenClaw } from "./openclaw.js";
14
15
  const GENERATORS = {
16
+ pi: generatePi,
15
17
  "claude-code": generateClaudeCode,
16
18
  cursor: generateCursor,
17
19
  windsurf: generateWindsurf,
@@ -0,0 +1,2 @@
1
+ import type { IntegrationOutput, IntegrationOptions } from "./types.js";
2
+ export declare function generatePi(options: IntegrationOptions): IntegrationOutput;
@@ -0,0 +1,68 @@
1
+ // Pi integration — project-local MCP config plus an agent operating skill.
2
+ import { governedPathsBlock, harnessServerEntry, manifestRef } from "./generate.js";
3
+ export function generatePi(options) {
4
+ const server = harnessServerEntry(options);
5
+ const manifest = manifestRef(options);
6
+ const mcpJson = {
7
+ path: ".pi/mcp.json",
8
+ content: JSON.stringify({
9
+ settings: { toolPrefix: "server", directTools: false },
10
+ mcpServers: { "kcp-harness": { ...server, lifecycle: "lazy" } },
11
+ }, null, 2) + "\n",
12
+ commitToGit: true,
13
+ description: "Pi MCP config — connects Pi to the lazy kcp-harness governance proxy",
14
+ };
15
+ const skill = {
16
+ path: ".pi/skills/kcp-harness/SKILL.md",
17
+ content: `---
18
+ name: kcp-harness
19
+ description: Use the kcp-harness MCP proxy for governed project knowledge. Load before reading governed paths or making architecture decisions.
20
+ ---
21
+
22
+ # KCP Harness for Pi
23
+
24
+ This project uses kcp-harness to govern knowledge access through \`knowledge.yaml\`.
25
+
26
+ ## Prerequisite: an MCP client extension
27
+
28
+ Stock Pi has no built-in MCP client, so \`.pi/mcp.json\` is inert until an MCP
29
+ client extension (for example \`pi-mcp-adapter\`) is installed. If the
30
+ \`kcp_plan\`/\`kcp_load\` tools are not available in this session, tell the
31
+ operator to install one — do not fall back to direct reads of governed paths.
32
+
33
+ ## Governed paths
34
+
35
+ ${governedPathsBlock(options)}
36
+
37
+ ## Agent workflow
38
+
39
+ 1. Call \`kcp_plan\` with the task and manifest \`${manifest}\`.
40
+ 2. Call \`kcp_load\` to load only the approved units.
41
+ 3. Use \`harness_status\` and \`harness_budget\` when the task has governance or budget implications.
42
+ 4. Treat plan and audit metadata as evidence; do not bypass the harness with direct reads of governed paths.
43
+ 5. Re-check temporal validity with \`harness_temporal_check\` before relying on an old plan.
44
+
45
+ The harness owns governance. This skill teaches the agent how to use it; it does not reimplement planner gates.
46
+ `,
47
+ commitToGit: true,
48
+ description: "Pi skill — instructs the agent to use kcp-harness plan/load and diagnostics",
49
+ };
50
+ return {
51
+ agent: "pi",
52
+ name: "Pi",
53
+ files: [mcpJson, skill],
54
+ instructions: `## Pi Integration
55
+
56
+ 1. The .pi/mcp.json registers kcp-harness as a lazy MCP server.
57
+ 2. The .pi/skills/kcp-harness/SKILL.md teaches Pi to call kcp_plan and kcp_load for governed knowledge.
58
+ 3. Keep directTools disabled by default to avoid flattening the provider's full tool surface.
59
+
60
+ Prerequisite: stock Pi ships without an MCP client — .pi/mcp.json takes effect
61
+ only once an MCP client extension such as pi-mcp-adapter is installed
62
+ (\`pi install npm:pi-mcp-adapter\`). The skill still loads either way from
63
+ .pi/skills/ after the project is trusted.
64
+
65
+ Review generated files before committing them. Regenerate with \`--dry-run\` to inspect output without writing.
66
+ `,
67
+ };
68
+ }
@@ -1,5 +1,5 @@
1
1
  /** Supported agent targets for integration generation. */
2
- export type AgentTarget = "claude-code" | "cursor" | "windsurf" | "cline" | "continue" | "copilot" | "copilot-cli" | "crush" | "openclaw";
2
+ export type AgentTarget = "pi" | "claude-code" | "cursor" | "windsurf" | "cline" | "continue" | "copilot" | "copilot-cli" | "crush" | "openclaw";
3
3
  /** Integration output — generated files for a specific agent. */
4
4
  export interface IntegrationOutput {
5
5
  /** Which agent this integration is for. */
@@ -1,6 +1,15 @@
1
1
  // Integration types — shared across all agent integration packages.
2
2
  /** Registry of all supported agents. */
3
3
  export const AGENTS = {
4
+ pi: {
5
+ target: "pi",
6
+ name: "Pi",
7
+ mcpSupport: true,
8
+ configFile: ".pi/mcp.json",
9
+ rulesFile: ".pi/skills/kcp-harness/SKILL.md",
10
+ topLevelKey: "mcpServers",
11
+ notes: "Project-local MCP config plus Agent Skills guidance",
12
+ },
4
13
  "claude-code": {
5
14
  target: "claude-code",
6
15
  name: "Claude Code",
package/dist/proxy.d.ts CHANGED
@@ -27,6 +27,12 @@ export declare class HarnessProxy {
27
27
  private listTools;
28
28
  /** Handle a tool call through the governance pipeline. */
29
29
  private handleToolCall;
30
+ /**
31
+ * Ship a decision trace to the dashboard when one is available (kcp_trace)
32
+ * and a dashboard URL is configured. Opt-in and fully fail-open — any error
33
+ * here is swallowed so telemetry never affects the tool result.
34
+ */
35
+ private maybeEmitTrace;
30
36
  /** Handle harness-internal tools. */
31
37
  private handleHarnessTool;
32
38
  /** Expose session for testing. */
package/dist/proxy.js CHANGED
@@ -18,6 +18,7 @@ import { AuditLog, buildEvent, buildLifecycleEvent, buildDriftEvent, } from "./a
18
18
  import { createSession, nextSequence } from "./session.js";
19
19
  import { DownstreamManager } from "./downstream.js";
20
20
  import { callKcpTool } from "./kcp-bridge.js";
21
+ import { toTraceEvent, emitTrace } from "./trace-emit.js";
21
22
  const HARNESS_VERSION = "0.1.0";
22
23
  const PROTOCOL_VERSION = "2025-06-18";
23
24
  const rpcResult = (id, result) => ({
@@ -238,6 +239,8 @@ export class HarnessProxy {
238
239
  // KCP tool — delegate to kcp-agent via the bridge
239
240
  const text = await callKcpTool(toolName, args);
240
241
  result = { content: [{ type: "text", text }], isError: false };
242
+ // Opt-in, fail-open: ship the decision trace to the dashboard.
243
+ this.maybeEmitTrace(toolName, args, text);
241
244
  }
242
245
  else {
243
246
  // Downstream tool — forward to the owning downstream server
@@ -263,6 +266,27 @@ export class HarnessProxy {
263
266
  });
264
267
  }
265
268
  }
269
+ /**
270
+ * Ship a decision trace to the dashboard when one is available (kcp_trace)
271
+ * and a dashboard URL is configured. Opt-in and fully fail-open — any error
272
+ * here is swallowed so telemetry never affects the tool result.
273
+ */
274
+ maybeEmitTrace(toolName, args, text) {
275
+ const url = this.config.dashboard?.url;
276
+ if (!url || toolName !== "kcp_trace")
277
+ return;
278
+ try {
279
+ const trace = JSON.parse(text);
280
+ emitTrace(url, toTraceEvent(trace, {
281
+ sessionId: this.session.id,
282
+ project: process.cwd(),
283
+ manifest: typeof args["manifest"] === "string" ? args["manifest"] : undefined,
284
+ }));
285
+ }
286
+ catch {
287
+ /* fail-open: never let telemetry break governance */
288
+ }
289
+ }
266
290
  /** Handle harness-internal tools. */
267
291
  async handleHarnessTool(name, _args) {
268
292
  switch (name) {
@@ -0,0 +1,47 @@
1
+ import type { DecisionTrace } from "kcp-agent";
2
+ /** One unit's gate-cascade verdict in the wire event. */
3
+ export interface TraceEventUnit {
4
+ id: string;
5
+ path?: string;
6
+ outcome: string;
7
+ rejected_by?: string;
8
+ score?: number;
9
+ gates: Array<{
10
+ gate: string;
11
+ verdict: "pass" | "fail";
12
+ detail?: string;
13
+ }>;
14
+ }
15
+ /** The compact wire event POSTed to the dashboard. */
16
+ export interface TraceEvent {
17
+ kind: "decision_trace";
18
+ session_id: string;
19
+ ts: string;
20
+ project?: string;
21
+ manifest?: string;
22
+ task: string;
23
+ as_of?: string;
24
+ selected: number;
25
+ skipped: number;
26
+ gate_summary: Array<{
27
+ gate: string;
28
+ passed: number;
29
+ failed: number;
30
+ }>;
31
+ units: TraceEventUnit[];
32
+ }
33
+ /** Context the trace itself doesn't carry (session, project, source manifest). */
34
+ export interface TraceContext {
35
+ sessionId: string;
36
+ project?: string;
37
+ manifest?: string;
38
+ /** Override the timestamp (tests); defaults to now. */
39
+ ts?: string;
40
+ }
41
+ /** Project a DecisionTrace into the compact, content-free wire event. */
42
+ export declare function toTraceEvent(trace: DecisionTrace, ctx: TraceContext): TraceEvent;
43
+ /**
44
+ * Fire-and-forget POST of a trace event to the dashboard. Never throws and
45
+ * never blocks: governance must not depend on the dashboard being up.
46
+ */
47
+ export declare function emitTrace(url: string, event: TraceEvent): void;
@@ -0,0 +1,60 @@
1
+ // Decision-trace emitter — POST a compact, content-free projection of a
2
+ // kcp-agent DecisionTrace to a kcp-dashboard `/trace` endpoint.
3
+ //
4
+ // This is the emit side of the thought-graph decision layer. It is:
5
+ // - opt-in — only fires when a dashboard URL is configured;
6
+ // - fail-open — a POST never throws or blocks governance;
7
+ // - content-free — unit ids, paths, gate names/verdicts, scores only. No
8
+ // unit content, no intents, no command bodies.
9
+ //
10
+ // See kcp-dashboard docs/thought-graph-phase2.md for the wire contract.
11
+ /** Project a DecisionTrace into the compact, content-free wire event. */
12
+ export function toTraceEvent(trace, ctx) {
13
+ const units = trace.units.map((u) => ({
14
+ id: u.id,
15
+ ...(u.path ? { path: u.path } : {}),
16
+ outcome: u.outcome,
17
+ ...(u.rejectedBy ? { rejected_by: u.rejectedBy } : {}),
18
+ ...(u.score !== undefined ? { score: u.score } : {}),
19
+ gates: u.gates.map((g) => ({
20
+ gate: g.gate,
21
+ verdict: g.passed ? "pass" : "fail",
22
+ ...(g.detail ? { detail: g.detail } : {}),
23
+ })),
24
+ }));
25
+ return {
26
+ kind: "decision_trace",
27
+ session_id: ctx.sessionId,
28
+ ts: ctx.ts ?? new Date().toISOString(),
29
+ ...(ctx.project ? { project: ctx.project } : {}),
30
+ ...(ctx.manifest ? { manifest: ctx.manifest } : {}),
31
+ task: trace.task,
32
+ ...(trace.asOf ? { as_of: trace.asOf } : {}),
33
+ selected: units.filter((u) => u.outcome === "selected").length,
34
+ skipped: units.filter((u) => u.outcome === "skipped").length,
35
+ gate_summary: (trace.gateSummary ?? []).map((g) => ({
36
+ gate: g.gate,
37
+ passed: g.passed,
38
+ failed: g.failed,
39
+ })),
40
+ units,
41
+ };
42
+ }
43
+ /**
44
+ * Fire-and-forget POST of a trace event to the dashboard. Never throws and
45
+ * never blocks: governance must not depend on the dashboard being up.
46
+ */
47
+ export function emitTrace(url, event) {
48
+ try {
49
+ void fetch(url, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify(event),
53
+ }).catch(() => {
54
+ /* fail-open: the dashboard is best-effort telemetry */
55
+ });
56
+ }
57
+ catch {
58
+ /* never throw — e.g. malformed URL — governance continues regardless */
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcp-harness",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "mcpName": "no.cantara/kcp-harness",
5
5
  "description": "KCP Compliance Harness — MCP proxy that enforces deterministic knowledge governance for any agent",
6
6
  "type": "module",