agent-simple-english 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +23 -0
  2. package/.claude-plugin/plugin.json +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +435 -0
  5. package/THIRD_PARTY_NOTICES.md +13 -0
  6. package/commands/ste.md +13 -0
  7. package/hooks/hooks.json +58 -0
  8. package/package.json +64 -0
  9. package/src/adapter/commit-message.ts +472 -0
  10. package/src/adapter/feedback.ts +40 -0
  11. package/src/adapter/rule-summary.ts +79 -0
  12. package/src/cli/hook.ts +681 -0
  13. package/src/cli/main.ts +201 -0
  14. package/src/cli/session-command.ts +78 -0
  15. package/src/cli/session-state.ts +214 -0
  16. package/src/config/load.ts +85 -0
  17. package/src/config/merge.ts +20 -0
  18. package/src/config/schema.ts +69 -0
  19. package/src/dictionary/README.md +18 -0
  20. package/src/dictionary/data/pi-ste.json +200 -0
  21. package/src/dictionary/form.ts +6 -0
  22. package/src/dictionary/load.ts +54 -0
  23. package/src/dictionary/schema.ts +28 -0
  24. package/src/engine/comments.ts +386 -0
  25. package/src/engine/diff.ts +328 -0
  26. package/src/engine/identifiers.ts +20 -0
  27. package/src/engine/kinds.ts +43 -0
  28. package/src/engine/lint.ts +530 -0
  29. package/src/engine/markdown.ts +338 -0
  30. package/src/engine/paragraphs.ts +105 -0
  31. package/src/engine/rules/contraction.ts +19 -0
  32. package/src/engine/rules/dictionary.ts +281 -0
  33. package/src/engine/rules/hedging.ts +27 -0
  34. package/src/engine/rules/marketing.ts +71 -0
  35. package/src/engine/rules/paragraph-length.ts +23 -0
  36. package/src/engine/rules/phrasal-verb.ts +57 -0
  37. package/src/engine/rules/registry.ts +15 -0
  38. package/src/engine/rules/semicolon.ts +14 -0
  39. package/src/engine/rules/sentence-length.ts +24 -0
  40. package/src/engine/rules/verb-form.ts +76 -0
  41. package/src/engine/scan.ts +15 -0
  42. package/src/engine/sentences.ts +285 -0
  43. package/src/engine/tagger.ts +8 -0
  44. package/src/engine/tokens.ts +2 -0
  45. package/src/engine/types.ts +45 -0
  46. package/src/extension/index.ts +755 -0
  47. package/src/tagger/wink.ts +44 -0
@@ -0,0 +1,755 @@
1
+ import { constants } from "node:fs"
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import { dirname } from "node:path"
4
+ import {
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ ExtensionRunner,
8
+ type MessageStartEvent,
9
+ type MessageUpdateEvent,
10
+ type ToolCallEventResult,
11
+ createBashToolDefinition,
12
+ createEditToolDefinition,
13
+ createWriteToolDefinition,
14
+ } from "@earendil-works/pi-coding-agent"
15
+ import type { AutocompleteItem } from "@earendil-works/pi-tui"
16
+ import { Effect } from "effect"
17
+ import { Type } from "typebox"
18
+ import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
19
+ import { formatViolations, violationDetails } from "../adapter/feedback.ts"
20
+ import { formatStatusSummary, ruleSummary } from "../adapter/rule-summary.ts"
21
+ import { loadConfig } from "../config/load.ts"
22
+ import type { SteConfig } from "../config/schema.ts"
23
+ import { loadDictionary } from "../dictionary/load.ts"
24
+ import type { Dictionary } from "../dictionary/schema.ts"
25
+ import { classifyPath } from "../engine/kinds.ts"
26
+ import { lint } from "../engine/lint.ts"
27
+ import type { Tagger } from "../engine/tagger.ts"
28
+ import type { LintReport, Violation } from "../engine/types.ts"
29
+ import { makeWinkTagger } from "../tagger/wink.ts"
30
+
31
+ const STE_COMMAND_COMPLETIONS: readonly AutocompleteItem[] = [
32
+ { value: "on", label: "on", description: "Enable STE enforcement" },
33
+ { value: "off", label: "off", description: "Disable STE enforcement" },
34
+ { value: "status", label: "status", description: "Show STE status" },
35
+ { value: "strict", label: "strict", description: "Enable strict reply gating" },
36
+ ]
37
+
38
+ interface SessionState {
39
+ config: SteConfig
40
+ dictionary?: Dictionary
41
+ tagger?: Tagger
42
+ enabled: boolean
43
+ error?: string
44
+ dictionaryError?: string
45
+ ready: boolean
46
+ strict: boolean
47
+ pendingReplyFeedback?: string
48
+ readonly approvedSayReplies: Map<string, string>
49
+ readonly pendingSayArguments: Map<string, Record<string, unknown>>
50
+ readonly pendingWarnings: Map<string, string>
51
+ readonly rejectedSayReplies: Map<string, string>
52
+ }
53
+
54
+ const STRICT_MODE_NOTE = [
55
+ "## Simplified Technical English: strict mode",
56
+ "",
57
+ "Send every user-facing reply through the `say` tool.",
58
+ "Write no prose outside that tool.",
59
+ "Call `say` as your final action.",
60
+ "A hard violation blocks the call before the user reads the text.",
61
+ "Read the feedback, rewrite the text, and call `say` again.",
62
+ ].join("\n")
63
+
64
+ let sharedTagger: Tagger | undefined
65
+
66
+ function statusSummary(state: SessionState): string {
67
+ const mode = !state.enabled ? "disabled" : state.strict ? "strict" : "enabled"
68
+ const dictionary =
69
+ state.dictionary !== undefined
70
+ ? "loaded"
71
+ : state.dictionaryError !== undefined
72
+ ? `failed (${state.dictionaryError})`
73
+ : "not loaded"
74
+ return formatStatusSummary(state.config, mode, dictionary)
75
+ }
76
+
77
+ function formatReplyFeedback(violations: readonly Violation[]): string {
78
+ return `STE feedback for your previous reply:\n${violationDetails(violations)}`
79
+ }
80
+
81
+ function lintReply(state: SessionState, text: string): LintReport {
82
+ return lint("prose-file", text, {
83
+ ...state.config,
84
+ dictionary: state.dictionary,
85
+ tagger: state.tagger,
86
+ })
87
+ }
88
+
89
+ type AssistantMessage = Extract<MessageStartEvent["message"], { role: "assistant" }>
90
+ type Message = MessageStartEvent["message"]
91
+
92
+ type BoundaryRegistry = {
93
+ installed: boolean
94
+ readonly states: WeakMap<object, SessionState>
95
+ }
96
+
97
+ type BoundaryGlobal = typeof globalThis & {
98
+ __simpleEnglishStrictBoundaryV1?: BoundaryRegistry
99
+ }
100
+
101
+ const REDACTED_SAY_TEXT = "[redacted until STE approval]"
102
+ const boundaryGlobal = globalThis as BoundaryGlobal
103
+ const boundaryRegistry = boundaryGlobal.__simpleEnglishStrictBoundaryV1 ?? {
104
+ installed: false,
105
+ states: new WeakMap(),
106
+ }
107
+ if (boundaryGlobal.__simpleEnglishStrictBoundaryV1 === undefined) {
108
+ boundaryGlobal.__simpleEnglishStrictBoundaryV1 = boundaryRegistry
109
+ }
110
+
111
+ function isRecord(value: unknown): value is Record<string, unknown> {
112
+ return typeof value === "object" && value !== null
113
+ }
114
+
115
+ function replaceRecord(
116
+ target: Record<string, unknown>,
117
+ replacement: Record<string, unknown>,
118
+ ): void {
119
+ for (const key of Object.keys(target)) delete target[key]
120
+ Object.assign(target, replacement)
121
+ }
122
+
123
+ function captureAndRedactSayArguments(
124
+ state: SessionState,
125
+ toolCallId: string,
126
+ argumentsValue: unknown,
127
+ ): void {
128
+ if (!isRecord(argumentsValue)) return
129
+ if (argumentsValue.text !== REDACTED_SAY_TEXT) {
130
+ state.pendingSayArguments.set(toolCallId, structuredClone(argumentsValue))
131
+ }
132
+ replaceRecord(argumentsValue, { text: REDACTED_SAY_TEXT })
133
+ }
134
+
135
+ function contentWithoutReplyProse(
136
+ state: SessionState,
137
+ message: AssistantMessage,
138
+ ): AssistantMessage["content"] {
139
+ return message.content.filter((block) => {
140
+ if (block.type === "text" || block.type === "thinking") return false
141
+ if (block.type === "toolCall" && block.name === "say") {
142
+ captureAndRedactSayArguments(state, block.id, block.arguments)
143
+ }
144
+ return true
145
+ })
146
+ }
147
+
148
+ function suppressAssistantReply(state: SessionState, message: Message): void {
149
+ if (message.role === "assistant") message.content = contentWithoutReplyProse(state, message)
150
+ }
151
+
152
+ function suppressAssistantReplyUpdate(state: SessionState, event: MessageUpdateEvent): void {
153
+ suppressAssistantReply(state, event.message)
154
+ const update = event.assistantMessageEvent
155
+ if ("partial" in update) suppressAssistantReply(state, update.partial)
156
+ if (
157
+ update.type === "text_delta" ||
158
+ update.type === "thinking_delta" ||
159
+ update.type === "toolcall_delta"
160
+ ) {
161
+ update.delta = ""
162
+ }
163
+ if (update.type === "text_end" || update.type === "thinking_end") update.content = ""
164
+ if (update.type === "toolcall_end" && update.toolCall.name === "say") {
165
+ captureAndRedactSayArguments(state, update.toolCall.id, update.toolCall.arguments)
166
+ }
167
+ if (update.type === "done") suppressAssistantReply(state, update.message)
168
+ if (update.type === "error") suppressAssistantReply(state, update.error)
169
+ }
170
+
171
+ function strictSayContent(state: SessionState, toolCallId: string) {
172
+ const approved = state.approvedSayReplies.get(toolCallId)
173
+ if (approved !== undefined) {
174
+ return { content: [{ type: "text" as const, text: approved }], details: {}, isError: false }
175
+ }
176
+ const rejected = state.rejectedSayReplies.get(toolCallId)
177
+ if (rejected !== undefined) {
178
+ return { content: [{ type: "text" as const, text: rejected }], details: {}, isError: true }
179
+ }
180
+ return { content: [], details: {}, isError: true }
181
+ }
182
+
183
+ function enforceStrictMessage(state: SessionState, message: Message): void {
184
+ if (!state.enabled || !state.strict) return
185
+ if (message.role === "assistant") {
186
+ suppressAssistantReply(state, message)
187
+ return
188
+ }
189
+ if (message.role !== "toolResult" || message.toolName !== "say") return
190
+ Object.assign(message, strictSayContent(state, message.toolCallId))
191
+ }
192
+
193
+ function enforceStrictEvent(state: SessionState, eventValue: unknown): void {
194
+ if (!state.enabled || !state.strict || !isRecord(eventValue)) return
195
+ const event = eventValue
196
+ if (isRecord(event.message) && "role" in event.message) {
197
+ enforceStrictMessage(state, event.message as unknown as Message)
198
+ }
199
+ if (event.type === "message_update")
200
+ suppressAssistantReplyUpdate(state, event as unknown as MessageUpdateEvent)
201
+ if (
202
+ (event.type === "tool_execution_start" || event.type === "tool_execution_update") &&
203
+ event.toolName === "say" &&
204
+ typeof event.toolCallId === "string"
205
+ ) {
206
+ captureAndRedactSayArguments(state, event.toolCallId, event.args)
207
+ }
208
+ if (
209
+ event.type === "tool_execution_end" &&
210
+ event.toolName === "say" &&
211
+ typeof event.toolCallId === "string" &&
212
+ isRecord(event.result)
213
+ ) {
214
+ Object.assign(event.result, strictSayContent(state, event.toolCallId))
215
+ }
216
+ if (event.type === "turn_end" && Array.isArray(event.toolResults)) {
217
+ for (const result of event.toolResults) {
218
+ if (isRecord(result) && result.role === "toolResult" && result.toolName === "say") {
219
+ enforceStrictMessage(state, result as unknown as Message)
220
+ }
221
+ }
222
+ }
223
+ if (event.type === "agent_end" && Array.isArray(event.messages)) {
224
+ for (const message of event.messages) {
225
+ if (isRecord(message) && "role" in message) {
226
+ enforceStrictMessage(state, message as unknown as Message)
227
+ }
228
+ }
229
+ state.approvedSayReplies.clear()
230
+ state.pendingSayArguments.clear()
231
+ state.rejectedSayReplies.clear()
232
+ }
233
+ }
234
+
235
+ function installStrictOutputBoundary(): void {
236
+ if (boundaryRegistry.installed) return
237
+ boundaryRegistry.installed = true
238
+
239
+ const originalEmit = ExtensionRunner.prototype.emit
240
+ Object.defineProperty(ExtensionRunner.prototype, "emit", {
241
+ configurable: true,
242
+ value: async function (this: ExtensionRunner, event: Record<string, unknown>) {
243
+ const result = await originalEmit.call(this, event as never)
244
+ const state = boundaryRegistry.states.get(this.createContext().sessionManager as object)
245
+ if (state !== undefined) enforceStrictEvent(state, event)
246
+ return result
247
+ },
248
+ writable: true,
249
+ })
250
+
251
+ const originalEmitMessageEnd = ExtensionRunner.prototype.emitMessageEnd
252
+ Object.defineProperty(ExtensionRunner.prototype, "emitMessageEnd", {
253
+ configurable: true,
254
+ value: async function (this: ExtensionRunner, event: { message: Message }) {
255
+ const replacement = await originalEmitMessageEnd.call(this, event as never)
256
+ const state = boundaryRegistry.states.get(this.createContext().sessionManager as object)
257
+ if (state === undefined || !state.enabled || !state.strict) return replacement
258
+ const message = (replacement ?? event.message) as Message
259
+ enforceStrictMessage(state, message)
260
+ return message
261
+ },
262
+ writable: true,
263
+ })
264
+
265
+ const originalEmitToolResult = ExtensionRunner.prototype.emitToolResult
266
+ Object.defineProperty(ExtensionRunner.prototype, "emitToolResult", {
267
+ configurable: true,
268
+ value: async function (
269
+ this: ExtensionRunner,
270
+ event: Record<string, unknown> & { toolCallId: string },
271
+ ) {
272
+ const replacement = await originalEmitToolResult.call(this, event as never)
273
+ const state = boundaryRegistry.states.get(this.createContext().sessionManager as object)
274
+ if (state === undefined || !state.enabled || !state.strict || event.toolName !== "say") {
275
+ return replacement
276
+ }
277
+ return strictSayContent(state, event.toolCallId)
278
+ },
279
+ writable: true,
280
+ })
281
+ }
282
+
283
+ installStrictOutputBoundary()
284
+
285
+ function showReplyReport(
286
+ state: SessionState,
287
+ ctx: ExtensionContext,
288
+ report: LintReport,
289
+ queueFeedback: boolean,
290
+ ): void {
291
+ const hard = report.violations.filter((violation) => violation.severity === "hard")
292
+ const softCount = report.summary.total - report.summary.hard
293
+ state.pendingReplyFeedback =
294
+ queueFeedback && hard.length > 0 ? formatReplyFeedback(hard) : undefined
295
+ if (!ctx.hasUI) return
296
+
297
+ const status =
298
+ report.summary.total === 0
299
+ ? "STE reply: clean"
300
+ : `STE reply: ${report.summary.hard} hard, ${softCount} soft`
301
+ ctx.ui.setWidget("simple-english-reply", [status])
302
+ }
303
+
304
+ function updateReplyState(state: SessionState, ctx: ExtensionContext, text?: string): void {
305
+ state.pendingReplyFeedback = undefined
306
+ if (text === undefined) {
307
+ if (ctx.hasUI) ctx.ui.setWidget("simple-english-reply", undefined)
308
+ return
309
+ }
310
+ showReplyReport(state, ctx, lintReply(state, text), true)
311
+ }
312
+
313
+ function restoreReplyState(state: SessionState, ctx: ExtensionContext): void {
314
+ const branch = ctx.sessionManager.getBranch()
315
+ for (let index = branch.length - 1; index >= 0; index--) {
316
+ const entry = branch[index]
317
+ if (entry?.type !== "message") continue
318
+ const message = entry.message
319
+ if (
320
+ message.role !== "assistant" &&
321
+ (message.role !== "toolResult" || message.toolName !== "say" || message.isError)
322
+ ) {
323
+ continue
324
+ }
325
+ const textBlocks = message.content.filter((block) => block.type === "text")
326
+ if (textBlocks.length === 0) continue
327
+ updateReplyState(state, ctx, textBlocks.map((block) => block.text).join("\n"))
328
+ return
329
+ }
330
+ updateReplyState(state, ctx)
331
+ }
332
+
333
+ function notifyWarnings(
334
+ ctx: ExtensionContext,
335
+ path: string,
336
+ violations: readonly Violation[],
337
+ ): void {
338
+ if (violations.length === 0 || !ctx.hasUI) return
339
+ ctx.ui.notify(formatViolations(path, "STE warnings for", violations), "warning")
340
+ }
341
+
342
+ function lintProposedText(
343
+ state: SessionState,
344
+ ctx: ExtensionContext,
345
+ operation: "write" | "edit",
346
+ toolCallId: string,
347
+ path: string,
348
+ text: string,
349
+ previousText?: string,
350
+ ): ToolCallEventResult | undefined {
351
+ const classification = classifyPath(path)
352
+ const report = lint(classification.kind, text, {
353
+ ...state.config,
354
+ dictionary: state.dictionary,
355
+ tagger: state.tagger,
356
+ sourceDialect: classification.sourceDialect,
357
+ previousText,
358
+ })
359
+ const hard = report.violations.filter((violation) => violation.severity === "hard")
360
+ const soft = report.violations.filter((violation) => violation.severity === "soft")
361
+ notifyWarnings(ctx, path, soft)
362
+ if (hard.length === 0) {
363
+ if (soft.length > 0) {
364
+ state.pendingWarnings.set(toolCallId, formatViolations(path, "STE warnings for", soft))
365
+ }
366
+ return undefined
367
+ }
368
+ return {
369
+ block: true,
370
+ reason: formatViolations(path, `STE blocked ${operation} for`, hard),
371
+ }
372
+ }
373
+
374
+ function lintCommitCommand(
375
+ state: SessionState,
376
+ ctx: ExtensionContext,
377
+ toolCallId: string,
378
+ command: string,
379
+ ): ToolCallEventResult | undefined {
380
+ const invocations = findCommitInvocations(command)
381
+ if (invocations.length === 0) return undefined
382
+ if (!state.ready) {
383
+ return {
384
+ block: true,
385
+ reason: `STE check is unavailable: ${state.error ?? "session setup is not complete"}`,
386
+ }
387
+ }
388
+
389
+ const warnings: string[] = []
390
+ for (const invocation of invocations) {
391
+ if (invocation.requiresExplicitMessage) {
392
+ return {
393
+ block: true,
394
+ reason:
395
+ "STE could not check the git commit message. Use git commit with a static -m or --message argument.",
396
+ }
397
+ }
398
+
399
+ const report = lint("commit-message", blankCommitMetadata(invocation.message), {
400
+ ...state.config,
401
+ dictionary: state.dictionary,
402
+ tagger: state.tagger,
403
+ })
404
+ const hard = report.violations.filter((violation) => violation.severity === "hard")
405
+ const soft = report.violations.filter((violation) => violation.severity === "soft")
406
+ notifyWarnings(ctx, "commit message", soft)
407
+ if (hard.length > 0) {
408
+ return {
409
+ block: true,
410
+ reason: formatViolations("commit message", "STE blocked commit for", hard),
411
+ }
412
+ }
413
+ if (soft.length > 0) {
414
+ warnings.push(formatViolations("commit message", "STE warnings for", soft))
415
+ }
416
+ }
417
+
418
+ if (warnings.length > 0) state.pendingWarnings.set(toolCallId, warnings.join("\n\n"))
419
+ return undefined
420
+ }
421
+
422
+ function createGatedBashTool(cwd: string, state: SessionState) {
423
+ const definition = createBashToolDefinition(cwd)
424
+ const execute: typeof definition.execute = async (...args) => {
425
+ const [toolCallId, input, _signal, _onUpdate, ctx] = args
426
+ if (state.enabled) {
427
+ const result = lintCommitCommand(state, ctx, toolCallId, input.command)
428
+ if (result?.block) throw new Error(result.reason)
429
+ }
430
+ return definition.execute(...args)
431
+ }
432
+ return { ...definition, execute }
433
+ }
434
+
435
+ function lintStrictReply(
436
+ state: SessionState,
437
+ ctx: ExtensionContext,
438
+ text: string,
439
+ ): ToolCallEventResult | undefined {
440
+ if (!state.ready) {
441
+ return {
442
+ block: true,
443
+ reason: `STE check is unavailable: ${state.error ?? "session setup is not complete"}`,
444
+ }
445
+ }
446
+ const report = lintReply(state, text)
447
+ showReplyReport(state, ctx, report, false)
448
+ const hard = report.violations.filter((violation) => violation.severity === "hard")
449
+ if (hard.length === 0) return undefined
450
+ return {
451
+ block: true,
452
+ reason: formatViolations("reply", "STE blocked", hard),
453
+ }
454
+ }
455
+
456
+ function createGatedWriteTool(cwd: string, state: SessionState) {
457
+ const definition = createWriteToolDefinition(cwd)
458
+ const execute: typeof definition.execute = async (...args) => {
459
+ const [toolCallId, input, signal, _onUpdate, ctx] = args
460
+ const implementation = createWriteToolDefinition(cwd, {
461
+ operations: {
462
+ mkdir: async () => undefined,
463
+ writeFile: async (path, content) => {
464
+ if (state.enabled) {
465
+ if (!state.ready) {
466
+ throw new Error(
467
+ `STE check is unavailable: ${state.error ?? "session setup is not complete"}`,
468
+ )
469
+ }
470
+ const result = lintProposedText(state, ctx, "write", toolCallId, input.path, content)
471
+ if (result?.block) throw new Error(result.reason)
472
+ }
473
+ await mkdir(dirname(path), { recursive: true })
474
+ if (signal?.aborted) throw new Error("Operation aborted")
475
+ await writeFile(path, content, "utf8")
476
+ },
477
+ },
478
+ })
479
+ return implementation.execute(...args)
480
+ }
481
+ return { ...definition, execute }
482
+ }
483
+
484
+ function createGatedEditTool(cwd: string, state: SessionState) {
485
+ const definition = createEditToolDefinition(cwd)
486
+ const execute: typeof definition.execute = async (...args) => {
487
+ const [toolCallId, input, _signal, _onUpdate, ctx] = args
488
+ let previousText: string | undefined
489
+ const implementation = createEditToolDefinition(cwd, {
490
+ operations: {
491
+ access: (path) => access(path, constants.R_OK | constants.W_OK),
492
+ readFile: async (path) => {
493
+ const content = await readFile(path)
494
+ previousText = content.toString("utf8")
495
+ return content
496
+ },
497
+ writeFile: async (path, content) => {
498
+ if (state.enabled) {
499
+ if (!state.ready) {
500
+ throw new Error(
501
+ `STE check is unavailable: ${state.error ?? "session setup is not complete"}`,
502
+ )
503
+ }
504
+ const result = lintProposedText(
505
+ state,
506
+ ctx,
507
+ "edit",
508
+ toolCallId,
509
+ input.path,
510
+ content,
511
+ previousText,
512
+ )
513
+ if (result?.block) throw new Error(result.reason)
514
+ }
515
+ await writeFile(path, content, "utf8")
516
+ },
517
+ },
518
+ })
519
+ return implementation.execute(...args)
520
+ }
521
+ return { ...definition, execute }
522
+ }
523
+
524
+ export default function simpleEnglishExtension(pi: ExtensionAPI): void {
525
+ const state: SessionState = {
526
+ config: {},
527
+ enabled: true,
528
+ ready: false,
529
+ strict: false,
530
+ approvedSayReplies: new Map(),
531
+ pendingSayArguments: new Map(),
532
+ pendingWarnings: new Map(),
533
+ rejectedSayReplies: new Map(),
534
+ }
535
+
536
+ const setSayActive = (active: boolean): void => {
537
+ const tools = pi.getActiveTools().filter((name) => name !== "say")
538
+ pi.setActiveTools(active ? [...tools, "say"] : tools)
539
+ }
540
+
541
+ pi.registerCommand("ste", {
542
+ description: "Toggle STE enforcement or show status. Use strict to gate replies.",
543
+ getArgumentCompletions: (prefix) => {
544
+ const completions = STE_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(prefix))
545
+ return completions.length > 0 ? completions : null
546
+ },
547
+ handler: async (args, ctx) => {
548
+ const command = args.trim().toLowerCase()
549
+ if (command === "status") {
550
+ ctx.ui.notify(statusSummary(state), "info")
551
+ return
552
+ }
553
+ if (command === "strict" || command === "strict on") {
554
+ state.enabled = true
555
+ state.strict = true
556
+ setSayActive(true)
557
+ ctx.ui.notify("STE strict mode enabled. Send every reply through the say tool.", "info")
558
+ return
559
+ }
560
+ if (command === "strict off") {
561
+ state.strict = false
562
+ setSayActive(false)
563
+ ctx.ui.notify("STE strict mode disabled.", "info")
564
+ return
565
+ }
566
+ if (command !== "" && command !== "on" && command !== "off") {
567
+ ctx.ui.notify("Usage: /ste [on|off|status|strict|strict off]", "warning")
568
+ return
569
+ }
570
+
571
+ state.enabled = command === "on" || (command === "" && !state.enabled)
572
+ if (!state.enabled) {
573
+ state.strict = false
574
+ setSayActive(false)
575
+ state.pendingReplyFeedback = undefined
576
+ state.approvedSayReplies.clear()
577
+ state.pendingSayArguments.clear()
578
+ state.pendingWarnings.clear()
579
+ state.rejectedSayReplies.clear()
580
+ if (ctx.hasUI) ctx.ui.setWidget("simple-english-reply", undefined)
581
+ }
582
+ ctx.ui.notify(`STE enforcement ${state.enabled ? "enabled" : "disabled"}.`, "info")
583
+ },
584
+ })
585
+
586
+ pi.registerTool({
587
+ name: "say",
588
+ label: "Say",
589
+ description:
590
+ "Send prose to the user. In STE strict mode, use this tool for every user-facing reply.",
591
+ parameters: Type.Object({
592
+ text: Type.String({ description: "The complete user-facing reply" }),
593
+ }),
594
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
595
+ if (state.enabled && state.strict) {
596
+ const result = lintStrictReply(state, ctx, params.text)
597
+ if (result?.block) {
598
+ state.approvedSayReplies.delete(toolCallId)
599
+ state.rejectedSayReplies.set(toolCallId, result.reason ?? "STE blocked the reply.")
600
+ throw new Error(result.reason)
601
+ }
602
+ state.approvedSayReplies.set(toolCallId, params.text)
603
+ state.rejectedSayReplies.delete(toolCallId)
604
+ }
605
+ return {
606
+ content: [{ type: "text" as const, text: params.text }],
607
+ details: {},
608
+ terminate: true,
609
+ }
610
+ },
611
+ })
612
+
613
+ pi.on("session_start", async (_event, ctx) => {
614
+ boundaryRegistry.states.set(ctx.sessionManager as object, state)
615
+ setSayActive(state.enabled && state.strict)
616
+ state.config = {}
617
+ state.dictionary = undefined
618
+ state.tagger = undefined
619
+ state.ready = false
620
+ state.error = undefined
621
+ state.dictionaryError = undefined
622
+ state.approvedSayReplies.clear()
623
+ state.pendingSayArguments.clear()
624
+ state.pendingWarnings.clear()
625
+ state.rejectedSayReplies.clear()
626
+ updateReplyState(state, ctx)
627
+ pi.registerTool(createGatedBashTool(ctx.cwd, state))
628
+ pi.registerTool(createGatedWriteTool(ctx.cwd, state))
629
+ pi.registerTool(createGatedEditTool(ctx.cwd, state))
630
+
631
+ try {
632
+ state.config = await Effect.runPromise(loadConfig(undefined, ctx.cwd, ctx.isProjectTrusted()))
633
+ } catch (error) {
634
+ state.error = error instanceof Error ? error.message : String(error)
635
+ if (ctx.hasUI)
636
+ ctx.ui.notify(`Simple English extension failed to start: ${state.error}`, "error")
637
+ return
638
+ }
639
+
640
+ try {
641
+ state.dictionary = await Effect.runPromise(
642
+ loadDictionary(process.env.SIMPLE_ENGLISH_DICTIONARY),
643
+ )
644
+ } catch (error) {
645
+ state.dictionaryError = error instanceof Error ? error.message : String(error)
646
+ state.error = state.dictionaryError
647
+ if (ctx.hasUI)
648
+ ctx.ui.notify(`Simple English extension failed to start: ${state.error}`, "error")
649
+ return
650
+ }
651
+
652
+ try {
653
+ sharedTagger ??= makeWinkTagger()
654
+ state.tagger = sharedTagger
655
+ state.ready = true
656
+ if (state.enabled) restoreReplyState(state, ctx)
657
+ } catch (error) {
658
+ state.error = error instanceof Error ? error.message : String(error)
659
+ if (ctx.hasUI)
660
+ ctx.ui.notify(`Simple English extension failed to start: ${state.error}`, "error")
661
+ }
662
+ })
663
+
664
+ pi.on("session_shutdown", (_event, ctx) => {
665
+ boundaryRegistry.states.delete(ctx.sessionManager as object)
666
+ })
667
+
668
+ pi.on("session_tree", (_event, ctx) => {
669
+ if (state.enabled && state.ready) restoreReplyState(state, ctx)
670
+ else updateReplyState(state, ctx)
671
+ })
672
+
673
+ pi.on("before_agent_start", (event) => {
674
+ if (!state.enabled) return undefined
675
+ const additions = [ruleSummary(state.config)]
676
+ if (state.strict) additions.push(STRICT_MODE_NOTE)
677
+ return { systemPrompt: `${event.systemPrompt}\n\n${additions.join("\n\n")}` }
678
+ })
679
+
680
+ pi.on("message_start", (event) => {
681
+ if (state.enabled && state.strict) enforceStrictMessage(state, event.message)
682
+ })
683
+
684
+ pi.on("message_update", (event) => {
685
+ if (state.enabled && state.strict) suppressAssistantReplyUpdate(state, event)
686
+ })
687
+
688
+ pi.on("message_end", (event) => {
689
+ if (!state.enabled || !state.strict) return undefined
690
+ enforceStrictMessage(state, event.message)
691
+ return { message: event.message }
692
+ })
693
+
694
+ pi.on("tool_execution_start", (event) => enforceStrictEvent(state, event))
695
+ pi.on("tool_execution_update", (event) => enforceStrictEvent(state, event))
696
+ pi.on("tool_execution_end", (event) => enforceStrictEvent(state, event))
697
+
698
+ pi.on("turn_end", (event, ctx) => {
699
+ if (!state.enabled || !state.ready || event.message.role !== "assistant") return
700
+ const textBlocks = event.message.content.filter((block) => block.type === "text")
701
+ if (textBlocks.length === 0) return
702
+ updateReplyState(state, ctx, textBlocks.map((block) => block.text).join("\n"))
703
+ })
704
+
705
+ pi.on("context", (event) => {
706
+ if (!state.enabled) return undefined
707
+ const feedback = state.pendingReplyFeedback
708
+ if (feedback === undefined) return undefined
709
+ state.pendingReplyFeedback = undefined
710
+ return {
711
+ messages: [
712
+ ...event.messages,
713
+ {
714
+ role: "custom" as const,
715
+ customType: "simple-english-reply-feedback",
716
+ content: feedback,
717
+ display: false,
718
+ timestamp: Date.now(),
719
+ },
720
+ ],
721
+ }
722
+ })
723
+
724
+ pi.on("tool_call", async (event, ctx) => {
725
+ if (!state.enabled) return undefined
726
+ if (event.toolName === "say" && state.strict) {
727
+ const savedArguments = state.pendingSayArguments.get(event.toolCallId)
728
+ if (savedArguments !== undefined) replaceRecord(event.input, structuredClone(savedArguments))
729
+ const text = (event.input as { text?: unknown }).text
730
+ if (typeof text !== "string") {
731
+ const reason = "STE could not check the reply: say requires text."
732
+ state.rejectedSayReplies.set(event.toolCallId, reason)
733
+ return { block: true, reason }
734
+ }
735
+ const result = lintStrictReply(state, ctx, text)
736
+ if (result?.block) {
737
+ state.rejectedSayReplies.set(event.toolCallId, result.reason ?? "STE blocked the reply.")
738
+ }
739
+ return result
740
+ }
741
+ if (event.toolName !== "write" && event.toolName !== "edit") return undefined
742
+ if (state.ready) return undefined
743
+ return {
744
+ block: true,
745
+ reason: `STE check is unavailable: ${state.error ?? "session setup is not complete"}`,
746
+ }
747
+ })
748
+
749
+ pi.on("tool_result", (event) => {
750
+ const warning = state.pendingWarnings.get(event.toolCallId)
751
+ if (warning === undefined) return undefined
752
+ state.pendingWarnings.delete(event.toolCallId)
753
+ return { content: [...event.content, { type: "text" as const, text: warning }] }
754
+ })
755
+ }