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.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. 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 }