portawhip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +1,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
|
+
});
|