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,129 @@
1
+ // PLAN.md Phase 4 step 1/2: append-only usage log + bounded weight
2
+ // adjustment. Append-only JSONL, never rewritten in place — matches the
3
+ // "no daemon, cheap, live-probed" style already used elsewhere in this
4
+ // repo, and avoids concurrent-write corruption risk from two hook
5
+ // processes editing the same file.
6
+ //
7
+ // Simplification (documented, not hidden): correlation is per-capability-id
8
+ // across all events, not strictly scoped to one session — a "used" event
9
+ // for id X counts as a hit for whatever the most recent unresolved
10
+ // "suggested" event for X was, regardless of which session logged either
11
+ // one. Good enough for the boost/decay signal this phase asks for; revisit
12
+ // only if cross-session bleed is actually observed causing a wrong weight.
13
+
14
+ import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { isSyntheticPrompt } from "../router/prompt-hygiene.mjs";
17
+
18
+ // Unbounded append-only growth was a real gap (found during a project
19
+ // review, not a live incident): this log has no rotation, so heavy daily
20
+ // use would grow it forever and slow computeFactors()'s full-file scan.
21
+ // Cheap fix: check file SIZE (no read) on every append — only pay the cost
22
+ // of a full read+rewrite the rare time the file actually crosses the cap.
23
+ const PRUNE_CHECK_BYTES = 512 * 1024; // ~512KB before we even look closer
24
+ const MAX_EVENTS = 5000; // keep the most recent N events after a prune
25
+
26
+ export function feedbackPathFor(root) {
27
+ return join(root, ".hp-state", "feedback", "events.jsonl");
28
+ }
29
+
30
+ function pruneIfOversized(path) {
31
+ let size;
32
+ try {
33
+ size = statSync(path).size;
34
+ } catch {
35
+ return;
36
+ }
37
+ if (size < PRUNE_CHECK_BYTES) return;
38
+
39
+ const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim());
40
+ if (lines.length <= MAX_EVENTS) return;
41
+ writeFileSync(path, lines.slice(-MAX_EVENTS).join("\n") + "\n");
42
+ }
43
+
44
+ export function logEvent(root, event) {
45
+ const path = feedbackPathFor(root);
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ pruneIfOversized(path);
48
+ appendFileSync(path, JSON.stringify({ ts: Date.now(), ...event }) + "\n");
49
+ }
50
+
51
+ export function readEvents(root) {
52
+ const path = feedbackPathFor(root);
53
+ if (!existsSync(path)) return [];
54
+ return readFileSync(path, "utf8")
55
+ .split("\n")
56
+ .filter((line) => line.trim())
57
+ .map((line) => {
58
+ try {
59
+ return JSON.parse(line);
60
+ } catch {
61
+ return null;
62
+ }
63
+ })
64
+ .filter(Boolean);
65
+ }
66
+
67
+ // Bounded per PLAN.md spec: x0.5 (ignored streak) .. x2.0 (used streak).
68
+ // Streak = consecutive most-recent suggestions for this id with the same
69
+ // used/ignored outcome, walking back from the newest event.
70
+ export function computeFactors(root) {
71
+ const events = readEvents(root).sort((a, b) => a.ts - b.ts);
72
+ const byId = new Map();
73
+ for (const e of events) {
74
+ if (e.type !== "suggested" && e.type !== "used") continue;
75
+ // Read-side hygiene, not a log rewrite: suggestions fired against
76
+ // harness-generated payloads (the hook now skips them at the source, but
77
+ // 21/26 historical suggested events were exactly this) must not count as
78
+ // "ignored" outcomes - that decayed genuinely good capabilities on noise.
79
+ // Filtering here retroactively cleans history while keeping the JSONL
80
+ // append-only.
81
+ if (e.type === "suggested" && isSyntheticPrompt(e.prompt)) continue;
82
+ if (!byId.has(e.id)) byId.set(e.id, []);
83
+ byId.get(e.id).push(e);
84
+ }
85
+
86
+ const factors = new Map();
87
+ for (const [id, idEvents] of byId) {
88
+ // Resolve each "suggested" against the next "used" that follows it in
89
+ // time for the same id -> a bounded sequence of hit/miss outcomes.
90
+ //
91
+ // Asymmetric credit (2026-07-09): suggestions with source:"pull" (MCP
92
+ // route() results - Claude asked) count toward boost when followed by a
93
+ // "used", but an unused pull suggestion resolves to NO outcome, not an
94
+ // ignored one. Pull is recall-generous by design - most returned
95
+ // candidates going unused is normal operation, not negative signal.
96
+ // Only push suggestions (unsolicited injections the model definitely
97
+ // saw) earn decay when ignored.
98
+ const outcomes = [];
99
+ let pending = null; // null | "push" | "pull"
100
+ const resolveIgnored = () => {
101
+ if (pending === "push") outcomes.push(false);
102
+ pending = null;
103
+ };
104
+ for (const e of idEvents) {
105
+ if (e.type === "suggested") {
106
+ resolveIgnored();
107
+ pending = e.source === "pull" ? "pull" : "push";
108
+ } else if (e.type === "used" && pending) {
109
+ outcomes.push(true);
110
+ pending = null;
111
+ }
112
+ }
113
+ resolveIgnored();
114
+
115
+ if (outcomes.length === 0) {
116
+ factors.set(id, 1.0);
117
+ continue;
118
+ }
119
+ let streak = 0;
120
+ const last = outcomes[outcomes.length - 1];
121
+ for (let i = outcomes.length - 1; i >= 0 && outcomes[i] === last; i -= 1) streak += 1;
122
+
123
+ const factor = last
124
+ ? Math.min(2.0, 1 + 0.2 * streak)
125
+ : Math.max(0.5, 1 - 0.15 * streak);
126
+ factors.set(id, Number(factor.toFixed(3)));
127
+ }
128
+ return factors;
129
+ }
@@ -0,0 +1,181 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { logEvent, readEvents, computeFactors } from "./feedback.mjs";
7
+
8
+ function tempRoot() {
9
+ return mkdtempSync(join(tmpdir(), "harness-feedback-"));
10
+ }
11
+
12
+ test("feedback: no events -> neutral factor", () => {
13
+ const root = tempRoot();
14
+ try {
15
+ const factors = computeFactors(root);
16
+ assert.equal(factors.size, 0);
17
+ } finally {
18
+ rmSync(root, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ test("feedback: a single used suggestion boosts above 1.0", () => {
23
+ const root = tempRoot();
24
+ try {
25
+ logEvent(root, { type: "suggested", id: "ripgrep" });
26
+ logEvent(root, { type: "used", id: "ripgrep", tool: "Bash" });
27
+ const factors = computeFactors(root);
28
+ assert.equal(factors.get("ripgrep"), 1.2);
29
+ } finally {
30
+ rmSync(root, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test("feedback: repeated ignores decay toward the 0.5 floor, never below it", () => {
35
+ const root = tempRoot();
36
+ try {
37
+ for (let i = 0; i < 10; i += 1) logEvent(root, { type: "suggested", id: "ripgrep" });
38
+ const factors = computeFactors(root);
39
+ assert.equal(factors.get("ripgrep"), 0.5);
40
+ } finally {
41
+ rmSync(root, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test("feedback: repeated uses cap at the 2.0 ceiling, never above it", () => {
46
+ const root = tempRoot();
47
+ try {
48
+ for (let i = 0; i < 10; i += 1) {
49
+ logEvent(root, { type: "suggested", id: "ripgrep" });
50
+ logEvent(root, { type: "used", id: "ripgrep", tool: "Bash" });
51
+ }
52
+ const factors = computeFactors(root);
53
+ assert.equal(factors.get("ripgrep"), 2.0);
54
+ } finally {
55
+ rmSync(root, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("feedback: unrelated capability ids don't affect each other's factor", () => {
60
+ const root = tempRoot();
61
+ try {
62
+ for (let i = 0; i < 6; i += 1) logEvent(root, { type: "suggested", id: "pdf" }); // ignored
63
+ logEvent(root, { type: "suggested", id: "context7" });
64
+ logEvent(root, { type: "used", id: "context7", tool: "mcp__context7__resolve-library-id" });
65
+ const factors = computeFactors(root);
66
+ assert.ok(factors.get("pdf") < 1.0);
67
+ assert.ok(factors.get("context7") > 1.0);
68
+ } finally {
69
+ rmSync(root, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test("feedback: readEvents reflects logged events in order", () => {
74
+ const root = tempRoot();
75
+ try {
76
+ logEvent(root, { type: "suggested", id: "a" });
77
+ logEvent(root, { type: "used", id: "a" });
78
+ const events = readEvents(root);
79
+ assert.equal(events.length, 2);
80
+ assert.equal(events[0].type, "suggested");
81
+ assert.equal(events[1].type, "used");
82
+ } finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test("feedback: log file is pruned once it crosses the size threshold", () => {
88
+ const root = tempRoot();
89
+ const TOTAL = 12000;
90
+ try {
91
+ // Force many small events so the file crosses the prune-check size
92
+ // (~512KB) at least once — pruning is a size-triggered, approximate
93
+ // cap (checked on append, not re-checked until the next 512KB of
94
+ // growth), not an exact "never more than 5000 lines" guarantee.
95
+ for (let i = 0; i < TOTAL; i += 1) logEvent(root, { type: "suggested", id: `cap-${i % 5}` });
96
+ const events = readEvents(root);
97
+ assert.ok(events.length < TOTAL, `expected pruning to have happened, got ${events.length} of ${TOTAL}`);
98
+ assert.ok(events.length > 0);
99
+ // Most recent event must survive the prune.
100
+ assert.equal(events[events.length - 1].id, `cap-${(TOTAL - 1) % 5}`);
101
+ } finally {
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test("feedback: suggestions fired on synthetic prompts are ignored, not counted as decay", () => {
107
+ const root = tempRoot();
108
+ try {
109
+ // 21/26 historical suggested events were harness task-notifications
110
+ // (2026-07-09 audit) - each would count as an "ignored" outcome and
111
+ // decay the capability on pure noise. The read-side filter must skip
112
+ // them without touching the append-only log.
113
+ for (let i = 0; i < 5; i += 1) {
114
+ logEvent(root, {
115
+ type: "suggested",
116
+ id: "ripgrep",
117
+ prompt: "<task-notification>\n<task-id>x</task-id>\n<summary>done</summary>\n</task-notification>",
118
+ });
119
+ }
120
+ // One genuine suggested->used pair: the only signal that should count.
121
+ logEvent(root, { type: "suggested", id: "ripgrep", prompt: "search codebase for foo" });
122
+ logEvent(root, { type: "used", id: "ripgrep", tool: "Bash" });
123
+ const factors = computeFactors(root);
124
+ assert.equal(factors.get("ripgrep"), 1.2); // single-hit boost, no noise decay mixed in
125
+ assert.equal(readEvents(root).length, 7); // log itself untouched
126
+ } finally {
127
+ rmSync(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test("feedback: pull-suggested then used earns boost (asymmetric credit, hit side)", () => {
132
+ const root = tempRoot();
133
+ try {
134
+ logEvent(root, { type: "suggested", id: "exa", source: "pull", query: "web search" });
135
+ logEvent(root, { type: "used", id: "exa", tool: "mcp__exa__web_search_exa" });
136
+ const factors = computeFactors(root);
137
+ assert.equal(factors.get("exa"), 1.2);
138
+ } finally {
139
+ rmSync(root, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test("feedback: pull-suggested but never used stays neutral, no decay", () => {
144
+ const root = tempRoot();
145
+ try {
146
+ // Pull is recall-generous: unused pull results are normal operation.
147
+ for (let i = 0; i < 10; i += 1) {
148
+ logEvent(root, { type: "suggested", id: "exa", source: "pull", query: "web search" });
149
+ }
150
+ const factors = computeFactors(root);
151
+ assert.equal(factors.get("exa"), 1.0);
152
+ } finally {
153
+ rmSync(root, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("feedback: push-suggested and ignored still decays (existing behavior preserved)", () => {
158
+ const root = tempRoot();
159
+ try {
160
+ logEvent(root, { type: "suggested", id: "ripgrep", prompt: "search codebase for foo" });
161
+ logEvent(root, { type: "suggested", id: "ripgrep", prompt: "search codebase again" });
162
+ const factors = computeFactors(root);
163
+ assert.ok(factors.get("ripgrep") < 1.0);
164
+ } finally {
165
+ rmSync(root, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test("feedback: mixed pull-ignored between push events doesn't break push decay", () => {
170
+ const root = tempRoot();
171
+ try {
172
+ // push ignored -> pull ignored (no outcome) -> push ignored: streak of 2 ignores.
173
+ logEvent(root, { type: "suggested", id: "x", prompt: "real prompt one here" });
174
+ logEvent(root, { type: "suggested", id: "x", source: "pull", query: "lookup" });
175
+ logEvent(root, { type: "suggested", id: "x", prompt: "real prompt two here" });
176
+ const factors = computeFactors(root);
177
+ assert.equal(factors.get("x"), Math.max(0.5, 1 - 0.15 * 2));
178
+ } finally {
179
+ rmSync(root, { recursive: true, force: true });
180
+ }
181
+ });
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { resolveRuntimeRoot } from "./bundle-state.mjs";
7
+
8
+ test("resolveRuntimeRoot prefers a workspace recipe and otherwise uses the package root", () => {
9
+ const workspace = mkdtempSync(join(tmpdir(), "portawhip-workspace-"));
10
+ const packageRoot = mkdtempSync(join(tmpdir(), "portawhip-package-"));
11
+ try {
12
+ writeFileSync(join(packageRoot, "recipe.yaml"), "[]\n");
13
+ assert.equal(resolveRuntimeRoot(workspace, packageRoot), packageRoot);
14
+
15
+ writeFileSync(join(workspace, "recipe.yaml"), "[]\n");
16
+ assert.equal(resolveRuntimeRoot(workspace, packageRoot), workspace);
17
+ } finally {
18
+ rmSync(workspace, { recursive: true, force: true });
19
+ rmSync(packageRoot, { recursive: true, force: true });
20
+ }
21
+ });
22
+
@@ -0,0 +1,102 @@
1
+ // Router gap found in review: nothing in the scoring pipeline knows what
2
+ // language/stack the CALLING project actually is, even though the eval
3
+ // harness's own failures are exactly cross-language mismatches (cpp-testing
4
+ // outscoring python-testing on a literal "pytest" prompt). Detecting the
5
+ // project's real stack from marker files and turning it into a per-id
6
+ // score factor is a cheap, deterministic, zero-dependency fix — reuses the
7
+ // same factor-multiplier seam core/feedback.mjs already established.
8
+
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ const STACK_MARKERS = {
13
+ python: ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"],
14
+ javascript: ["package.json"],
15
+ go: ["go.mod"],
16
+ rust: ["Cargo.toml"],
17
+ java: ["pom.xml", "build.gradle", "build.gradle.kts"],
18
+ ruby: ["Gemfile"],
19
+ php: ["composer.json"],
20
+ swift: ["Package.swift"],
21
+ dart: ["pubspec.yaml"],
22
+ cpp: ["CMakeLists.txt"],
23
+ };
24
+
25
+ const STACK_EXTENSIONS = {
26
+ csharp: [".csproj", ".sln"],
27
+ };
28
+
29
+ // What language a capability doc is ABOUT, inferred from its id — this
30
+ // ecosystem consistently names language-scoped skills/agents "<lang>-...."
31
+ // (python-testing, cpp-reviewer, golang-patterns) or names a framework that
32
+ // implies one language (django-tdd implies python, quarkus-patterns implies
33
+ // java). Only doc ids that hit one of these keywords are touched at all;
34
+ // generic entries (code-review, tdd-workflow, git-workflow) are never
35
+ // boosted or demoted by stack detection.
36
+ const DOC_STACK_KEYWORDS = {
37
+ python: ["python", "django", "flask", "fastapi", "pytest"],
38
+ javascript: ["javascript", "typescript", "react", "vue", "node", "nextjs", "nuxt", "angular", "bun", "vite"],
39
+ go: ["golang", "go-"],
40
+ rust: ["rust", "cargo"],
41
+ java: ["java", "spring", "quarkus", "jpa"],
42
+ ruby: ["ruby", "rails"],
43
+ php: ["php", "laravel"],
44
+ swift: ["swift"],
45
+ dart: ["dart", "flutter"],
46
+ cpp: ["cpp"],
47
+ csharp: ["csharp", "dotnet"],
48
+ kotlin: ["kotlin"],
49
+ };
50
+
51
+ export function detectStack(cwd) {
52
+ const detected = new Set();
53
+ for (const [lang, markers] of Object.entries(STACK_MARKERS)) {
54
+ if (markers.some((marker) => existsSync(join(cwd, marker)))) detected.add(lang);
55
+ }
56
+ let entries = [];
57
+ try {
58
+ entries = readdirSync(cwd);
59
+ } catch {
60
+ entries = [];
61
+ }
62
+ for (const [lang, exts] of Object.entries(STACK_EXTENSIONS)) {
63
+ if (entries.some((name) => exts.some((ext) => name.endsWith(ext)))) detected.add(lang);
64
+ }
65
+ return detected;
66
+ }
67
+
68
+ function docStacks(id) {
69
+ const idLower = id.toLowerCase();
70
+ const stacks = new Set();
71
+ for (const [lang, keywords] of Object.entries(DOC_STACK_KEYWORDS)) {
72
+ if (keywords.some((kw) => idLower.includes(kw))) stacks.add(lang);
73
+ }
74
+ return stacks;
75
+ }
76
+
77
+ // Precision-first (PLAN.md: "precision over recall") — only ever touches
78
+ // docs that are unambiguously about a specific stack, and only ever acts
79
+ // once at least one stack was actually detected in cwd. An unrecognized or
80
+ // empty project must not demote anything.
81
+ export function stackFactors(index, cwd, { boost = 1.3, demote = 0.4 } = {}) {
82
+ const factors = new Map();
83
+ const detected = detectStack(cwd);
84
+ if (detected.size === 0) return factors;
85
+ for (const entry of index.entries) {
86
+ const stacks = docStacks(entry.id);
87
+ if (stacks.size === 0) continue;
88
+ const overlaps = [...stacks].some((lang) => detected.has(lang));
89
+ factors.set(entry.id, overlaps ? boost : demote);
90
+ }
91
+ return factors;
92
+ }
93
+
94
+ export function combineFactors(...maps) {
95
+ const combined = new Map();
96
+ for (const map of maps) {
97
+ for (const [id, value] of map) {
98
+ combined.set(id, (combined.get(id) ?? 1.0) * value);
99
+ }
100
+ }
101
+ return combined;
102
+ }
@@ -0,0 +1,64 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { detectStack, stackFactors, combineFactors } from "./stack-detect.mjs";
7
+
8
+ function tempDir() {
9
+ return mkdtempSync(join(tmpdir(), "stack-detect-test-"));
10
+ }
11
+
12
+ test("detectStack: empty dir detects nothing", () => {
13
+ const dir = tempDir();
14
+ try {
15
+ assert.deepEqual(detectStack(dir), new Set());
16
+ } finally {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ test("detectStack: requirements.txt marks python", () => {
22
+ const dir = tempDir();
23
+ try {
24
+ writeFileSync(join(dir, "requirements.txt"), "pytest\n");
25
+ assert.ok(detectStack(dir).has("python"));
26
+ } finally {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test("stackFactors: empty cwd returns no factors (no demotion on unknown project)", () => {
32
+ const dir = tempDir();
33
+ try {
34
+ const index = { entries: [{ id: "cpp-testing" }, { id: "python-testing" }] };
35
+ const factors = stackFactors(index, dir);
36
+ assert.equal(factors.size, 0);
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("stackFactors: python project demotes cpp-testing, boosts python-testing, leaves generic docs alone", () => {
43
+ const dir = tempDir();
44
+ try {
45
+ writeFileSync(join(dir, "requirements.txt"), "pytest\n");
46
+ const index = {
47
+ entries: [{ id: "cpp-testing" }, { id: "python-testing" }, { id: "code-review" }],
48
+ };
49
+ const factors = stackFactors(index, dir);
50
+ assert.equal(factors.get("cpp-testing"), 0.4);
51
+ assert.equal(factors.get("python-testing"), 1.3);
52
+ assert.equal(factors.has("code-review"), false);
53
+ } finally {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test("combineFactors: multiplies overlapping ids, keeps non-overlapping ones", () => {
59
+ const a = new Map([["x", 2.0], ["y", 0.5]]);
60
+ const b = new Map([["x", 1.3]]);
61
+ const combined = combineFactors(a, b);
62
+ assert.equal(combined.get("x"), 2.6);
63
+ assert.equal(combined.get("y"), 0.5);
64
+ });