portawhip 0.1.0
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One command to fully retract the harness from every detected host and
|
|
3
|
+
// scope. Does not reimplement removal logic — shells out to link-hooks.mjs
|
|
4
|
+
// and link-connectors.mjs "remove" (marker-based, so only harness-owned
|
|
5
|
+
// entries are touched; the rest of each host's config is untouched) and
|
|
6
|
+
// then re-runs doctor to confirm nothing harness-owned is left.
|
|
7
|
+
//
|
|
8
|
+
// Does NOT touch add-mcp / agent-skill-manager state — those are separate
|
|
9
|
+
// tools with their own install/uninstall; see doctor.mjs's closing note.
|
|
10
|
+
|
|
11
|
+
import spawnSync from "cross-spawn";
|
|
12
|
+
|
|
13
|
+
function run(cmd, args) {
|
|
14
|
+
const result = spawnSync.sync(process.execPath, [cmd, ...args], { encoding: "utf8" });
|
|
15
|
+
process.stdout.write(result.stdout || "");
|
|
16
|
+
process.stderr.write(result.stderr || "");
|
|
17
|
+
return result.status === 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const scopes = ["project", "global"];
|
|
21
|
+
let ok = true;
|
|
22
|
+
|
|
23
|
+
for (const scope of scopes) {
|
|
24
|
+
console.log(`\n== removing native hooks (${scope}) ==`);
|
|
25
|
+
ok = run("scripts/link/link-hooks.mjs", ["remove", "--scope", scope]) && ok;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const scope of scopes) {
|
|
29
|
+
console.log(`\n== removing instruction connectors (${scope}) ==`);
|
|
30
|
+
ok = run("scripts/link/link-connectors.mjs", ["remove", "--scope", scope]) && ok;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log("\n== verifying via doctor ==");
|
|
34
|
+
run("scripts/doctor.mjs", []);
|
|
35
|
+
|
|
36
|
+
if (!ok) {
|
|
37
|
+
console.error("\nuninstall-all: one or more remove steps exited non-zero — check output above.");
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pull mode (PLAN.md Phase 2): exposes the same core/registry.mjs +
|
|
3
|
+
// core/scorer.mjs used by router-cli.mjs, as an MCP server any host can
|
|
4
|
+
// call directly — no per-host code beyond this one stdio process.
|
|
5
|
+
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, join, isAbsolute } from "node:path";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { loadIndex } from "../core/registry/registry.mjs";
|
|
12
|
+
import { listAll } from "../core/router/scorer.mjs";
|
|
13
|
+
import { explainRoute } from "../core/router/route-entry.mjs";
|
|
14
|
+
import { loadConfig } from "../core/state/config.mjs";
|
|
15
|
+
import { computeFactors, logEvent } from "../core/state/feedback.mjs";
|
|
16
|
+
import { stackFactors, combineFactors } from "../core/state/stack-detect.mjs";
|
|
17
|
+
import { readActiveSelection, resolveRecipePaths } from "../core/state/bundle-state.mjs";
|
|
18
|
+
import { warmDense, setDenseCachePath, primeDocCache } from "../core/router/dense-embedder.mjs";
|
|
19
|
+
import { buildCapabilityDocs } from "../core/registry/capability-docs.mjs";
|
|
20
|
+
|
|
21
|
+
// This server is registered globally (add-mcp may promote project scope to
|
|
22
|
+
// global depending on the host), so a caller can invoke it from ANY cwd —
|
|
23
|
+
// recipe.yaml/router.config.yaml must resolve to this repo, never the
|
|
24
|
+
// caller's working directory.
|
|
25
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
// Integration tests and embedded deployments may isolate outcome state from
|
|
27
|
+
// the repository's live dogfood log. Capability/config discovery still uses
|
|
28
|
+
// ROOT; only feedback events are redirected.
|
|
29
|
+
const FEEDBACK_ROOT = process.env.PORTAWHIP_FEEDBACK_ROOT || ROOT;
|
|
30
|
+
// Whatever bundles were opted into via `scripts/bundles.mjs select` (foundry
|
|
31
|
+
// + roles), resolved in front of this repo's own recipe.yaml — defaults to
|
|
32
|
+
// just recipe.yaml when nothing has been selected (today's behavior).
|
|
33
|
+
const RECIPE_PATHS = resolveRecipePaths(ROOT, readActiveSelection(ROOT));
|
|
34
|
+
const CONFIG_PATH = join(ROOT, "router.config.yaml");
|
|
35
|
+
|
|
36
|
+
const server = new McpServer({ name: "harness-router", version: "0.0.1" });
|
|
37
|
+
|
|
38
|
+
server.tool(
|
|
39
|
+
"route",
|
|
40
|
+
"Look up which installed capability (MCP tool, skill, or CLI) fits a task before you start it. " +
|
|
41
|
+
"State only the positively requested action and its direct object; do not copy the raw prompt. " +
|
|
42
|
+
"Drop chit-chat, venting, background, and any rejected, negated, or hypothetical option. " +
|
|
43
|
+
"If a request is buried in chat, route only the request; if the message names several distinct actions, call route once per action. " +
|
|
44
|
+
"Example: 'ugh CI is flaky, anyway find where we parse the auth token' -> query 'find the code that parses the auth token'. " +
|
|
45
|
+
"Returns pointers, not full content. An empty result is expected and means nothing installed fits.",
|
|
46
|
+
{ query: z.string(), k: z.number().optional() },
|
|
47
|
+
async ({ query, k }) => {
|
|
48
|
+
const index = await loadIndex(RECIPE_PATHS);
|
|
49
|
+
const config = loadConfig(CONFIG_PATH);
|
|
50
|
+
// Same class of bug fixed earlier for recipe.yaml/router.config.yaml:
|
|
51
|
+
// graphPath in config is written as a repo-relative string, which
|
|
52
|
+
// silently resolves against the CALLER's cwd, not this repo, when this
|
|
53
|
+
// server is invoked from elsewhere (which is the whole point of
|
|
54
|
+
// installing it globally).
|
|
55
|
+
const graphPath =
|
|
56
|
+
config.graphPath && !isAbsolute(config.graphPath) ? join(ROOT, config.graphPath) : config.graphPath;
|
|
57
|
+
const factors = combineFactors(computeFactors(FEEDBACK_ROOT), stackFactors(index, process.cwd()));
|
|
58
|
+
// denseBlock:false - this is the interactive tier. A cold dense-model load
|
|
59
|
+
// must never block a route() call (it would time the MCP client out; see
|
|
60
|
+
// core/dense-embedder.mjs). Early calls are sparse-only and dense joins in
|
|
61
|
+
// once the background warm (started at server boot below) finishes.
|
|
62
|
+
const result = await explainRoute(index, query, {
|
|
63
|
+
...config,
|
|
64
|
+
graphPath,
|
|
65
|
+
k: k ?? config.k,
|
|
66
|
+
factors,
|
|
67
|
+
denseBlock: false,
|
|
68
|
+
mode: "pull",
|
|
69
|
+
});
|
|
70
|
+
logEvent(FEEDBACK_ROOT, {
|
|
71
|
+
type: "route",
|
|
72
|
+
engine: config.engine,
|
|
73
|
+
queryLength: query.length,
|
|
74
|
+
resultCount: result.results.length,
|
|
75
|
+
suppressedCount: result.suppressed.length,
|
|
76
|
+
topIds: result.results.slice(0, 3).map((hit) => hit.id),
|
|
77
|
+
latencyMs: result.latency_ms,
|
|
78
|
+
emptyReason: result.negative_evidence?.reason ?? null,
|
|
79
|
+
});
|
|
80
|
+
// Pull results join the trust loop as boost-only signal (2026-07-09):
|
|
81
|
+
// source:"pull" marks these so computeFactors credits a follow-up "used"
|
|
82
|
+
// (Claude asked, got an answer, acted on it - the strongest possible
|
|
83
|
+
// relevance signal) but never counts an unused pull result as an
|
|
84
|
+
// "ignored" outcome. Pull is recall-generous by design; punishing
|
|
85
|
+
// unclicked results would recreate the noise-decay bug at this layer.
|
|
86
|
+
for (const hit of result.results) {
|
|
87
|
+
// The reasoned summary may contain sensitive task context. Outcome
|
|
88
|
+
// attribution only needs capability id + source, so never persist it.
|
|
89
|
+
logEvent(FEEDBACK_ROOT, { type: "suggested", id: hit.id, source: "pull" });
|
|
90
|
+
}
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
server.tool(
|
|
96
|
+
"list_all",
|
|
97
|
+
"List every installed capability known to the harness registry, " +
|
|
98
|
+
"optionally filtered by type (mcp | cli | skill).",
|
|
99
|
+
{ type: z.string().optional() },
|
|
100
|
+
async ({ type }) => {
|
|
101
|
+
const index = await loadIndex(RECIPE_PATHS);
|
|
102
|
+
const result = listAll(index, type);
|
|
103
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Start the (slow) dense model load in the background at boot, so it overlaps
|
|
108
|
+
// the idle time before the first route() call rather than blocking it. Safe
|
|
109
|
+
// to call unconditionally: it's fire-and-forget and a no-op when the model
|
|
110
|
+
// can't be loaded (dense degrades to sparse-only). If dense is disabled in
|
|
111
|
+
// config, the warm still completes harmlessly and simply never gets used.
|
|
112
|
+
warmDense();
|
|
113
|
+
|
|
114
|
+
// Doc-embedding cache: persisted to disk (survives this process restarting,
|
|
115
|
+
// which happens every session) AND primed here at boot, in the background,
|
|
116
|
+
// overlapping the model load above - so the FIRST interactive route() call
|
|
117
|
+
// after warm doesn't land on 559 uncached docs being embedded one-by-one in
|
|
118
|
+
// its own request (found live: 67-104s route() latency from exactly this -
|
|
119
|
+
// denseBlock:false only ever guarded the model load, never this). Fire and
|
|
120
|
+
// forget: no client is waiting on server boot, and denseRetrieve's own
|
|
121
|
+
// on-demand embed path still covers any doc this background pass hasn't
|
|
122
|
+
// reached yet.
|
|
123
|
+
setDenseCachePath(join(ROOT, ".hp-state", "dense-cache.json"));
|
|
124
|
+
loadIndex(RECIPE_PATHS)
|
|
125
|
+
.then((index) => primeDocCache(buildCapabilityDocs(index)))
|
|
126
|
+
.catch(() => {});
|
|
127
|
+
|
|
128
|
+
const transport = new StdioServerTransport();
|
|
129
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { after, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
// Live integration test - spawns the REAL MCP server and calls route() over
|
|
10
|
+
// real stdio JSON-RPC, no mocks. The unit tests in core/router.test.mjs inject
|
|
11
|
+
// a fake extractor, which by design mocks away the exact thing that broke in
|
|
12
|
+
// production: the real BGE-M3 cold load blocking the first route() call for
|
|
13
|
+
// ~73s, past the MCP client timeout, leaving route() uncallable for a whole
|
|
14
|
+
// session (found live 2026-07-07). A mock can't catch that; only spawning the
|
|
15
|
+
// real server with dense enabled (the default) and timing a real call can.
|
|
16
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const SERVER = join(ROOT, "server", "mcp-server.mjs");
|
|
18
|
+
const TEST_FEEDBACK_ROOT = mkdtempSync(join(tmpdir(), "portawhip-mcp-feedback-"));
|
|
19
|
+
const FEEDBACK_PATH = join(TEST_FEEDBACK_ROOT, ".hp-state", "feedback", "events.jsonl");
|
|
20
|
+
|
|
21
|
+
after(() => rmSync(TEST_FEEDBACK_ROOT, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
// The server logs a real "route" event to this repo's live feedback log.
|
|
24
|
+
// Snapshot and restore it so running tests never destroys real dogfood
|
|
25
|
+
// history - same discipline as adapters/hooks/universal-hook.test.mjs.
|
|
26
|
+
let feedbackBackup;
|
|
27
|
+
function snapshotFeedback() {
|
|
28
|
+
feedbackBackup = existsSync(FEEDBACK_PATH) ? readFileSync(FEEDBACK_PATH, "utf8") : null;
|
|
29
|
+
}
|
|
30
|
+
function restoreFeedback() {
|
|
31
|
+
if (existsSync(FEEDBACK_PATH)) rmSync(FEEDBACK_PATH);
|
|
32
|
+
if (feedbackBackup != null) {
|
|
33
|
+
mkdirSync(dirname(FEEDBACK_PATH), { recursive: true });
|
|
34
|
+
writeFileSync(FEEDBACK_PATH, feedbackBackup);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Spawn the server, do the MCP initialize handshake, then send one request.
|
|
39
|
+
// Resolves with the round-trip time of the request itself (t0 is
|
|
40
|
+
// set right before it is sent, so server boot / index load is excluded - we
|
|
41
|
+
// are measuring the route() latency the client would feel, which is what the
|
|
42
|
+
// non-blocking guarantee is about). MCP stdio framing is newline-delimited
|
|
43
|
+
// JSON-RPC.
|
|
44
|
+
function callMcp(method, params, { timeoutMs = 25000 } = {}) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const srv = spawn(process.execPath, [SERVER], {
|
|
47
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
48
|
+
cwd: ROOT,
|
|
49
|
+
env: { ...process.env, PORTAWHIP_FEEDBACK_ROOT: TEST_FEEDBACK_ROOT },
|
|
50
|
+
});
|
|
51
|
+
let buf = "";
|
|
52
|
+
let t0 = null;
|
|
53
|
+
const done = (fn, arg) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
srv.kill();
|
|
56
|
+
fn(arg);
|
|
57
|
+
};
|
|
58
|
+
const timer = setTimeout(
|
|
59
|
+
() => done(reject, new Error(`route() did not respond within ${timeoutMs}ms`)),
|
|
60
|
+
timeoutMs,
|
|
61
|
+
);
|
|
62
|
+
srv.stdout.on("data", (chunk) => {
|
|
63
|
+
buf += chunk.toString();
|
|
64
|
+
let idx;
|
|
65
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
66
|
+
const line = buf.slice(0, idx);
|
|
67
|
+
buf = buf.slice(idx + 1);
|
|
68
|
+
if (!line.trim()) continue;
|
|
69
|
+
let msg;
|
|
70
|
+
try {
|
|
71
|
+
msg = JSON.parse(line);
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (msg.id === 2) {
|
|
76
|
+
const ms = Date.now() - t0;
|
|
77
|
+
const payload = method === "tools/call" ? JSON.parse(msg.result.content[0].text) : msg.result;
|
|
78
|
+
done(resolve, { ms, result: payload });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
srv.on("error", (err) => done(reject, err));
|
|
83
|
+
const send = (obj) => srv.stdin.write(`${JSON.stringify(obj)}\n`);
|
|
84
|
+
send({
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
id: 1,
|
|
87
|
+
method: "initialize",
|
|
88
|
+
params: {
|
|
89
|
+
protocolVersion: "2024-11-05",
|
|
90
|
+
capabilities: {},
|
|
91
|
+
clientInfo: { name: "mcp-server-test", version: "1" },
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
// Give initialize a moment to be processed before firing the tool call.
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
97
|
+
t0 = Date.now();
|
|
98
|
+
send({ jsonrpc: "2.0", id: 2, method, params });
|
|
99
|
+
}, 500);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function callRoute(query, options) {
|
|
104
|
+
return callMcp("tools/call", { name: "route", arguments: { query } }, options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listTools(options) {
|
|
108
|
+
return callMcp("tools/list", {}, options);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test("mcp-server: route tool asks for a reasoned requested-action summary", async () => {
|
|
112
|
+
const { result } = await listTools();
|
|
113
|
+
const routeTool = result.tools.find((tool) => tool.name === "route");
|
|
114
|
+
assert.ok(routeTool, "route tool must be listed");
|
|
115
|
+
assert.match(routeTool.description, /requested action/i);
|
|
116
|
+
assert.match(routeTool.description, /not (?:repeat|copy) the raw (?:prompt|topic)/i);
|
|
117
|
+
assert.deepEqual(Object.keys(routeTool.inputSchema.properties).sort(), ["k", "query"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("mcp-server: concise router-analysis actions can still abstain", async () => {
|
|
121
|
+
const summaries = [
|
|
122
|
+
"compare capability-router architecture choices",
|
|
123
|
+
"analyze router context-efficiency principles",
|
|
124
|
+
];
|
|
125
|
+
for (const summary of summaries) {
|
|
126
|
+
const { result } = await callRoute(summary);
|
|
127
|
+
assert.equal(result.decision, "abstain", summary);
|
|
128
|
+
assert.deepEqual(result.results, [], summary);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("mcp-server: reasoned actionable summary still routes the curated capability", async () => {
|
|
133
|
+
const { result } = await callRoute("import an installed CLI and synchronize it across agent hosts");
|
|
134
|
+
assert.equal(result.decision, "route");
|
|
135
|
+
const portawhip = result.results.find((hit) => hit.id === "portawhip");
|
|
136
|
+
assert.ok(portawhip);
|
|
137
|
+
assert.equal(portawhip.intentEvidence.mode, "pull");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("mcp-server: pull feedback never persists the reasoned summary text", async () => {
|
|
141
|
+
snapshotFeedback();
|
|
142
|
+
const summary = "import a sensitive internal CLI and synchronize it across agent hosts";
|
|
143
|
+
try {
|
|
144
|
+
await callRoute(summary);
|
|
145
|
+
const events = readFileSync(FEEDBACK_PATH, "utf8")
|
|
146
|
+
.split(/\r?\n/)
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.map((line) => JSON.parse(line));
|
|
149
|
+
assert.ok(!readFileSync(FEEDBACK_PATH, "utf8").includes(summary));
|
|
150
|
+
for (const event of events.filter((item) => item.source === "pull")) {
|
|
151
|
+
assert.ok(!("query" in event));
|
|
152
|
+
assert.ok(!("prompt" in event));
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
restoreFeedback();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("mcp-server (live): route() answers fast, never blocking on the dense model cold-load", async () => {
|
|
160
|
+
snapshotFeedback();
|
|
161
|
+
try {
|
|
162
|
+
const { ms, result } = await callRoute("convert this pdf file to markdown text");
|
|
163
|
+
// The regressed behavior blocked ~73s here (dense cold load) and timed the
|
|
164
|
+
// client out. Non-blocking dense must answer in seconds regardless of
|
|
165
|
+
// whether the model is cached, downloading, or unreachable - it returns
|
|
166
|
+
// sparse-only until the background warm finishes. 15s is far below the
|
|
167
|
+
// 73s failure and well under any MCP client timeout, so it cleanly
|
|
168
|
+
// separates healthy from regressed without being flaky on a cold index.
|
|
169
|
+
assert.ok(ms < 15000, `route() took ${ms}ms - expected a fast, non-blocking answer (regression blocked ~73s)`);
|
|
170
|
+
assert.ok(result && typeof result.status === "string", "route() must return a structured decision object");
|
|
171
|
+
assert.ok(Array.isArray(result.results), "decision must carry a results array (possibly empty)");
|
|
172
|
+
} finally {
|
|
173
|
+
restoreFeedback();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portawhip
|
|
3
|
+
description: >-
|
|
4
|
+
Manage portawhip's cross-host capability sync — import newly-installed
|
|
5
|
+
tools/skills/MCP/commands/agents into the shared canonical set, enrich them,
|
|
6
|
+
check coverage, and fan them out to every AI host. Use when the user wants to
|
|
7
|
+
import a capability, share tools across Claude Code / Codex / Gemini / Cursor,
|
|
8
|
+
check what's synced, see the surface coverage matrix, or run/inspect the
|
|
9
|
+
loader, router, connectors, or hooks.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# portawhip — cross-host capability manager
|
|
13
|
+
|
|
14
|
+
portawhip loads capabilities into whatever AI hosts + OS the user has, and
|
|
15
|
+
routes the right one at the right moment. Two directions:
|
|
16
|
+
|
|
17
|
+
- **import** (manual): promote a capability that's installed *somewhere* into
|
|
18
|
+
the shared canonical set (`recipes/imported.yaml` for CLI/skill/command/
|
|
19
|
+
agent, `.agents/agents.json` for MCP). Auto-enriched so it routes on natural
|
|
20
|
+
phrasing, not just its own name.
|
|
21
|
+
- **fan-out** (automatic on session start, or manual): push canonical
|
|
22
|
+
capabilities out to every detected host.
|
|
23
|
+
|
|
24
|
+
Run everything from the repo root. All commands are safe to preview first;
|
|
25
|
+
nothing installs or writes across hosts without an explicit apply/sync.
|
|
26
|
+
|
|
27
|
+
## Import a newly-installed capability
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run import # grouped status: what's installed but not yet canonical
|
|
31
|
+
npm run import:preview # same, framed as a plan
|
|
32
|
+
# apply — narrow by surface or pick one; hand-curated recipe.yaml always wins:
|
|
33
|
+
node scripts/import-surfaces.mjs apply --apply --type cli,mcp
|
|
34
|
+
node scripts/import-surfaces.mjs apply --apply --include skill:pdf
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Default view groups by surface (cli / mcp / skill / command / agent) with
|
|
38
|
+
real counts; large groups are sampled with an import hint. Nothing hidden.
|
|
39
|
+
- CLI entries are auto-enriched on apply (mise registry → package registry →
|
|
40
|
+
tldr → --help). A CLI that can't be enriched at all is held back.
|
|
41
|
+
- Import is idempotent: re-running proposes only what's genuinely new.
|
|
42
|
+
|
|
43
|
+
## Fan out to all hosts
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run sync-surfaces sync # push canonical -> every detected host (idempotent)
|
|
47
|
+
npm run sync-surfaces check # dry-run: what would change
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Session start also fires this automatically in the background (throttled) via
|
|
51
|
+
the auto-sync hook — see `router.config.yaml`'s `autoSync`. It only ever
|
|
52
|
+
propagates already-canonical entries, never auto-imports.
|
|
53
|
+
|
|
54
|
+
## Check coverage and health
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm run doctor # unified status + surface coverage matrix (light)
|
|
58
|
+
node scripts/doctor.mjs --heavy # matrix with full discovery counts
|
|
59
|
+
npm run surface # detailed inventory (capabilities/hooks/connectors)
|
|
60
|
+
npm run tui -- --summary # one-screen attention summary
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The surface matrix (`surface-matrix.yaml` + `core/surface-matrix.mjs`) shows,
|
|
64
|
+
per surface, whether the read (import) and write (fan-out) lanes are covered,
|
|
65
|
+
missing, or partial — live-probed, not asserted.
|
|
66
|
+
|
|
67
|
+
## Routing (surface the right capability at the right time)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node core/router-cli.mjs route --prompt "..." # what would route for a prompt
|
|
71
|
+
node core/router-cli.mjs list # everything in the index
|
|
72
|
+
node core/router-cli.mjs enrich # refresh tool descriptions/triggers
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Notes
|
|
76
|
+
|
|
77
|
+
- Removing an imported entry (`forget`) is not built yet — edit
|
|
78
|
+
`recipes/imported.yaml` / `.agents/agents.json` by hand for now.
|
|
79
|
+
- Design + rationale: `VISION.md`, `docs/sync-connector-plan.md`, and the
|
|
80
|
+
`docs/phaseS*-verify.md` records.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Surface coverage matrix — DATA ONLY (Phase S0).
|
|
2
|
+
#
|
|
3
|
+
# Rows = the portable surfaces the connector is meant to sync. For each
|
|
4
|
+
# direction (read=import from hosts, write=fan-out to hosts) this file
|
|
5
|
+
# declares WHICH backend owns the lane, or a declared gap (missing/
|
|
6
|
+
# unsupported) where no lane exists yet. It never asserts a live status:
|
|
7
|
+
# core/surface-matrix.mjs live-probes every declared owner and reports what
|
|
8
|
+
# it actually finds (VISION.md: live-probe, never overclaim). The host axis
|
|
9
|
+
# stays dynamic — resolved at runtime from detectHosts(), never listed here.
|
|
10
|
+
#
|
|
11
|
+
# probe kinds interpreted by surface-matrix.mjs:
|
|
12
|
+
# command : run argv; covered if the backend command resolves + exits 0
|
|
13
|
+
# discover : call core/discover.mjs <fn>; covered if it returns items
|
|
14
|
+
# (count reported; 0 = lane works but nothing found)
|
|
15
|
+
# link-hooks : scripts/link/link-hooks.mjs status; covered if any host linked
|
|
16
|
+
# link-connectors: scripts/link/link-connectors.mjs status; covered if any linked
|
|
17
|
+
# none : no probe; status is the declared `gap`
|
|
18
|
+
#
|
|
19
|
+
# gap values: missing (no lane, planned) | unsupported (no host API) | partial
|
|
20
|
+
|
|
21
|
+
surfaces:
|
|
22
|
+
- id: mcp
|
|
23
|
+
label: MCP servers
|
|
24
|
+
read:
|
|
25
|
+
owner: "discover.mjs:discoverMcp"
|
|
26
|
+
probe: { kind: discover, fn: mcp, heavy: true }
|
|
27
|
+
write:
|
|
28
|
+
owner: "add-mcp + agents-dotdir"
|
|
29
|
+
probe: { kind: command, argv: [agents, --version] }
|
|
30
|
+
|
|
31
|
+
- id: cli
|
|
32
|
+
label: CLI tools
|
|
33
|
+
read:
|
|
34
|
+
owner: "discover.mjs:discoverCli (mise ls)"
|
|
35
|
+
probe: { kind: discover, fn: cli }
|
|
36
|
+
write:
|
|
37
|
+
owner: "mise (via load.mjs)"
|
|
38
|
+
probe: { kind: command, argv: [mise, --version] }
|
|
39
|
+
|
|
40
|
+
- id: skill
|
|
41
|
+
label: Skills
|
|
42
|
+
read:
|
|
43
|
+
owner: "discover.mjs:discoverSkills"
|
|
44
|
+
probe: { kind: discover, fn: skills, heavy: true }
|
|
45
|
+
write:
|
|
46
|
+
owner: "agent-skill-manager (via load.mjs)"
|
|
47
|
+
probe: { kind: command, argv: [agent-skill-manager, --version] }
|
|
48
|
+
|
|
49
|
+
- id: command
|
|
50
|
+
label: Slash commands
|
|
51
|
+
read:
|
|
52
|
+
owner: "discover.mjs:discoverCommands (+ host-native dirs)"
|
|
53
|
+
probe: { kind: discover, fn: commands }
|
|
54
|
+
write:
|
|
55
|
+
owner: "link-surfaces.mjs (managed-copy)"
|
|
56
|
+
probe: { kind: command, argv: [node, scripts/link/link-surfaces.mjs, status] }
|
|
57
|
+
|
|
58
|
+
- id: agent
|
|
59
|
+
label: Subagent defs
|
|
60
|
+
read:
|
|
61
|
+
owner: "discover.mjs:discoverAgents (+ host-native dirs)"
|
|
62
|
+
probe: { kind: discover, fn: agents }
|
|
63
|
+
write:
|
|
64
|
+
owner: "link-surfaces.mjs (managed-copy; gemini unsupported)"
|
|
65
|
+
probe: { kind: command, argv: [node, scripts/link/link-surfaces.mjs, status] }
|
|
66
|
+
|
|
67
|
+
- id: hook-declared
|
|
68
|
+
label: Hooks (declared)
|
|
69
|
+
read:
|
|
70
|
+
owner: "hooks.manifest.yaml (curated)"
|
|
71
|
+
probe: { kind: command, argv: [node, scripts/link/link-hooks.mjs, status] }
|
|
72
|
+
write:
|
|
73
|
+
owner: "link-hooks.mjs (native-hook hosts)"
|
|
74
|
+
probe: { kind: link-hooks }
|
|
75
|
+
|
|
76
|
+
- id: hook-embedded
|
|
77
|
+
label: Hooks (embedded in skills/plugins)
|
|
78
|
+
read:
|
|
79
|
+
owner: "discover-hooks.mjs:discoverEmbeddedHooks"
|
|
80
|
+
probe: { kind: discover, fn: embeddedHooks, heavy: true }
|
|
81
|
+
write:
|
|
82
|
+
owner: null
|
|
83
|
+
gap: missing
|
|
84
|
+
note: "Inventory only (Phase S3 mode B). Linking embedded hooks is deferred until a proven need (S3 full)."
|
|
85
|
+
|
|
86
|
+
- id: instruction
|
|
87
|
+
label: Instructions / router link
|
|
88
|
+
read:
|
|
89
|
+
owner: "ai-config-sync (claude<->codex)"
|
|
90
|
+
probe: { kind: command, argv: [node, scripts/link/link-connectors.mjs, status] }
|
|
91
|
+
write:
|
|
92
|
+
owner: "ai-config-sync + link-connectors.mjs"
|
|
93
|
+
probe: { kind: link-connectors }
|