pi-harness-runtime 1.1.404 → 1.1.406
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/index.ts +275 -1
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/dist/adapters/line-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.js +6 -1
- package/packages/notification/dist/adapters/line-adapter.js.map +1 -1
- package/packages/notification/dist/notification-center.d.ts.map +1 -1
- package/packages/notification/dist/notification-center.js +32 -0
- package/packages/notification/dist/notification-center.js.map +1 -1
- package/packages/notification/dist/telegram-webhook-handler.d.ts +7 -0
- package/packages/notification/dist/telegram-webhook-handler.d.ts.map +1 -1
- package/packages/notification/dist/telegram-webhook-handler.js +7 -0
- package/packages/notification/dist/telegram-webhook-handler.js.map +1 -1
- package/packages/notification/dist/types.d.ts +1 -1
- package/packages/notification/dist/types.d.ts.map +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/notification/src/adapters/line-adapter.ts +7 -1
- package/packages/notification/src/notification-center.ts +30 -0
- package/packages/notification/src/telegram-webhook-handler.ts +7 -0
- package/packages/notification/src/types.ts +1 -0
- package/packages/notification/test/notification.test.ts +31 -28
- package/packages/notification/test/tooling-proposal-payload.test.ts +212 -0
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tooling-preference/dist/detect.d.ts +62 -0
- package/packages/tooling-preference/dist/detect.d.ts.map +1 -0
- package/packages/tooling-preference/dist/detect.js +209 -0
- package/packages/tooling-preference/dist/detect.js.map +1 -0
- package/packages/tooling-preference/dist/index.d.ts +13 -0
- package/packages/tooling-preference/dist/index.d.ts.map +1 -0
- package/packages/tooling-preference/dist/index.js +13 -0
- package/packages/tooling-preference/dist/index.js.map +1 -0
- package/packages/tooling-preference/dist/proposal.d.ts +70 -0
- package/packages/tooling-preference/dist/proposal.d.ts.map +1 -0
- package/packages/tooling-preference/dist/proposal.js +137 -0
- package/packages/tooling-preference/dist/proposal.js.map +1 -0
- package/packages/tooling-preference/dist/registry.d.ts +50 -0
- package/packages/tooling-preference/dist/registry.d.ts.map +1 -0
- package/packages/tooling-preference/dist/registry.js +53 -0
- package/packages/tooling-preference/dist/registry.js.map +1 -0
- package/packages/tooling-preference/dist/store.d.ts +61 -0
- package/packages/tooling-preference/dist/store.d.ts.map +1 -0
- package/packages/tooling-preference/dist/store.js +129 -0
- package/packages/tooling-preference/dist/store.js.map +1 -0
- package/packages/tooling-preference/package.json +21 -0
- package/packages/tooling-preference/src/detect.ts +251 -0
- package/packages/tooling-preference/src/index.ts +44 -0
- package/packages/tooling-preference/src/proposal.ts +173 -0
- package/packages/tooling-preference/src/registry.ts +79 -0
- package/packages/tooling-preference/src/store.ts +162 -0
- package/packages/tooling-preference/tests/detect.test.ts +188 -0
- package/packages/tooling-preference/tests/proposal.test.ts +107 -0
- package/packages/tooling-preference/tests/registry.test.ts +51 -0
- package/packages/tooling-preference/tests/store.test.ts +99 -0
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tooling proposal: request IDs, buttons and the wording.
|
|
3
|
+
*
|
|
4
|
+
* The proposal is a decision of its own. It never travels through the
|
|
5
|
+
* task-approval path, because a "No" there pauses the work and a "Yes"
|
|
6
|
+
* there resumes it - neither belongs to a tooling preference.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import type { ToolingContext } from "./detect.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The callback action of the proposal.
|
|
14
|
+
*
|
|
15
|
+
* The callback data is parsed by splitting on "_", so an action must be a
|
|
16
|
+
* single token: an action that carries an underscore would be read as the
|
|
17
|
+
* action up to that underscore, and the rest as the target.
|
|
18
|
+
*/
|
|
19
|
+
export const TOOLING_ACTION = "biome";
|
|
20
|
+
|
|
21
|
+
/** The answer of the user. */
|
|
22
|
+
export type ToolingChoice = "yes" | "no";
|
|
23
|
+
|
|
24
|
+
/** The saved preference of a user in a workspace. */
|
|
25
|
+
export type ToolingPreference = "prefer_biome" | "use_project_tools";
|
|
26
|
+
|
|
27
|
+
export interface ToolingButton {
|
|
28
|
+
/** The label of the button. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** The callback action - always `TOOLING_ACTION`. */
|
|
31
|
+
action: string;
|
|
32
|
+
/** The choice and the request ID, e.g. `yes-biome-<hash>`. */
|
|
33
|
+
targetId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToolingTarget {
|
|
37
|
+
choice: ToolingChoice;
|
|
38
|
+
requestId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortHash(value: string, length = 16): string {
|
|
42
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The canonical request ID of one proposal.
|
|
47
|
+
*
|
|
48
|
+
* The request ID is the correlation key: both channels send the same ID,
|
|
49
|
+
* and the first answer wins. It only holds hex characters and dashes so it
|
|
50
|
+
* survives the "_" split of the callback data.
|
|
51
|
+
*/
|
|
52
|
+
export function buildToolingRequestId(userKey: string, workspaceRoot: string): string {
|
|
53
|
+
return `biome-${shortHash(`${userKey} ${workspaceRoot}`)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The key of the user the proposal is sent to.
|
|
58
|
+
*
|
|
59
|
+
* The identity is hashed: the store holds the key of a user, never the id
|
|
60
|
+
* of the user.
|
|
61
|
+
*/
|
|
62
|
+
export function buildToolingUserKey(identity: string): string {
|
|
63
|
+
const trimmed = identity.trim();
|
|
64
|
+
if (!trimmed) return "default";
|
|
65
|
+
return `u-${shortHash(`user:${trimmed}`)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The two buttons of the proposal.
|
|
70
|
+
*/
|
|
71
|
+
export function buildToolingButtons(requestId: string): ToolingButton[] {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
text: "Yes - use Biome.js",
|
|
75
|
+
action: TOOLING_ACTION,
|
|
76
|
+
targetId: `yes-${requestId}`,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
text: "No - keep the current tools",
|
|
80
|
+
action: TOOLING_ACTION,
|
|
81
|
+
targetId: `no-${requestId}`,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the choice and the request ID out of a callback target.
|
|
88
|
+
*
|
|
89
|
+
* A target that does not belong to the proposal returns `null` - a click on
|
|
90
|
+
* a foreign button must never resolve a preference.
|
|
91
|
+
*/
|
|
92
|
+
export function parseToolingTarget(targetId: string | undefined): ToolingTarget | null {
|
|
93
|
+
if (!targetId) return null;
|
|
94
|
+
const match = /^(yes|no)-(.+)$/.exec(targetId);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
return { choice: match[1] as ToolingChoice, requestId: match[2] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The wording of the proposal.
|
|
101
|
+
*/
|
|
102
|
+
export function buildToolingProposalMessage(context: ToolingContext): string {
|
|
103
|
+
const frameworks: string[] = [];
|
|
104
|
+
if (context.isNextJs) frameworks.push("Next.js");
|
|
105
|
+
if (context.isReact && !context.isNextJs) frameworks.push("React");
|
|
106
|
+
|
|
107
|
+
const tools: string[] = [];
|
|
108
|
+
if (context.usesEslint) tools.push("ESLint");
|
|
109
|
+
if (context.usesPrettier) tools.push("Prettier");
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(
|
|
113
|
+
frameworks.length > 0
|
|
114
|
+
? `This workspace looks like a ${frameworks.join(" + ")} project that still uses ${tools.join(" and ")}.`
|
|
115
|
+
: `This workspace looks like a JavaScript/TypeScript project that still uses ${tools.join(" and ")}.`,
|
|
116
|
+
);
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push("Biome.js can replace both: one tool for linting, formatting and import order.");
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(`Detected: ${tools.join(", ")}${context.packageManager ? ` - ${context.packageManager}` : ""}`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push(
|
|
123
|
+
"Yes adds Biome and migrates the lint/format setup here. No keeps the current tools. Nothing changes until you answer.",
|
|
124
|
+
);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The instruction the agent receives after a "Yes".
|
|
130
|
+
*
|
|
131
|
+
* The agent does the repository work: the harness only records the
|
|
132
|
+
* decision and starts the implementation.
|
|
133
|
+
*/
|
|
134
|
+
export function buildToolingImplementationPrompt(
|
|
135
|
+
context: ToolingContext,
|
|
136
|
+
workspaceRoot: string,
|
|
137
|
+
): string {
|
|
138
|
+
const frameworks: string[] = [];
|
|
139
|
+
if (context.isNextJs) frameworks.push("Next.js");
|
|
140
|
+
if (context.isReact && !context.isNextJs) frameworks.push("React");
|
|
141
|
+
|
|
142
|
+
const manager = context.packageManager;
|
|
143
|
+
let install = "install it as a dev dependency with the package manager of the workspace";
|
|
144
|
+
if (manager === "npm") {
|
|
145
|
+
install = "install it with npm install --save-dev";
|
|
146
|
+
} else if (manager) {
|
|
147
|
+
install = `install it with ${manager} add --save-dev`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
lines.push(
|
|
152
|
+
`The user chose to use Biome.js in this workspace (${workspaceRoot}). Implement it now.`,
|
|
153
|
+
);
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push("1. Add Biome to the dev dependencies: " + install + " @biomejs/biome.");
|
|
156
|
+
lines.push("2. Add a biome.json that fits the project:");
|
|
157
|
+
if (frameworks.length > 0) {
|
|
158
|
+
lines.push(` - enable the ${frameworks.join(" and ")} rules,`);
|
|
159
|
+
}
|
|
160
|
+
lines.push(" - enable the formatter, the linter and the import organizer,");
|
|
161
|
+
lines.push(" - keep the settings that match the existing code style (indentation, quotes, semicolons).");
|
|
162
|
+
lines.push(
|
|
163
|
+
"3. Replace the lint and format scripts in package.json with the Biome equivalents (biome check, biome format, biome ci).",
|
|
164
|
+
);
|
|
165
|
+
lines.push(
|
|
166
|
+
"4. Migrate the ESLint and Prettier configuration that Biome replaces and remove the files Biome fully replaces.",
|
|
167
|
+
);
|
|
168
|
+
lines.push("5. Keep the type check and the tests as they are - Biome does not replace them.");
|
|
169
|
+
lines.push("6. Run the Biome check, fix what it reports in the files you touched, and report the result.");
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push("Do not touch files outside this workspace and do not change the public behaviour of the code.");
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending proposals of this session.
|
|
3
|
+
*
|
|
4
|
+
* The first answer wins: a second click on the other button changes
|
|
5
|
+
* nothing. The registry is in memory only - a proposal that was never
|
|
6
|
+
* answered simply disappears with the session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolingChoice, ToolingPreference } from "./proposal.js";
|
|
10
|
+
|
|
11
|
+
export interface ToolingRequest {
|
|
12
|
+
/** The canonical request ID of the proposal. */
|
|
13
|
+
requestId: string;
|
|
14
|
+
/** The workspace the proposal belongs to. */
|
|
15
|
+
workspaceRoot: string;
|
|
16
|
+
/** The user the proposal was sent to. */
|
|
17
|
+
userKey: string;
|
|
18
|
+
/** The tooling facts of the workspace. */
|
|
19
|
+
frameworks: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolingResolution {
|
|
23
|
+
choice: ToolingChoice;
|
|
24
|
+
preference: ToolingPreference;
|
|
25
|
+
channel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ToolingRequestRegistry {
|
|
29
|
+
private readonly pending = new Map<string, ToolingRequest>();
|
|
30
|
+
private readonly resolved = new Map<string, ToolingResolution>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register a proposal. A request ID that is already known is refused.
|
|
34
|
+
*/
|
|
35
|
+
register(request: ToolingRequest): boolean {
|
|
36
|
+
if (this.pending.has(request.requestId) || this.resolved.has(request.requestId)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
this.pending.set(request.requestId, request);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is the request still waiting for an answer?
|
|
45
|
+
*/
|
|
46
|
+
has(requestId: string): boolean {
|
|
47
|
+
return this.pending.has(requestId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Claim a request for an answer.
|
|
52
|
+
*
|
|
53
|
+
* The claim removes the request, so a second click finds nothing and the
|
|
54
|
+
* first answer stays the one that counts.
|
|
55
|
+
*/
|
|
56
|
+
claim(
|
|
57
|
+
requestId: string,
|
|
58
|
+
choice: ToolingChoice,
|
|
59
|
+
channel?: string,
|
|
60
|
+
): { request: ToolingRequest; resolution: ToolingResolution } | null {
|
|
61
|
+
const request = this.pending.get(requestId);
|
|
62
|
+
if (!request) return null;
|
|
63
|
+
this.pending.delete(requestId);
|
|
64
|
+
const resolution: ToolingResolution = {
|
|
65
|
+
choice,
|
|
66
|
+
preference: choice === "yes" ? "prefer_biome" : "use_project_tools",
|
|
67
|
+
...(channel ? { channel } : {}),
|
|
68
|
+
};
|
|
69
|
+
this.resolved.set(requestId, resolution);
|
|
70
|
+
return { request, resolution };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The answer of a request that was already resolved.
|
|
75
|
+
*/
|
|
76
|
+
resolutionOf(requestId: string): ToolingResolution | undefined {
|
|
77
|
+
return this.resolved.get(requestId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The saved tooling preference of a user in a workspace.
|
|
3
|
+
*
|
|
4
|
+
* The store is a plain JSON file under the harness runtime directory: it
|
|
5
|
+
* works in every runtime (a SQLite file would not - `node:sqlite` is
|
|
6
|
+
* missing in some of them) and it stays readable for the user.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import type { ToolingPreference } from "./proposal.js";
|
|
14
|
+
|
|
15
|
+
export interface ToolingPreferenceRecord {
|
|
16
|
+
/** The saved decision, when the user answered. */
|
|
17
|
+
decision?: ToolingPreference;
|
|
18
|
+
/** When the proposal was sent. */
|
|
19
|
+
askedAt?: string;
|
|
20
|
+
/** When the user answered. */
|
|
21
|
+
decidedAt?: string;
|
|
22
|
+
/** The request ID of the proposal that was sent. */
|
|
23
|
+
requestId?: string;
|
|
24
|
+
/** The workspace the record belongs to. */
|
|
25
|
+
workspaceRoot?: string;
|
|
26
|
+
/** The channel the answer came from. */
|
|
27
|
+
channel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type StoreFile = Record<string, ToolingPreferenceRecord>;
|
|
31
|
+
|
|
32
|
+
function shortHash(value: string, length = 16): string {
|
|
33
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The key of one record: the user and the workspace.
|
|
38
|
+
*/
|
|
39
|
+
export function toolingPreferenceKey(userKey: string, workspaceRoot: string): string {
|
|
40
|
+
return `${userKey}::${shortHash(workspaceRoot)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The runtime directory of the harness.
|
|
45
|
+
*/
|
|
46
|
+
export function harnessRuntimeDir(): string {
|
|
47
|
+
const configured = process.env.PI_HARNESS_HOME?.trim();
|
|
48
|
+
if (configured) return configured;
|
|
49
|
+
return join(homedir(), ".pi-harness-runtime");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ToolingPreferenceStore {
|
|
53
|
+
constructor(private readonly filePath: string) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The default store: `~/.pi-harness-runtime/tooling-preference.json`.
|
|
57
|
+
*/
|
|
58
|
+
static defaultFilePath(): string {
|
|
59
|
+
return join(harnessRuntimeDir(), "tooling-preference.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The record of one user in one workspace.
|
|
64
|
+
*/
|
|
65
|
+
read(userKey: string, workspaceRoot: string): ToolingPreferenceRecord | undefined {
|
|
66
|
+
const data = this.load();
|
|
67
|
+
return data[toolingPreferenceKey(userKey, workspaceRoot)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remember that the proposal was sent.
|
|
72
|
+
*/
|
|
73
|
+
markAsked(
|
|
74
|
+
userKey: string,
|
|
75
|
+
workspaceRoot: string,
|
|
76
|
+
requestId: string,
|
|
77
|
+
now: Date = new Date(),
|
|
78
|
+
): void {
|
|
79
|
+
const key = toolingPreferenceKey(userKey, workspaceRoot);
|
|
80
|
+
const data = this.load();
|
|
81
|
+
const existing = data[key] ?? {};
|
|
82
|
+
data[key] = {
|
|
83
|
+
...existing,
|
|
84
|
+
askedAt: now.toISOString(),
|
|
85
|
+
requestId,
|
|
86
|
+
workspaceRoot,
|
|
87
|
+
};
|
|
88
|
+
this.save(data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Save the answer of the user.
|
|
93
|
+
*/
|
|
94
|
+
recordDecision(
|
|
95
|
+
userKey: string,
|
|
96
|
+
workspaceRoot: string,
|
|
97
|
+
decision: ToolingPreference,
|
|
98
|
+
requestId: string,
|
|
99
|
+
channel?: string,
|
|
100
|
+
now: Date = new Date(),
|
|
101
|
+
): void {
|
|
102
|
+
const key = toolingPreferenceKey(userKey, workspaceRoot);
|
|
103
|
+
const data = this.load();
|
|
104
|
+
const existing = data[key] ?? {};
|
|
105
|
+
data[key] = {
|
|
106
|
+
...existing,
|
|
107
|
+
decision,
|
|
108
|
+
decidedAt: now.toISOString(),
|
|
109
|
+
askedAt: existing.askedAt ?? now.toISOString(),
|
|
110
|
+
requestId,
|
|
111
|
+
workspaceRoot,
|
|
112
|
+
...(channel ? { channel } : {}),
|
|
113
|
+
};
|
|
114
|
+
this.save(data);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private load(): StoreFile {
|
|
118
|
+
if (!existsSync(this.filePath)) return {};
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf8")) as unknown;
|
|
121
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
122
|
+
return parsed as StoreFile;
|
|
123
|
+
} catch {
|
|
124
|
+
// A broken store must not stop the harness: the proposal is
|
|
125
|
+
// simply asked again.
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private save(data: StoreFile): void {
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
133
|
+
const temporary = `${this.filePath}.tmp`;
|
|
134
|
+
writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
135
|
+
// The rename keeps a half written file from being read.
|
|
136
|
+
renameSync(temporary, this.filePath);
|
|
137
|
+
} catch {
|
|
138
|
+
// A store that cannot be written is a lost preference, not a
|
|
139
|
+
// broken harness.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* May the proposal be sent again?
|
|
146
|
+
*
|
|
147
|
+
* A workspace that was asked and never answered is asked again after the
|
|
148
|
+
* cooldown - the user may have missed it. A workspace with a decision is
|
|
149
|
+
* never asked again.
|
|
150
|
+
*/
|
|
151
|
+
export function shouldAskAgain(
|
|
152
|
+
record: ToolingPreferenceRecord | undefined,
|
|
153
|
+
cooldownMs: number,
|
|
154
|
+
now: Date = new Date(),
|
|
155
|
+
): boolean {
|
|
156
|
+
if (!record) return true;
|
|
157
|
+
if (record.decision) return false;
|
|
158
|
+
if (!record.askedAt) return true;
|
|
159
|
+
const askedAt = Date.parse(record.askedAt);
|
|
160
|
+
if (Number.isNaN(askedAt)) return true;
|
|
161
|
+
return now.getTime() - askedAt >= cooldownMs;
|
|
162
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
detectToolingContext,
|
|
7
|
+
evaluateBiomeProposalEligibility,
|
|
8
|
+
} from "../src/detect.ts";
|
|
9
|
+
|
|
10
|
+
function workspace(files: Record<string, string>): string {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "tooling-preference-"));
|
|
12
|
+
for (const [name, content] of Object.entries(files)) {
|
|
13
|
+
const path = join(root, name);
|
|
14
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
15
|
+
writeFileSync(path, content, "utf8");
|
|
16
|
+
}
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanup(root: string): void {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("detectToolingContext", () => {
|
|
25
|
+
it("detects a Next.js + React + TypeScript workspace with ESLint and Prettier", () => {
|
|
26
|
+
const root = workspace({
|
|
27
|
+
"package.json": JSON.stringify({
|
|
28
|
+
name: "web",
|
|
29
|
+
dependencies: { next: "15.0.0", react: "19.0.0" },
|
|
30
|
+
devDependencies: { eslint: "^9.0.0", prettier: "^3.0.0", typescript: "^5.7.0" },
|
|
31
|
+
}),
|
|
32
|
+
"tsconfig.json": "{}",
|
|
33
|
+
".eslintrc.json": "{}",
|
|
34
|
+
".prettierrc": "{}",
|
|
35
|
+
"src/app/page.tsx": "export const x = 1;",
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
const context = detectToolingContext(root);
|
|
39
|
+
expect(context.hasPackageJson).toBe(true);
|
|
40
|
+
expect(context.isJavaScriptTypeScript).toBe(true);
|
|
41
|
+
expect(context.isNextJs).toBe(true);
|
|
42
|
+
expect(context.isReact).toBe(true);
|
|
43
|
+
expect(context.usesBiome).toBe(false);
|
|
44
|
+
expect(context.usesEslint).toBe(true);
|
|
45
|
+
expect(context.usesPrettier).toBe(true);
|
|
46
|
+
expect(context.frameworks).toContain("next");
|
|
47
|
+
} finally {
|
|
48
|
+
cleanup(root);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reports a workspace that already uses Biome", () => {
|
|
53
|
+
const root = workspace({
|
|
54
|
+
"package.json": JSON.stringify({ name: "web", devDependencies: { eslint: "^9.0.0" } }),
|
|
55
|
+
"biome.json": "{}",
|
|
56
|
+
"tsconfig.json": "{}",
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const context = detectToolingContext(root);
|
|
60
|
+
expect(context.usesBiome).toBe(true);
|
|
61
|
+
} finally {
|
|
62
|
+
cleanup(root);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("detects Biome through the dependency alone", () => {
|
|
67
|
+
const root = workspace({
|
|
68
|
+
"package.json": JSON.stringify({
|
|
69
|
+
name: "web",
|
|
70
|
+
devDependencies: { "@biomejs/biome": "^2.4.7" },
|
|
71
|
+
}),
|
|
72
|
+
"tsconfig.json": "{}",
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
expect(detectToolingContext(root).usesBiome).toBe(true);
|
|
76
|
+
} finally {
|
|
77
|
+
cleanup(root);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("detects the package manager through the lockfile", () => {
|
|
82
|
+
const root = workspace({
|
|
83
|
+
"package.json": JSON.stringify({ name: "web" }),
|
|
84
|
+
"pnpm-lock.yaml": "",
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
expect(detectToolingContext(root).packageManager).toBe("pnpm");
|
|
88
|
+
} finally {
|
|
89
|
+
cleanup(root);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("prefers the declared package manager", () => {
|
|
94
|
+
const root = workspace({
|
|
95
|
+
"package.json": JSON.stringify({ name: "web", packageManager: "bun@1.3.0" }),
|
|
96
|
+
"package-lock.json": "{}",
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
expect(detectToolingContext(root).packageManager).toBe("bun");
|
|
100
|
+
} finally {
|
|
101
|
+
cleanup(root);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("finds a source file without a tsconfig", () => {
|
|
106
|
+
const root = workspace({
|
|
107
|
+
"package.json": JSON.stringify({ name: "web" }),
|
|
108
|
+
"src/index.ts": "export const x = 1;",
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
expect(detectToolingContext(root).isJavaScriptTypeScript).toBe(true);
|
|
112
|
+
} finally {
|
|
113
|
+
cleanup(root);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not walk into a dependency directory for sources", () => {
|
|
118
|
+
const root = workspace({
|
|
119
|
+
"package.json": JSON.stringify({ name: "web" }),
|
|
120
|
+
"node_modules/some-package/index.js": "module.exports = 1;",
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
expect(detectToolingContext(root).isJavaScriptTypeScript).toBe(false);
|
|
124
|
+
} finally {
|
|
125
|
+
cleanup(root);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("evaluateBiomeProposalEligibility", () => {
|
|
131
|
+
it("offers the proposal for a JS/TS workspace with ESLint or Prettier", () => {
|
|
132
|
+
const root = workspace({
|
|
133
|
+
"package.json": JSON.stringify({ name: "web", devDependencies: { eslint: "^9.0.0" } }),
|
|
134
|
+
"tsconfig.json": "{}",
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
const decision = evaluateBiomeProposalEligibility(detectToolingContext(root));
|
|
138
|
+
expect(decision.eligible).toBe(true);
|
|
139
|
+
} finally {
|
|
140
|
+
cleanup(root);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("never asks a workspace that already uses Biome", () => {
|
|
145
|
+
const root = workspace({
|
|
146
|
+
"package.json": JSON.stringify({
|
|
147
|
+
name: "web",
|
|
148
|
+
devDependencies: { eslint: "^9.0.0", "@biomejs/biome": "^2.4.7" },
|
|
149
|
+
}),
|
|
150
|
+
"tsconfig.json": "{}",
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
const decision = evaluateBiomeProposalEligibility(detectToolingContext(root));
|
|
154
|
+
expect(decision.eligible).toBe(false);
|
|
155
|
+
expect(decision.reason).toContain("Biome");
|
|
156
|
+
} finally {
|
|
157
|
+
cleanup(root);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("never asks a workspace without ESLint and Prettier", () => {
|
|
162
|
+
const root = workspace({
|
|
163
|
+
"package.json": JSON.stringify({ name: "web" }),
|
|
164
|
+
"tsconfig.json": "{}",
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
const decision = evaluateBiomeProposalEligibility(detectToolingContext(root));
|
|
168
|
+
expect(decision.eligible).toBe(false);
|
|
169
|
+
expect(decision.reason).toContain("neither ESLint nor Prettier");
|
|
170
|
+
} finally {
|
|
171
|
+
cleanup(root);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("never asks a workspace that is not JavaScript/TypeScript", () => {
|
|
176
|
+
const root = workspace({
|
|
177
|
+
"package.json": JSON.stringify({ name: "docs" }),
|
|
178
|
+
"README.md": "# docs",
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
const decision = evaluateBiomeProposalEligibility(detectToolingContext(root));
|
|
182
|
+
expect(decision.eligible).toBe(false);
|
|
183
|
+
expect(decision.reason).toContain("not a JavaScript/TypeScript project");
|
|
184
|
+
} finally {
|
|
185
|
+
cleanup(root);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildToolingButtons,
|
|
4
|
+
buildToolingImplementationPrompt,
|
|
5
|
+
buildToolingProposalMessage,
|
|
6
|
+
buildToolingRequestId,
|
|
7
|
+
parseToolingTarget,
|
|
8
|
+
TOOLING_ACTION,
|
|
9
|
+
} from "../src/proposal.ts";
|
|
10
|
+
import type { ToolingContext } from "../src/detect.ts";
|
|
11
|
+
|
|
12
|
+
const context: ToolingContext = {
|
|
13
|
+
root: "/work/web",
|
|
14
|
+
hasPackageJson: true,
|
|
15
|
+
isJavaScriptTypeScript: true,
|
|
16
|
+
isNextJs: true,
|
|
17
|
+
isReact: true,
|
|
18
|
+
usesBiome: false,
|
|
19
|
+
usesEslint: true,
|
|
20
|
+
usesPrettier: true,
|
|
21
|
+
packageManager: "pnpm",
|
|
22
|
+
frameworks: ["next"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("buildToolingRequestId", () => {
|
|
26
|
+
it("is stable for the same user and workspace", () => {
|
|
27
|
+
const first = buildToolingRequestId("user-1", "/work/web");
|
|
28
|
+
const second = buildToolingRequestId("user-1", "/work/web");
|
|
29
|
+
expect(first).toBe(second);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("differs per user and per workspace", () => {
|
|
33
|
+
expect(buildToolingRequestId("user-1", "/work/web")).not.toBe(
|
|
34
|
+
buildToolingRequestId("user-2", "/work/web"),
|
|
35
|
+
);
|
|
36
|
+
expect(buildToolingRequestId("user-1", "/work/web")).not.toBe(
|
|
37
|
+
buildToolingRequestId("user-1", "/work/other"),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("holds no underscore, so the callback parser keeps it intact", () => {
|
|
42
|
+
const requestId = buildToolingRequestId("user-1", "/work/web");
|
|
43
|
+
expect(requestId).not.toContain("_");
|
|
44
|
+
expect(requestId.startsWith("biome-")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("buildToolingButtons", () => {
|
|
49
|
+
it("sends one action with a choice per button", () => {
|
|
50
|
+
const requestId = buildToolingRequestId("user-1", "/work/web");
|
|
51
|
+
const buttons = buildToolingButtons(requestId);
|
|
52
|
+
expect(buttons).toHaveLength(2);
|
|
53
|
+
for (const button of buttons) {
|
|
54
|
+
expect(button.action).toBe(TOOLING_ACTION);
|
|
55
|
+
expect(button.targetId).toContain(requestId);
|
|
56
|
+
}
|
|
57
|
+
expect(buttons[0].targetId.startsWith("yes-")).toBe(true);
|
|
58
|
+
expect(buttons[1].targetId.startsWith("no-")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the action a single token", () => {
|
|
62
|
+
expect(TOOLING_ACTION).not.toContain("_");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("parseToolingTarget", () => {
|
|
67
|
+
it("reads the choice and the request ID back", () => {
|
|
68
|
+
const requestId = buildToolingRequestId("user-1", "/work/web");
|
|
69
|
+
const [yes, no] = buildToolingButtons(requestId);
|
|
70
|
+
expect(parseToolingTarget(yes.targetId)).toEqual({ choice: "yes", requestId });
|
|
71
|
+
expect(parseToolingTarget(no.targetId)).toEqual({ choice: "no", requestId });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("refuses a target that is not part of the proposal", () => {
|
|
75
|
+
expect(parseToolingTarget("q-123")).toBeNull();
|
|
76
|
+
expect(parseToolingTarget("maybe-biome-abc")).toBeNull();
|
|
77
|
+
expect(parseToolingTarget("")).toBeNull();
|
|
78
|
+
expect(parseToolingTarget(undefined)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("survives the callback data round trip", () => {
|
|
82
|
+
const requestId = buildToolingRequestId("user-1", "/work/web");
|
|
83
|
+
const [yes] = buildToolingButtons(requestId);
|
|
84
|
+
const data = `${yes.action}_${yes.targetId}`;
|
|
85
|
+
const [action, targetId] = data.split("_");
|
|
86
|
+
expect(action).toBe(TOOLING_ACTION);
|
|
87
|
+
expect(parseToolingTarget(targetId)?.requestId).toBe(requestId);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("the wording", () => {
|
|
92
|
+
it("names the detected tools and the frameworks", () => {
|
|
93
|
+
const message = buildToolingProposalMessage(context);
|
|
94
|
+
expect(message).toContain("Next.js");
|
|
95
|
+
expect(message).toContain("ESLint");
|
|
96
|
+
expect(message).toContain("Prettier");
|
|
97
|
+
expect(message).toContain("Biome");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("asks the agent to implement Biome after a yes", () => {
|
|
101
|
+
const prompt = buildToolingImplementationPrompt(context, "/work/web");
|
|
102
|
+
expect(prompt).toContain("/work/web");
|
|
103
|
+
expect(prompt).toContain("@biomejs/biome");
|
|
104
|
+
expect(prompt).toContain("pnpm add --save-dev");
|
|
105
|
+
expect(prompt).toContain("Next.js");
|
|
106
|
+
});
|
|
107
|
+
});
|