@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.
@@ -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
+ }