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 +2 -1
- package/dist/cli.js +2 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +8 -0
- package/dist/integrations/claude-code.js +88 -8
- package/dist/integrations/generate.js +2 -0
- package/dist/integrations/pi.d.ts +2 -0
- package/dist/integrations/pi.js +68 -0
- package/dist/integrations/types.d.ts +1 -1
- package/dist/integrations/types.js +9 -0
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +24 -0
- package/dist/trace-emit.d.ts +47 -0
- package/dist/trace-emit.js +60 -0
- package/package.json +1 -1
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.
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,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