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.
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/commands/ste.md +13 -0
- package/hooks/hooks.json +58 -0
- package/package.json +64 -0
- package/src/adapter/commit-message.ts +472 -0
- package/src/adapter/feedback.ts +40 -0
- package/src/adapter/rule-summary.ts +79 -0
- package/src/cli/hook.ts +681 -0
- package/src/cli/main.ts +201 -0
- package/src/cli/session-command.ts +78 -0
- package/src/cli/session-state.ts +214 -0
- package/src/config/load.ts +85 -0
- package/src/config/merge.ts +20 -0
- package/src/config/schema.ts +69 -0
- package/src/dictionary/README.md +18 -0
- package/src/dictionary/data/pi-ste.json +200 -0
- package/src/dictionary/form.ts +6 -0
- package/src/dictionary/load.ts +54 -0
- package/src/dictionary/schema.ts +28 -0
- package/src/engine/comments.ts +386 -0
- package/src/engine/diff.ts +328 -0
- package/src/engine/identifiers.ts +20 -0
- package/src/engine/kinds.ts +43 -0
- package/src/engine/lint.ts +530 -0
- package/src/engine/markdown.ts +338 -0
- package/src/engine/paragraphs.ts +105 -0
- package/src/engine/rules/contraction.ts +19 -0
- package/src/engine/rules/dictionary.ts +281 -0
- package/src/engine/rules/hedging.ts +27 -0
- package/src/engine/rules/marketing.ts +71 -0
- package/src/engine/rules/paragraph-length.ts +23 -0
- package/src/engine/rules/phrasal-verb.ts +57 -0
- package/src/engine/rules/registry.ts +15 -0
- package/src/engine/rules/semicolon.ts +14 -0
- package/src/engine/rules/sentence-length.ts +24 -0
- package/src/engine/rules/verb-form.ts +76 -0
- package/src/engine/scan.ts +15 -0
- package/src/engine/sentences.ts +285 -0
- package/src/engine/tagger.ts +8 -0
- package/src/engine/tokens.ts +2 -0
- package/src/engine/types.ts +45 -0
- package/src/extension/index.ts +755 -0
- 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
|
+
}
|