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,48 @@
1
+ // Supplementary host detection (Phase S4) — for coding-agent hosts that
2
+ // add-mcp's detectGlobalAgents() does not yet know about. add-mcp stays the
3
+ // primary, delegated detector (VISION: don't rebuild host detection); this
4
+ // only fills the proven gap of newer harnesses it hasn't catalogued, by the
5
+ // same method it uses — checking whether the host's real config dir exists.
6
+ //
7
+ // Pure data + a presence check. Each entry cites the documented convention so
8
+ // a reviewer can verify it (same bar as docs/connector-research.md). A host
9
+ // is only ever reported when its config dir is actually present, so this is
10
+ // inert until the user installs one — never an overclaim.
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ const HOME = homedir();
17
+
18
+ // id: our stable host id. present: dirs that, if any exist, mean it's
19
+ // installed. surfaces: which portawhip surfaces this host can receive (used
20
+ // by the host-support matrix + to skip lanes a host can't take). source: the
21
+ // doc that establishes the convention.
22
+ export const EXTRA_HOSTS = {
23
+ pi: {
24
+ label: "Pi (earendil-works/pi)",
25
+ present: [join(HOME, ".pi")],
26
+ surfaces: { instructions: true, skills: true, commands: true, agents: false, mcp: false, hooks: false },
27
+ source: "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md",
28
+ note: "Reads AGENTS.md and .agents/skills; prompt templates in ~/.pi/agent/prompts. No native subagents/MCP/declarative hooks (extensions only).",
29
+ },
30
+ amp: {
31
+ label: "Sourcegraph Amp",
32
+ present: [join(HOME, ".config", "amp")],
33
+ surfaces: { instructions: true, skills: true, commands: false, agents: false, mcp: true, hooks: false },
34
+ source: "https://ampcode.com/manual",
35
+ note: "Reads AGENTS.md (project + ~/.config/AGENTS.md); skills in ~/.config/amp/skills. MCP supported; no documented declarative hook file.",
36
+ },
37
+ };
38
+
39
+ // Present extra hosts on this machine (ids only), for merging into the host set.
40
+ export function detectExtraHosts() {
41
+ return Object.entries(EXTRA_HOSTS)
42
+ .filter(([, def]) => def.present.some((dir) => existsSync(dir)))
43
+ .map(([id]) => id);
44
+ }
45
+
46
+ export function extraHostSupports(hostId, surface) {
47
+ return EXTRA_HOSTS[hostId]?.surfaces?.[surface] === true;
48
+ }
@@ -0,0 +1,24 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EXTRA_HOSTS, detectExtraHosts, extraHostSupports } from "./extra-hosts.mjs";
4
+
5
+ test("EXTRA_HOSTS: each entry has presence paths, a surface map, and a source", () => {
6
+ for (const [id, def] of Object.entries(EXTRA_HOSTS)) {
7
+ assert.ok(Array.isArray(def.present) && def.present.length, `${id} has presence paths`);
8
+ assert.equal(typeof def.surfaces, "object", `${id} has surfaces`);
9
+ assert.match(def.source, /^https?:\/\//, `${id} cites a source URL`);
10
+ }
11
+ });
12
+
13
+ test("extraHostSupports: reads the per-host surface map", () => {
14
+ assert.equal(extraHostSupports("pi", "instructions"), true);
15
+ assert.equal(extraHostSupports("pi", "agents"), false); // no native subagents
16
+ assert.equal(extraHostSupports("amp", "mcp"), true);
17
+ assert.equal(extraHostSupports("nope", "instructions"), false);
18
+ });
19
+
20
+ test("detectExtraHosts: returns an array of only present hosts (subset of catalog)", () => {
21
+ const present = detectExtraHosts();
22
+ assert.ok(Array.isArray(present));
23
+ for (const id of present) assert.ok(EXTRA_HOSTS[id], `${id} is a catalogued host`);
24
+ });
@@ -0,0 +1,102 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ const HOME = homedir();
5
+
6
+ function homePath(...parts) {
7
+ return join(HOME, ...parts);
8
+ }
9
+
10
+ function projectPath(...parts) {
11
+ return join(...parts);
12
+ }
13
+
14
+ export const LOGICAL_HOOKS = {
15
+ "route-on-prompt": {
16
+ logicalEvent: "user_prompt",
17
+ description: "Suggest relevant harness-router capabilities before the agent starts work.",
18
+ },
19
+ "mark-tool-feedback": {
20
+ logicalEvent: "post_tool",
21
+ description: "Record whether a suggested capability was actually used.",
22
+ },
23
+ "auto-sync-on-start": {
24
+ logicalEvent: "session_start",
25
+ description: "On session start, fan already-canonical capabilities out to all hosts in the background.",
26
+ },
27
+ };
28
+
29
+ // Which manifest hook backs each logical event — used for host formats that
30
+ // want the hook's name/description (e.g. gemini). Generic lookup so adding a
31
+ // logical event never needs a code change here.
32
+ export const LOGICAL_EVENT_TO_MANIFEST = Object.fromEntries(
33
+ Object.entries(LOGICAL_HOOKS).map(([key, def]) => [def.logicalEvent, key]),
34
+ );
35
+
36
+ // Native hook surfaces only. Hosts with instruction/rules but no confirmed
37
+ // lifecycle hook API are intentionally omitted and reported as unsupported.
38
+ export const HOOK_TARGETS = {
39
+ "claude-code": {
40
+ kind: "json-settings",
41
+ format: "claude-code",
42
+ targets: [
43
+ { scope: "project", path: projectPath(".claude", "settings.json") },
44
+ { scope: "global", path: homePath(".claude", "settings.json") },
45
+ ],
46
+ events: {
47
+ user_prompt: "UserPromptSubmit",
48
+ post_tool: "PostToolUse",
49
+ // Session start fires the fire-and-forget auto-import worker. Claude
50
+ // Code is the one host with a confirmed native SessionStart event; the
51
+ // trigger only needs to exist on ONE host, since auto-import writes
52
+ // canonical + fans out to every other host from there.
53
+ session_start: "SessionStart",
54
+ },
55
+ },
56
+ codex: {
57
+ kind: "json-settings",
58
+ format: "codex-hooks-json",
59
+ targets: [
60
+ { scope: "project", path: projectPath(".codex", "hooks.json") },
61
+ { scope: "global", path: homePath(".codex", "hooks.json") },
62
+ ],
63
+ events: {
64
+ user_prompt: "UserPromptSubmit",
65
+ post_tool: "PostToolUse",
66
+ },
67
+ },
68
+ "gemini-cli": {
69
+ kind: "json-settings",
70
+ format: "gemini-settings-json",
71
+ targets: [
72
+ { scope: "project", path: projectPath(".gemini", "settings.json") },
73
+ { scope: "global", path: homePath(".gemini", "settings.json") },
74
+ ],
75
+ events: {
76
+ user_prompt: "BeforeAgent",
77
+ post_tool: "AfterTool",
78
+ },
79
+ },
80
+ opencode: {
81
+ kind: "plugin-file",
82
+ format: "opencode-plugin",
83
+ targets: [
84
+ { scope: "project", path: projectPath(".opencode", "plugins", "harness-router.js") },
85
+ { scope: "global", path: homePath(".config", "opencode", "plugins", "harness-router.js") },
86
+ ],
87
+ events: {
88
+ user_prompt: null,
89
+ post_tool: "tool.execute.after",
90
+ },
91
+ note: "OpenCode plugins support tool/session events, but not a direct user-prompt event in the current docs.",
92
+ },
93
+ };
94
+
95
+ export function hookTargetForHost(hostId, { scope = "project" } = {}) {
96
+ const target = HOOK_TARGETS[hostId];
97
+ if (!target) return null;
98
+ const scoped = target.targets.find((item) => item.scope === scope);
99
+ if (!scoped) return null;
100
+ return { ...target, path: scoped.path, scope };
101
+ }
102
+
@@ -0,0 +1,37 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ const HOME = homedir();
5
+
6
+ // Data catalog: where each host keeps its slash-command and subagent dirs, and
7
+ // whether that surface is markdown-copyable. Not decision logic — each path is
8
+ // a documented host convention. Hosts whose format differs (gemini uses TOML
9
+ // for custom commands) are marked unsupported rather than fed a .md that
10
+ // wouldn't load. v1 does frontmatter-passthrough copy only, no translation.
11
+ export const SURFACE_COPY_TARGETS = {
12
+ "claude-code": {
13
+ command: [
14
+ { scope: "global", dir: join(HOME, ".claude", "commands"), format: "md" },
15
+ { scope: "project", dir: join(".claude", "commands"), format: "md" },
16
+ ],
17
+ agent: [
18
+ { scope: "global", dir: join(HOME, ".claude", "agents"), format: "md" },
19
+ { scope: "project", dir: join(".claude", "agents"), format: "md" },
20
+ ],
21
+ },
22
+ codex: {
23
+ command: [{ scope: "global", dir: join(HOME, ".codex", "prompts"), format: "md" }],
24
+ agent: [{ scope: "global", dir: join(HOME, ".codex", "agents"), format: "md" }],
25
+ },
26
+ "gemini-cli": {
27
+ command: [{ scope: "global", format: "toml", unsupported: true }],
28
+ agent: [{ scope: "global", format: "unknown", unsupported: true }],
29
+ },
30
+ // Pi: prompt templates (its slash-command equivalent) are markdown under
31
+ // ~/.pi/agent/prompts. No native subagents -> agent unsupported.
32
+ // Source: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
33
+ pi: {
34
+ command: [{ scope: "global", dir: join(HOME, ".pi", "agent", "prompts"), format: "md" }],
35
+ agent: [{ scope: "global", format: "none", unsupported: true }],
36
+ },
37
+ };
@@ -0,0 +1,106 @@
1
+ import { readActiveSelection, resolveRecipePaths, resolveRuntimeRoot } from "../state/bundle-state.mjs";
2
+ import { capabilityKind } from "../registry/capability-kind.mjs";
3
+ import { DEFAULT_CACHE_PATH, readEnrichmentCache } from "../registry/enrich.mjs";
4
+ import { loadIndex } from "../registry/registry.mjs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { collectConnectorLinks } from "../../scripts/link/link-connectors.mjs";
8
+ import { collectHookLinks } from "../../scripts/link/link-hooks.mjs";
9
+ import { collectSurfaceMatrix } from "./surface-matrix.mjs";
10
+
11
+ const SCOPES = ["project", "global"];
12
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
13
+
14
+ function statusRank(status) {
15
+ if (status === "missing") return 2;
16
+ if (status === "bare-name") return 2;
17
+ if (status === "unsupported" || status === "mcp-only") return 1;
18
+ return 0;
19
+ }
20
+
21
+ function summarize(rows, getStatus) {
22
+ const counts = {};
23
+ for (const row of rows) {
24
+ const status = getStatus(row);
25
+ counts[status] = (counts[status] ?? 0) + 1;
26
+ }
27
+ return counts;
28
+ }
29
+
30
+ function capabilityRows(index) {
31
+ return index.entries.map((entry) => ({
32
+ id: entry.id,
33
+ type: entry.type,
34
+ kind: capabilityKind(entry.type),
35
+ origin: entry.origin,
36
+ source: entry.source ?? null,
37
+ path: entry.path ?? null,
38
+ description: entry.route?.description ?? null,
39
+ status: "available",
40
+ }));
41
+ }
42
+
43
+ function enrichmentRows(capabilities, cache) {
44
+ return capabilities
45
+ .filter((entry) => entry.type === "mcp" || entry.type === "cli")
46
+ .filter((entry) => entry.origin?.startsWith("auto:"))
47
+ .map((entry) => {
48
+ const enriched = cache[entry.id] ?? null;
49
+ return {
50
+ id: entry.id,
51
+ type: entry.type,
52
+ origin: entry.origin,
53
+ status: enriched ? "enriched" : "bare-name",
54
+ triggerCount: enriched?.triggers?.length ?? entry.route?.triggers?.length ?? 0,
55
+ description: enriched?.description ?? entry.route?.description ?? null,
56
+ enrichedAt: enriched?.enrichedAt ?? null,
57
+ };
58
+ })
59
+ .sort((a, b) => statusRank(b.status) - statusRank(a.status) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id));
60
+ }
61
+
62
+ export async function collectSurfaceInventory({ cwd = process.cwd(), packageRoot = PACKAGE_ROOT } = {}) {
63
+ const root = resolveRuntimeRoot(cwd, packageRoot);
64
+ const recipePaths = resolveRecipePaths(root, readActiveSelection(root));
65
+ const index = await loadIndex(recipePaths);
66
+ const capabilities = capabilityRows(index);
67
+ const enrichCachePath = resolve(cwd, DEFAULT_CACHE_PATH);
68
+ const enrichments = enrichmentRows(index.entries, readEnrichmentCache(enrichCachePath));
69
+ const hooks = [];
70
+ const connectors = [];
71
+
72
+ for (const scope of SCOPES) {
73
+ hooks.push(...(await collectHookLinks({ command: "status", scope })).rows);
74
+ connectors.push(...(await collectConnectorLinks({ command: "status", scope })).rows);
75
+ }
76
+
77
+ // Full surface coverage matrix (Phase S0): heavy=true so mcp/skill read
78
+ // lanes report real discovery counts, not the light "declared" placeholder.
79
+ const matrix = await collectSurfaceMatrix({ cwd, heavy: true });
80
+
81
+ hooks.sort((a, b) => statusRank(a.status) - statusRank(b.status) || a.hostId.localeCompare(b.hostId));
82
+ connectors.sort(
83
+ (a, b) =>
84
+ statusRank(a.instructionStatus) - statusRank(b.instructionStatus) ||
85
+ a.hostId.localeCompare(b.hostId) ||
86
+ a.scope.localeCompare(b.scope),
87
+ );
88
+
89
+ return {
90
+ generatedAt: new Date().toISOString(),
91
+ cwd,
92
+ recipePaths,
93
+ summary: {
94
+ capabilities: summarize(capabilities, (row) => row.type),
95
+ hooks: summarize(hooks, (row) => row.status),
96
+ connectors: summarize(connectors, (row) => row.instructionStatus),
97
+ enrichments: summarize(enrichments, (row) => row.status),
98
+ surfaceAttention: matrix.summary.attention,
99
+ },
100
+ capabilities,
101
+ enrichments,
102
+ hooks,
103
+ connectors,
104
+ surfaceMatrix: matrix,
105
+ };
106
+ }
@@ -0,0 +1,133 @@
1
+ // Surface coverage matrix collector (Phase S0).
2
+ //
3
+ // Reads surface-matrix.yaml (pure data: which backend owns each read/write
4
+ // lane, or a declared gap) and LIVE-PROBES every declared owner so the
5
+ // reported status is observed, never asserted (VISION.md: live-probe, never
6
+ // overclaim). Owns zero sync logic — each probe just runs the backend's own
7
+ // command or the existing discover.mjs function and counts what comes back.
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import yaml from "js-yaml";
13
+ import spawnSync from "cross-spawn";
14
+ import * as discover from "../registry/discover.mjs";
15
+ import { discoverEmbeddedHooks } from "./discover-hooks.mjs";
16
+ import { collectHookLinks } from "../../scripts/link/link-hooks.mjs";
17
+ import { collectConnectorLinks } from "../../scripts/link/link-connectors.mjs";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = resolve(HERE, "..", "..");
21
+ const DEFAULT_MATRIX_PATH = join(ROOT, "surface-matrix.yaml");
22
+ const PROBE_TIMEOUT_MS = 8000;
23
+
24
+ const DISCOVER_FNS = {
25
+ mcp: () => discover.discoverMcp(),
26
+ cli: () => discover.discoverCli(),
27
+ skills: () => discover.discoverSkills(),
28
+ commands: () => discover.discoverCommands(),
29
+ agents: () => discover.discoverAgents(),
30
+ embeddedHooks: () => discoverEmbeddedHooks(),
31
+ };
32
+
33
+ export function loadSurfaceMatrix(path = DEFAULT_MATRIX_PATH) {
34
+ const doc = yaml.load(readFileSync(path, "utf8"));
35
+ if (!doc || !Array.isArray(doc.surfaces)) {
36
+ throw new Error(`surface-matrix.yaml: missing "surfaces" list (${path})`);
37
+ }
38
+ return doc.surfaces;
39
+ }
40
+
41
+ function commandResolves(argv, cwd) {
42
+ const [cmd, ...args] = argv;
43
+ // Prefer a repo-local bin, matching the resolution the sync scripts use.
44
+ const suffix = process.platform === "win32" ? ".cmd" : "";
45
+ const local = join(cwd, "node_modules", ".bin", `${cmd}${suffix}`);
46
+ const command = existsSync(local) ? local : cmd;
47
+ const result = spawnSync.sync(command, args, { cwd, encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
48
+ // status 0 = ran fine. ENOENT (error) = backend not installed.
49
+ if (result.error) return { covered: false, detail: result.error.code || "not found" };
50
+ return { covered: result.status === 0, detail: `exit ${result.status ?? "?"}` };
51
+ }
52
+
53
+ async function probeDiscover(fn, cwd) {
54
+ const runner = DISCOVER_FNS[fn];
55
+ if (!runner) return { covered: false, detail: `unknown discover fn "${fn}"` };
56
+ try {
57
+ const items = await runner();
58
+ const count = Array.isArray(items) ? items.length : 0;
59
+ return { covered: true, count, detail: `${count} found` };
60
+ } catch (error) {
61
+ return { covered: false, detail: `probe error: ${error.message}` };
62
+ }
63
+ }
64
+
65
+ async function probeLink(kind) {
66
+ const collect = kind === "link-hooks" ? collectHookLinks : collectConnectorLinks;
67
+ const statusField = kind === "link-hooks" ? "status" : "instructionStatus";
68
+ const linkedValues = new Set(["linked"]);
69
+ let linked = 0;
70
+ let total = 0;
71
+ for (const scope of ["project", "global"]) {
72
+ const { rows } = await collect({ command: "status", scope });
73
+ for (const row of rows) {
74
+ total += 1;
75
+ if (linkedValues.has(row[statusField])) linked += 1;
76
+ }
77
+ }
78
+ return { covered: linked > 0, count: linked, detail: `${linked}/${total} host rows linked` };
79
+ }
80
+
81
+ async function probeDirection(direction, { cwd, heavy }) {
82
+ if (!direction) return { status: "undeclared", detail: "no direction declared" };
83
+ if (!direction.owner) {
84
+ return { status: direction.gap ?? "missing", owner: null, detail: direction.note ?? "no lane" };
85
+ }
86
+ // Owner present but a gap declared with no probe = a known-partial lane
87
+ // (e.g. agents write via ai-config-sync covers only claude<->codex). The
88
+ // gap is the honest status; owner is informational.
89
+ if (direction.gap && !direction.probe) {
90
+ return { status: direction.gap, owner: direction.owner, detail: direction.note ?? direction.gap };
91
+ }
92
+ const probe = direction.probe ?? { kind: "none" };
93
+ if (probe.heavy && !heavy) {
94
+ return { status: "declared", owner: direction.owner, detail: "count skipped (run with heavy=true)" };
95
+ }
96
+ let result;
97
+ if (probe.kind === "command") result = commandResolves(probe.argv, cwd);
98
+ else if (probe.kind === "discover") result = await probeDiscover(probe.fn, cwd);
99
+ else if (probe.kind === "link-hooks" || probe.kind === "link-connectors") result = await probeLink(probe.kind);
100
+ else result = { covered: false, detail: `unknown probe kind "${probe.kind}"` };
101
+
102
+ return {
103
+ status: result.covered ? "covered" : "backend-missing",
104
+ owner: direction.owner,
105
+ count: result.count,
106
+ detail: result.detail,
107
+ };
108
+ }
109
+
110
+ export async function collectSurfaceMatrix({ cwd = process.cwd(), heavy = false, path } = {}) {
111
+ const surfaces = loadSurfaceMatrix(path);
112
+ const rows = [];
113
+ for (const surface of surfaces) {
114
+ rows.push({
115
+ id: surface.id,
116
+ label: surface.label ?? surface.id,
117
+ read: await probeDirection(surface.read, { cwd, heavy }),
118
+ write: await probeDirection(surface.write, { cwd, heavy }),
119
+ });
120
+ }
121
+ const attention = rows.filter(
122
+ (row) => ["missing", "backend-missing"].includes(row.read.status) || ["missing", "backend-missing"].includes(row.write.status),
123
+ );
124
+ return {
125
+ generatedAt: new Date().toISOString(),
126
+ heavy,
127
+ rows,
128
+ summary: {
129
+ surfaces: rows.length,
130
+ attention: attention.map((row) => row.id),
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,90 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { collectSurfaceMatrix, loadSurfaceMatrix } from "./surface-matrix.mjs";
7
+
8
+ function fixture(contents) {
9
+ const dir = mkdtempSync(join(tmpdir(), "surface-matrix-"));
10
+ const path = join(dir, "surface-matrix.yaml");
11
+ writeFileSync(path, contents);
12
+ return path;
13
+ }
14
+
15
+ test("loadSurfaceMatrix rejects a doc with no surfaces list", () => {
16
+ const path = fixture("nope: true\n");
17
+ assert.throws(() => loadSurfaceMatrix(path), /missing "surfaces" list/);
18
+ });
19
+
20
+ test("declared gap (owner null) reports the gap verbatim, no probe run", async () => {
21
+ const path = fixture(`surfaces:
22
+ - id: embedded
23
+ label: Embedded hooks
24
+ read:
25
+ owner: null
26
+ gap: missing
27
+ note: no scanner yet
28
+ write:
29
+ owner: null
30
+ gap: missing
31
+ `);
32
+ const result = await collectSurfaceMatrix({ path });
33
+ const row = result.rows[0];
34
+ assert.equal(row.read.status, "missing");
35
+ assert.equal(row.read.detail, "no scanner yet");
36
+ assert.equal(row.write.status, "missing");
37
+ assert.ok(result.summary.attention.includes("embedded"));
38
+ });
39
+
40
+ test("owner present + gap + no probe = the declared gap (partial lane)", async () => {
41
+ const path = fixture(`surfaces:
42
+ - id: agent
43
+ label: Agents
44
+ read:
45
+ owner: x
46
+ gap: partial
47
+ write:
48
+ owner: ai-config-sync
49
+ gap: partial
50
+ note: claude<->codex only
51
+ `);
52
+ const result = await collectSurfaceMatrix({ path });
53
+ assert.equal(result.rows[0].write.status, "partial");
54
+ assert.equal(result.rows[0].write.owner, "ai-config-sync");
55
+ // partial is a known lane, not an attention gap
56
+ assert.deepEqual(result.summary.attention, []);
57
+ });
58
+
59
+ test("command probe: resolvable command covered, bogus command backend-missing", async () => {
60
+ const path = fixture(`surfaces:
61
+ - id: real
62
+ label: Real
63
+ read:
64
+ owner: node
65
+ probe: { kind: command, argv: [node, --version] }
66
+ write:
67
+ owner: nope
68
+ probe: { kind: command, argv: [definitely-not-a-real-binary-xyz, --version] }
69
+ `);
70
+ const result = await collectSurfaceMatrix({ path });
71
+ assert.equal(result.rows[0].read.status, "covered");
72
+ assert.equal(result.rows[0].write.status, "backend-missing");
73
+ assert.ok(result.summary.attention.includes("real"));
74
+ });
75
+
76
+ test("heavy probe is skipped (declared) when heavy=false", async () => {
77
+ const path = fixture(`surfaces:
78
+ - id: mcp
79
+ label: MCP
80
+ read:
81
+ owner: discover.mjs:discoverMcp
82
+ probe: { kind: discover, fn: mcp, heavy: true }
83
+ write:
84
+ owner: x
85
+ gap: partial
86
+ `);
87
+ const result = await collectSurfaceMatrix({ path, heavy: false });
88
+ assert.equal(result.rows[0].read.status, "declared");
89
+ assert.match(result.rows[0].read.detail, /count skipped/);
90
+ });
@@ -0,0 +1,38 @@
1
+ {"id":"p2-pdf","prompt":"extract table from this pdf","shouldRoute":true,"expectedTopId":"pdf","expectedAnyIds":["pdf"],"expectedKind":"skill","suggest":"skill","category":"phase2-positive","notes":"Curated PDF skill from recipe.yaml."}
2
+ {"id":"p2-grep","prompt":"search codebase for TODO comments","shouldRoute":true,"expectedTopId":"ripgrep","expectedAnyIds":["ripgrep"],"expectedKind":"tool","suggest":"tool","category":"phase2-positive","notes":"Curated CLI from recipe.yaml."}
3
+ {"id":"p2-sdk-docs","prompt":"look up SDK usage documentation","shouldRoute":true,"expectedTopId":"context7","expectedAnyIds":["context7"],"expectedKind":"tool","suggest":"tool","category":"phase2-positive","notes":"Curated docs MCP from recipe.yaml."}
4
+ {"id":"p2-postgres","prompt":"optimize PostgreSQL schema and queries","shouldRoute":true,"expectedTopId":"postgres-patterns","expectedAnyIds":["postgres-patterns"],"expectedKind":"skill","suggest":"skill","category":"phase2-positive","notes":"Auto-discovered Codex skill; validates installed skill retrieval."}
5
+ {"id":"p2-zero-downtime-migration","prompt":"plan a zero-downtime database migration","shouldRoute":true,"expectedTopId":"database-migrations","expectedAnyIds":["database-migrations"],"expectedKind":"skill","suggest":"skill","category":"phase2-positive","notes":"Auto-discovered Codex skill; validates phrase/intent retrieval."}
6
+ {"id":"p25-e2e-playwright-skill","prompt":"run Playwright E2E tests for the login flow","shouldRoute":true,"expectedTopId":"e2e-testing","expectedAnyIds":["e2e-testing"],"expectedKind":"skill","suggest":"skill","category":"phase2.5-positive","notes":"Skill suggestion for E2E workflow, distinct from Playwright MCP tool."}
7
+ {"id":"p25-security-review-skill","prompt":"review authentication code security","shouldRoute":true,"expectedTopId":"security-review","expectedAnyIds":["security-review"],"expectedKind":"skill","suggest":"skill","category":"phase2.5-positive","notes":"Skill suggestion driven by task intent and authentication context."}
8
+ {"id":"p25-article-writing-skill","prompt":"write a long-form article from notes","shouldRoute":true,"expectedTopId":"article-writing","expectedAnyIds":["article-writing"],"expectedKind":"skill","suggest":"skill","category":"phase2.5-positive","notes":"Skill suggestion for writing workflow."}
9
+ {"id":"p25-exa-tool","prompt":"search the web for recent AI papers with Exa","shouldRoute":true,"expectedTopId":"exa","expectedAnyIds":["exa"],"expectedKind":"tool","suggest":"tool","category":"phase2.5-positive","notes":"Tool suggestion for explicit MCP/server usage."}
10
+ {"id":"p25-github-tool","prompt":"inspect pull requests with GitHub MCP","shouldRoute":true,"expectedTopId":"github","expectedAnyIds":["github"],"expectedKind":"tool","suggest":"tool","category":"phase2.5-positive","notes":"Tool suggestion for explicit GitHub MCP usage."}
11
+ {"id":"p25-playwright-tool","prompt":"open a browser with playwright and click the login button","shouldRoute":true,"expectedTopId":"playwright","expectedAnyIds":["playwright"],"expectedKind":"tool","suggest":"tool","category":"phase2.5-positive","notes":"Tool suggestion separated from e2e-testing skill."}
12
+ {"id":"p25-sequential-thinking-tool","prompt":"think step by step with sequential thinking","shouldRoute":true,"expectedTopId":"sequential-thinking","expectedAnyIds":["sequential-thinking"],"expectedKind":"tool","suggest":"tool","category":"phase2.5-positive","notes":"Tool suggestion for explicit sequential-thinking MCP usage."}
13
+ {"id":"p2-poem","prompt":"write a poem about the ocean","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"phase2-negative","notes":"Creative writing should not route to a technical capability."}
14
+ {"id":"p2-france","prompt":"identify France's capital","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"phase2-negative","notes":"Simple knowledge answer should abstain."}
15
+ {"id":"p2-tcp","prompt":"explain the TCP handshake","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"phase2-negative","notes":"General explanation should abstain unless a specific capability is requested."}
16
+ {"id":"hard-router-architecture","prompt":"design the architecture for a lightweight capability router for AI coding agents","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Old noisy hook misrouted harness architecture discussion to unrelated ops skills."}
17
+ {"id":"hard-skill-router-false-positive","prompt":"diagnose skill-router hook misfires in harness architecture","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Router diagnosis should not trigger unrelated skills."}
18
+ {"id":"hard-notifications-false-positive","prompt":"audit noisy route-suggestion injection timing","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Should not route to unified-notifications-ops despite the audit action."}
19
+ {"id":"hard-homelab-false-positive","prompt":"compare push and pull routing modes for an agent harness","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Should not route to homelab/network skills just because router appears in the prompt."}
20
+ {"id":"hard-finance-false-positive","prompt":"make the capability router more precise without hardcoded decision logic","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Should not route to finance or billing skills."}
21
+ {"id":"hard-general-debug","prompt":"the model ignored an available skill; explain possible attention and context reasons","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Conceptual diagnosis should not inject a tool automatically."}
22
+ {"id":"hard-cross-host","prompt":"what are the tradeoffs of cross-host agent capability loading","mode":"push","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"delivery-hard-negative","notes":"Raw push is silent because no reasoned intent signal exists."}
23
+ {"id":"cross-host-pull-action","prompt":"what are the tradeoffs of cross-host agent capability loading","routePrompt":"compare cross-host capability-loading tradeoffs","mode":"pull","shouldRoute":true,"expectedTopId":"portawhip","expectedAnyIds":["portawhip"],"expectedKind":"skill","suggest":"skill","category":"intent-extraction","notes":"A concise comparison action can use the domain-specific portawhip skill."}
24
+ {"id":"hard-token-budget","prompt":"how should a router avoid token bloat when many tools are installed","mode":"push","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"delivery-hard-negative","notes":"Raw push is silent because no reasoned intent signal exists."}
25
+ {"id":"hard-token-budget-pull","prompt":"how should a router avoid token bloat when many tools are installed","routePrompt":"analyze router context-efficiency principles","mode":"pull","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"delivery-hard-negative","notes":"Pull evaluates only the requested action and direct object."}
26
+ {"id":"hard-graph-rag","prompt":"explain how graph retrieval might help tool selection","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Research discussion should not route unless user asks to use a specific research tool."}
27
+ {"id":"hard-state-aware","prompt":"what context signals should a router observe before injecting a skill","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Planning discussion should abstain."}
28
+ {"id":"hard-retrieval-comparison","prompt":"compare sparse dense and graph retrieval for capability routing","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Research/planning discussion should not trigger retrieval tools by itself."}
29
+ {"id":"hard-skill-tool-concept","prompt":"explain the difference between a skill and a tool in an agent","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Conceptual explanation should abstain."}
30
+ {"id":"hard-eval-set-design","prompt":"what makes a good eval set for a router","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Design discussion should abstain until a concrete capability is requested."}
31
+ {"id":"hard-creative-database","prompt":"tell me a bedtime story about a database","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Creative writing with a technical noun should not route to database skills."}
32
+ {"id":"hard-model-confidence","prompt":"should I use tools less when the model is confident","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"hard-negative","notes":"Meta discussion about tool use should abstain."}
33
+ {"id":"enrich-playwright-natural","prompt":"open a browser and click the login button","shouldRoute":true,"expectedTopId":"playwright","expectedAnyIds":["playwright"],"expectedKind":"tool","suggest":"tool","category":"enrichment-recall","notes":"Auto-discovered MCP tool, no description before enrichment - fired only when the prompt named 'playwright' literally. Regression guard for router-cli enrich (core/enrich.mjs)."}
34
+ {"id":"enrich-github-natural","prompt":"look up recent commits and open issues on this repository","shouldRoute":true,"expectedTopId":"github","expectedAnyIds":["github"],"expectedKind":"tool","suggest":"tool","category":"enrichment-recall","notes":"Auto-discovered MCP tool, same recall-gap class as playwright above."}
35
+ {"id":"enrich-markitdown-natural","prompt":"convert this pdf file to markdown text","shouldRoute":true,"expectedTopId":"markitdown","expectedAnyIds":["markitdown","pandoc"],"expectedKind":"tool","suggest":"tool","requiresInstalled":true,"category":"enrichment-recall","notes":"Auto-discovered CLI tool enriched via pip show when installed. pandoc also acceptable; skip explicitly when neither exists in the fixed live index."}
36
+ {"id":"intent-research-mcp-domain","prompt":"research MCP availability and live precision for dynamic tools and skills and future agents","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"intent-gate-negative","notes":"Live FP harvested 2026-07-07: a research/meta prompt about the capability system itself, saturated with capability-name vocab (mcp/tool/skill), matched build-mcp-server purely on that meta-vocabulary. Fixed by adding mcp/cli/capability to hybrid-router.mjs BROAD_TERMS. See docs/intent-gate-bakeoff.md for why a heavier intent gate was NOT built."}
37
+ {"id":"mixed-pull-extracted","prompt":"ugh the linter keeps yelling at me lol anyway grep the repo for leftover TODO comments","routePrompt":"search the codebase for TODO comments","mode":"pull","shouldRoute":true,"expectedTopId":"ripgrep","expectedAnyIds":["ripgrep"],"expectedKind":"tool","suggest":"tool","category":"intent-extraction","notes":"Mixed chat+command; pull routes the extracted action, not the venting."}
38
+ {"id":"mixed-push-silent","prompt":"ugh the linter keeps yelling at me lol anyway grep the repo for leftover TODO comments","mode":"push","shouldRoute":false,"expectedTopId":null,"expectedAnyIds":[],"category":"intent-extraction","notes":"Raw push has no reasoned intent signal, so a command buried in chat stays silent by mode policy."}
@@ -0,0 +1,16 @@
1
+ # Canonical hook sync manifest.
2
+ # Each logical hook maps to the nearest native lifecycle event per host.
3
+ hooks:
4
+ route-on-prompt:
5
+ logicalEvent: user_prompt
6
+ description: "Suggest relevant harness-router capabilities before the agent starts work."
7
+ enabled: true
8
+ mark-tool-feedback:
9
+ logicalEvent: post_tool
10
+ description: "Record whether a suggested MCP/CLI/skill capability was actually used."
11
+ enabled: true
12
+ auto-sync-on-start:
13
+ logicalEvent: session_start
14
+ description: "On session start, fan already-canonical capabilities out to all hosts in the background."
15
+ enabled: true
16
+