@schilderlabs/pitown-package 0.2.1
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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +34 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +75 -0
- package/pi/agents/docs-keeper.md +14 -0
- package/pi/agents/leader.md +21 -0
- package/pi/agents/planner.md +15 -0
- package/pi/agents/reviewer.md +15 -0
- package/pi/agents/scout.md +15 -0
- package/pi/agents/supervisor.md +16 -0
- package/pi/agents/worker.md +15 -0
- package/pi/extensions/index.test.ts +288 -0
- package/pi/extensions/index.ts +64 -0
- package/pi/extensions/mayor-plan.ts +265 -0
- package/pi/extensions/require-tests/index.test.ts +119 -0
- package/pi/extensions/require-tests/index.ts +106 -0
- package/pi/extensions/town-tools.ts +309 -0
- package/pi/prompts/overnight-start.md +8 -0
- package/pi/prompts/retry-task.md +8 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core"
|
|
2
|
+
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"
|
|
4
|
+
import { resolveTownAgentContext } from "#pitown-town-tools"
|
|
5
|
+
|
|
6
|
+
interface PlanTodo {
|
|
7
|
+
step: number
|
|
8
|
+
text: string
|
|
9
|
+
completed: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PersistedMayorPlanState {
|
|
13
|
+
enabled?: boolean
|
|
14
|
+
savedTools?: string[]
|
|
15
|
+
todos?: PlanTodo[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PLAN_ALLOWED_TOOLS = new Set(["read", "grep", "find", "ls", "questionnaire", "pitown_board", "pitown_peek_agent"])
|
|
19
|
+
const PLAN_ENTRY_TYPE = "pitown-mayor-plan"
|
|
20
|
+
const PLAN_CONTEXT_TYPE = "pitown-mayor-plan-context"
|
|
21
|
+
const PLAN_CAPTURE_TYPE = "pitown-mayor-plan-captured"
|
|
22
|
+
|
|
23
|
+
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
|
|
24
|
+
return message.role === "assistant" && Array.isArray(message.content)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getAssistantText(message: AssistantMessage): string {
|
|
28
|
+
return message.content
|
|
29
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
30
|
+
.map((block) => block.text)
|
|
31
|
+
.join("\n")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dedupe(values: string[]): string[] {
|
|
35
|
+
return [...new Set(values)]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getAllToolNames(pi: ExtensionAPI): string[] {
|
|
39
|
+
return pi.getAllTools().map((tool) => tool.name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolvePlanTools(candidateTools: string[]): string[] {
|
|
43
|
+
return dedupe(candidateTools.filter((tool) => PLAN_ALLOWED_TOOLS.has(tool)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isMayorSession(ctx: ExtensionContext): boolean {
|
|
47
|
+
const context = resolveTownAgentContext(ctx.sessionManager.getSessionFile())
|
|
48
|
+
if (context === null) return false
|
|
49
|
+
return context.agentId === "leader" || context.role === "leader" || context.role === "mayor"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function persistPlanState(pi: ExtensionAPI, enabled: boolean, savedTools: string[], todos: PlanTodo[]) {
|
|
53
|
+
pi.appendEntry<PersistedMayorPlanState>(PLAN_ENTRY_TYPE, {
|
|
54
|
+
enabled,
|
|
55
|
+
savedTools,
|
|
56
|
+
todos,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractPlanTodos(text: string): PlanTodo[] {
|
|
61
|
+
const lines = text.split(/\r?\n/)
|
|
62
|
+
const startIndex = lines.findIndex((line) => /^plan:\s*$/i.test(line.trim()))
|
|
63
|
+
const scanFrom = startIndex === -1 ? 0 : startIndex + 1
|
|
64
|
+
const todos: PlanTodo[] = []
|
|
65
|
+
|
|
66
|
+
for (const line of lines.slice(scanFrom)) {
|
|
67
|
+
const match = line.match(/^\s*(\d+)[.)]\s+(.+?)\s*$/)
|
|
68
|
+
if (match) {
|
|
69
|
+
todos.push({
|
|
70
|
+
step: Number.parseInt(match[1] ?? "0", 10),
|
|
71
|
+
text: match[2] ?? "",
|
|
72
|
+
completed: false,
|
|
73
|
+
})
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (todos.length > 0 && line.trim() === "") break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return todos
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderTodos(todos: PlanTodo[]): string {
|
|
84
|
+
if (todos.length === 0) return "No captured plan steps yet. Ask the mayor to produce a numbered Plan: block."
|
|
85
|
+
return todos.map((todo) => `${todo.step}. ${todo.completed ? "✓" : "○"} ${todo.text}`).join("\n")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setPlanStatus(ctx: ExtensionContext, enabled: boolean, todos: PlanTodo[]) {
|
|
89
|
+
if (!ctx.hasUI) return
|
|
90
|
+
if (!enabled) {
|
|
91
|
+
ctx.ui.setStatus("pitown-plan", undefined)
|
|
92
|
+
ctx.ui.setWidget("pitown-plan", undefined)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ctx.ui.setStatus("pitown-plan", ctx.ui.theme.fg("warning", "plan"))
|
|
97
|
+
ctx.ui.setWidget(
|
|
98
|
+
"pitown-plan",
|
|
99
|
+
todos.length === 0 ? ["Mayor plan mode is active."] : todos.map((todo) => `${todo.step}. ${todo.text}`),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerMayorPlanMode(pi: ExtensionAPI) {
|
|
104
|
+
let planModeEnabled = false
|
|
105
|
+
let savedTools: string[] = []
|
|
106
|
+
let todos: PlanTodo[] = []
|
|
107
|
+
|
|
108
|
+
function enablePlanMode(ctx: ExtensionContext) {
|
|
109
|
+
const currentTools = pi.getActiveTools()
|
|
110
|
+
const fallbackTools = currentTools.length > 0 ? currentTools : getAllToolNames(pi)
|
|
111
|
+
if (savedTools.length === 0) savedTools = dedupe(fallbackTools)
|
|
112
|
+
|
|
113
|
+
const planTools = resolvePlanTools(savedTools)
|
|
114
|
+
pi.setActiveTools(planTools)
|
|
115
|
+
planModeEnabled = true
|
|
116
|
+
setPlanStatus(ctx, planModeEnabled, todos)
|
|
117
|
+
persistPlanState(pi, planModeEnabled, savedTools, todos)
|
|
118
|
+
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
"Mayor plan mode enabled. Planning is read-only. Use /todos to inspect captured steps and /plan again to leave plan mode.",
|
|
121
|
+
"info",
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function disablePlanMode(ctx: ExtensionContext) {
|
|
126
|
+
const restoreTools = savedTools.length > 0 ? savedTools : getAllToolNames(pi)
|
|
127
|
+
pi.setActiveTools(restoreTools)
|
|
128
|
+
planModeEnabled = false
|
|
129
|
+
setPlanStatus(ctx, planModeEnabled, todos)
|
|
130
|
+
persistPlanState(pi, planModeEnabled, savedTools, todos)
|
|
131
|
+
|
|
132
|
+
ctx.ui.notify("Mayor plan mode disabled. Delegation and execution tools are available again.", "info")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pi.registerFlag("plan", {
|
|
136
|
+
description: "Start the mayor in read-only planning mode",
|
|
137
|
+
type: "boolean",
|
|
138
|
+
default: false,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
pi.registerCommand("plan", {
|
|
142
|
+
description: "Toggle mayor planning mode",
|
|
143
|
+
handler: async (_args, ctx) => {
|
|
144
|
+
if (!isMayorSession(ctx)) {
|
|
145
|
+
ctx.ui.notify("/plan is only available in the mayor session.", "warning")
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (planModeEnabled) {
|
|
150
|
+
disablePlanMode(ctx)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
enablePlanMode(ctx)
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
pi.registerCommand("todos", {
|
|
159
|
+
description: "Show the mayor's captured plan steps",
|
|
160
|
+
handler: async (_args, ctx) => {
|
|
161
|
+
if (!isMayorSession(ctx)) {
|
|
162
|
+
ctx.ui.notify("/todos is only available in the mayor session.", "warning")
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ctx.ui.notify(renderTodos(todos), todos.length === 0 ? "info" : "success")
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
171
|
+
if (!isMayorSession(ctx)) return
|
|
172
|
+
|
|
173
|
+
const entry = ctx.sessionManager
|
|
174
|
+
.getEntries()
|
|
175
|
+
.filter((candidate: { type: string; customType?: string }) => {
|
|
176
|
+
return candidate.type === "custom" && candidate.customType === PLAN_ENTRY_TYPE
|
|
177
|
+
})
|
|
178
|
+
.pop() as { data?: PersistedMayorPlanState } | undefined
|
|
179
|
+
|
|
180
|
+
if (entry?.data?.savedTools) savedTools = dedupe(entry.data.savedTools)
|
|
181
|
+
if (entry?.data?.todos) todos = entry.data.todos
|
|
182
|
+
planModeEnabled = entry?.data?.enabled ?? false
|
|
183
|
+
|
|
184
|
+
if (pi.getFlag("plan") === true) {
|
|
185
|
+
planModeEnabled = true
|
|
186
|
+
if (savedTools.length === 0) {
|
|
187
|
+
const currentTools = pi.getActiveTools()
|
|
188
|
+
savedTools = dedupe(currentTools.length > 0 ? currentTools : getAllToolNames(pi))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (planModeEnabled) {
|
|
193
|
+
const planTools = resolvePlanTools(savedTools.length > 0 ? savedTools : getAllToolNames(pi))
|
|
194
|
+
pi.setActiveTools(planTools)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
setPlanStatus(ctx, planModeEnabled, todos)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
201
|
+
if (!planModeEnabled || !isMayorSession(ctx)) return undefined
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
message: {
|
|
205
|
+
customType: PLAN_CONTEXT_TYPE,
|
|
206
|
+
content: [
|
|
207
|
+
"[MAYOR PLAN MODE ACTIVE]",
|
|
208
|
+
"You are planning only.",
|
|
209
|
+
"Use read-only exploration and Pi Town inspection tools to understand the repo and the current town state.",
|
|
210
|
+
"Do not delegate work, do not claim that code changes were made, and do not switch into execution.",
|
|
211
|
+
"Produce a concise numbered plan under a `Plan:` header.",
|
|
212
|
+
"If the plan depends on active agents, inspect the board first.",
|
|
213
|
+
].join("\n"),
|
|
214
|
+
display: false,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
220
|
+
if (!planModeEnabled || !isMayorSession(ctx)) return undefined
|
|
221
|
+
|
|
222
|
+
if (!PLAN_ALLOWED_TOOLS.has(event.toolName)) {
|
|
223
|
+
return {
|
|
224
|
+
block: true,
|
|
225
|
+
reason: `Mayor plan mode only allows read-only tools. Disable /plan before using ${event.toolName}.`,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (event.toolName === "pitown_delegate" || event.toolName === "pitown_message_agent" || event.toolName === "pitown_update_status") {
|
|
230
|
+
return {
|
|
231
|
+
block: true,
|
|
232
|
+
reason: `Mayor plan mode blocks orchestration side effects. Disable /plan before using ${event.toolName}.`,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return undefined
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
240
|
+
if (!planModeEnabled || !isMayorSession(ctx)) return
|
|
241
|
+
|
|
242
|
+
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage)
|
|
243
|
+
if (!lastAssistant) return
|
|
244
|
+
|
|
245
|
+
const extractedTodos = extractPlanTodos(getAssistantText(lastAssistant))
|
|
246
|
+
if (extractedTodos.length === 0) {
|
|
247
|
+
persistPlanState(pi, planModeEnabled, savedTools, todos)
|
|
248
|
+
setPlanStatus(ctx, planModeEnabled, todos)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
todos = extractedTodos
|
|
253
|
+
persistPlanState(pi, planModeEnabled, savedTools, todos)
|
|
254
|
+
setPlanStatus(ctx, planModeEnabled, todos)
|
|
255
|
+
|
|
256
|
+
pi.sendMessage(
|
|
257
|
+
{
|
|
258
|
+
customType: PLAN_CAPTURE_TYPE,
|
|
259
|
+
content: `Plan captured.\n\n${renderTodos(todos)}\n\nStay in /plan to refine it, or run /plan again to leave planning mode and execute through the mayor.`,
|
|
260
|
+
display: true,
|
|
261
|
+
},
|
|
262
|
+
{ triggerTurn: false },
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
|
|
4
|
+
import requireTestsExtension from "./index"
|
|
5
|
+
|
|
6
|
+
type RegisteredHandler = (event: unknown, ctx: unknown) => unknown | Promise<unknown>
|
|
7
|
+
|
|
8
|
+
function setup() {
|
|
9
|
+
const handlers: Record<string, RegisteredHandler[]> = {}
|
|
10
|
+
const pi = {
|
|
11
|
+
on: vi.fn((event: string, handler: RegisteredHandler) => {
|
|
12
|
+
;(handlers[event] ??= []).push(handler)
|
|
13
|
+
}),
|
|
14
|
+
sendMessage: vi.fn(),
|
|
15
|
+
} as unknown as ExtensionAPI & {
|
|
16
|
+
sendMessage: ReturnType<typeof vi.fn>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
requireTestsExtension(pi)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
handlers,
|
|
23
|
+
pi,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("require-tests extension", () => {
|
|
28
|
+
it("queues a follow-up when implementation files change without tests", async () => {
|
|
29
|
+
const { handlers, pi } = setup()
|
|
30
|
+
|
|
31
|
+
await handlers["agent_start"]?.[0]?.({}, {})
|
|
32
|
+
await handlers["tool_result"]?.[0]?.(
|
|
33
|
+
{
|
|
34
|
+
toolName: "edit",
|
|
35
|
+
input: { path: "src/domain/create-widget.ts" },
|
|
36
|
+
content: [],
|
|
37
|
+
details: undefined,
|
|
38
|
+
isError: false,
|
|
39
|
+
},
|
|
40
|
+
{},
|
|
41
|
+
)
|
|
42
|
+
await handlers["agent_end"]?.[0]?.({ messages: [] }, {})
|
|
43
|
+
|
|
44
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
45
|
+
expect.objectContaining({
|
|
46
|
+
customType: "require-tests",
|
|
47
|
+
display: true,
|
|
48
|
+
content: expect.stringContaining("src/domain/create-widget.ts"),
|
|
49
|
+
}),
|
|
50
|
+
expect.objectContaining({ triggerTurn: true }),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("does not queue a follow-up when a test file changes in the same prompt", async () => {
|
|
55
|
+
const { handlers, pi } = setup()
|
|
56
|
+
|
|
57
|
+
await handlers["agent_start"]?.[0]?.({}, {})
|
|
58
|
+
await handlers["tool_result"]?.[0]?.(
|
|
59
|
+
{
|
|
60
|
+
toolName: "write",
|
|
61
|
+
input: { path: "src/domain/create-widget.ts" },
|
|
62
|
+
content: [],
|
|
63
|
+
details: undefined,
|
|
64
|
+
isError: false,
|
|
65
|
+
},
|
|
66
|
+
{},
|
|
67
|
+
)
|
|
68
|
+
await handlers["tool_result"]?.[0]?.(
|
|
69
|
+
{
|
|
70
|
+
toolName: "write",
|
|
71
|
+
input: { path: "src/domain/create-widget.test.ts" },
|
|
72
|
+
content: [],
|
|
73
|
+
details: undefined,
|
|
74
|
+
isError: false,
|
|
75
|
+
},
|
|
76
|
+
{},
|
|
77
|
+
)
|
|
78
|
+
await handlers["agent_end"]?.[0]?.({ messages: [] }, {})
|
|
79
|
+
|
|
80
|
+
expect(pi.sendMessage).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("does not queue a follow-up for UI-only changes", async () => {
|
|
84
|
+
const { handlers, pi } = setup()
|
|
85
|
+
|
|
86
|
+
await handlers["agent_start"]?.[0]?.({}, {})
|
|
87
|
+
await handlers["tool_result"]?.[0]?.(
|
|
88
|
+
{
|
|
89
|
+
toolName: "edit",
|
|
90
|
+
input: { path: "apps/native/src/screens/SettingsScreen.tsx" },
|
|
91
|
+
content: [],
|
|
92
|
+
details: undefined,
|
|
93
|
+
isError: false,
|
|
94
|
+
},
|
|
95
|
+
{},
|
|
96
|
+
)
|
|
97
|
+
await handlers["agent_end"]?.[0]?.({ messages: [] }, {})
|
|
98
|
+
|
|
99
|
+
expect(pi.sendMessage).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("injects a hidden reminder before each agent run", async () => {
|
|
103
|
+
const { handlers } = setup()
|
|
104
|
+
|
|
105
|
+
const result = await handlers["before_agent_start"]?.[0]?.(
|
|
106
|
+
{ systemPrompt: "base", prompt: "do work", images: [] },
|
|
107
|
+
{},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
message: expect.objectContaining({
|
|
113
|
+
customType: "require-tests",
|
|
114
|
+
display: false,
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import { isEditToolResult, isWriteToolResult } from "@mariozechner/pi-coding-agent"
|
|
3
|
+
|
|
4
|
+
const TEST_FILE_PATTERN = /(^|\/)(__tests__|tests?)\/|\.(test|spec)\.[^.]+$/i
|
|
5
|
+
const UI_DIRECTORY_PATTERNS = [/^apps\/native\//i, /^packages\/frontend-native\//i, /^packages\/ui-native\//i]
|
|
6
|
+
const UI_FILE_PATTERN = /\.(tsx|jsx)$/i
|
|
7
|
+
const IMPLEMENTATION_FILE_PATTERN = /\.(c|m)?(t|j)sx?$|\.(py|rb|go|rs|java|kt|swift)$/i
|
|
8
|
+
|
|
9
|
+
export interface ModifiedFileAnalysis {
|
|
10
|
+
modifiedFiles: string[]
|
|
11
|
+
implementationFiles: string[]
|
|
12
|
+
testFiles: string[]
|
|
13
|
+
requiresTests: boolean
|
|
14
|
+
hasRequiredTests: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeFilePath(filePath: string): string {
|
|
18
|
+
return filePath.replace(/^@/, "").replace(/\\/g, "/")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isTestFile(filePath: string): boolean {
|
|
22
|
+
return TEST_FILE_PATTERN.test(normalizeFilePath(filePath))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isUiOnlyFile(filePath: string): boolean {
|
|
26
|
+
const normalizedPath = normalizeFilePath(filePath)
|
|
27
|
+
return UI_FILE_PATTERN.test(normalizedPath) || UI_DIRECTORY_PATTERNS.some((pattern) => pattern.test(normalizedPath))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isImplementationFile(filePath: string): boolean {
|
|
31
|
+
const normalizedPath = normalizeFilePath(filePath)
|
|
32
|
+
if (!IMPLEMENTATION_FILE_PATTERN.test(normalizedPath)) return false
|
|
33
|
+
if (isTestFile(normalizedPath)) return false
|
|
34
|
+
if (isUiOnlyFile(normalizedPath)) return false
|
|
35
|
+
if (normalizedPath.endsWith(".d.ts")) return false
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function analyzeModifiedFiles(files: Iterable<string>): ModifiedFileAnalysis {
|
|
40
|
+
const modifiedFiles = [...new Set(Array.from(files, normalizeFilePath))]
|
|
41
|
+
const implementationFiles = modifiedFiles.filter(isImplementationFile)
|
|
42
|
+
const testFiles = modifiedFiles.filter(isTestFile)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
modifiedFiles,
|
|
46
|
+
implementationFiles,
|
|
47
|
+
testFiles,
|
|
48
|
+
requiresTests: implementationFiles.length > 0,
|
|
49
|
+
hasRequiredTests: testFiles.length > 0,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildReminderMessage(implementationFiles: string[]): string {
|
|
54
|
+
const changedFiles = implementationFiles.map((filePath) => `- ${filePath}`).join("\n")
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
"Test guard: you changed implementation code without also updating tests.",
|
|
58
|
+
"",
|
|
59
|
+
"Changed implementation files:",
|
|
60
|
+
changedFiles,
|
|
61
|
+
"",
|
|
62
|
+
"Before finishing, write or update tests that verify behavior through public interfaces.",
|
|
63
|
+
"Prefer integration-style tests that describe what the system does and can survive refactors.",
|
|
64
|
+
].join("\n")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function requireTestsExtension(pi: ExtensionAPI) {
|
|
68
|
+
let modifiedFiles = new Set<string>()
|
|
69
|
+
|
|
70
|
+
pi.on("before_agent_start", async () => ({
|
|
71
|
+
message: {
|
|
72
|
+
customType: "require-tests",
|
|
73
|
+
content:
|
|
74
|
+
"If you modify non-UI implementation files, also add or update tests in the same turn. UI-only TSX/JSX changes and files under apps/native, packages/frontend-native, or packages/ui-native are exempt.",
|
|
75
|
+
display: false,
|
|
76
|
+
},
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
pi.on("agent_start", async () => {
|
|
80
|
+
modifiedFiles = new Set<string>()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
pi.on("tool_result", async (event) => {
|
|
84
|
+
if (event.isError) return
|
|
85
|
+
if (!isEditToolResult(event) && !isWriteToolResult(event)) return
|
|
86
|
+
|
|
87
|
+
const filePath = event.input["path"]
|
|
88
|
+
if (typeof filePath !== "string") return
|
|
89
|
+
|
|
90
|
+
modifiedFiles.add(normalizeFilePath(filePath))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
pi.on("agent_end", async () => {
|
|
94
|
+
const analysis = analyzeModifiedFiles(modifiedFiles)
|
|
95
|
+
if (!analysis.requiresTests || analysis.hasRequiredTests) return
|
|
96
|
+
|
|
97
|
+
pi.sendMessage(
|
|
98
|
+
{
|
|
99
|
+
customType: "require-tests",
|
|
100
|
+
content: buildReminderMessage(analysis.implementationFiles),
|
|
101
|
+
display: true,
|
|
102
|
+
},
|
|
103
|
+
{ triggerTurn: true },
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
}
|