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
package/src/cli/hook.ts
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { open, readFile } from "node:fs/promises"
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import { Cause, Effect } from "effect"
|
|
5
|
+
import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
|
|
6
|
+
import { formatViolations } from "../adapter/feedback.ts"
|
|
7
|
+
import { ruleSummary } from "../adapter/rule-summary.ts"
|
|
8
|
+
import { loadConfig } from "../config/load.ts"
|
|
9
|
+
import { loadDictionary } from "../dictionary/load.ts"
|
|
10
|
+
import { classifyPath } from "../engine/kinds.ts"
|
|
11
|
+
import { lint } from "../engine/lint.ts"
|
|
12
|
+
import type { Tagger } from "../engine/tagger.ts"
|
|
13
|
+
import type { LintOptions, Violation } from "../engine/types.ts"
|
|
14
|
+
import { TaggerService } from "../tagger/wink.ts"
|
|
15
|
+
import {
|
|
16
|
+
consumePendingFeedback,
|
|
17
|
+
getSessionControl,
|
|
18
|
+
hasProcessedReply,
|
|
19
|
+
setReplyFeedback,
|
|
20
|
+
} from "./session-state.ts"
|
|
21
|
+
|
|
22
|
+
interface CommonEvent {
|
|
23
|
+
readonly cwd: string
|
|
24
|
+
readonly sessionId: string
|
|
25
|
+
readonly transcriptPath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SessionStartEvent extends CommonEvent {
|
|
29
|
+
readonly hookEventName: "SessionStart"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WriteEvent extends CommonEvent {
|
|
33
|
+
readonly hookEventName: "PreToolUse"
|
|
34
|
+
readonly toolName: "Write"
|
|
35
|
+
readonly filePath: string
|
|
36
|
+
readonly content: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface EditEvent extends CommonEvent {
|
|
40
|
+
readonly hookEventName: "PreToolUse"
|
|
41
|
+
readonly toolName: "Edit"
|
|
42
|
+
readonly filePath: string
|
|
43
|
+
readonly oldString: string
|
|
44
|
+
readonly newString: string
|
|
45
|
+
readonly replaceAll: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BashEvent extends CommonEvent {
|
|
49
|
+
readonly hookEventName: "PreToolUse"
|
|
50
|
+
readonly toolName: "Bash"
|
|
51
|
+
readonly command: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface StopEvent extends CommonEvent {
|
|
55
|
+
readonly hookEventName: "Stop"
|
|
56
|
+
readonly lastAssistantMessage?: string
|
|
57
|
+
readonly stopHookActive: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface UserPromptSubmitEvent extends CommonEvent {
|
|
61
|
+
readonly hookEventName: "UserPromptSubmit"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type PreToolUseEvent = WriteEvent | EditEvent | BashEvent
|
|
65
|
+
type HookEvent = SessionStartEvent | PreToolUseEvent | StopEvent | UserPromptSubmitEvent
|
|
66
|
+
|
|
67
|
+
interface HookSpecificOutput {
|
|
68
|
+
readonly hookEventName: "PreToolUse"
|
|
69
|
+
readonly permissionDecision: "allow" | "deny"
|
|
70
|
+
readonly permissionDecisionReason?: string
|
|
71
|
+
readonly additionalContext?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface HookDecision {
|
|
75
|
+
readonly hookSpecificOutput: HookSpecificOutput
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ContextOutput {
|
|
79
|
+
readonly hookSpecificOutput: {
|
|
80
|
+
readonly hookEventName: "SessionStart" | "UserPromptSubmit"
|
|
81
|
+
readonly additionalContext: string
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface HookError {
|
|
86
|
+
readonly continue: true
|
|
87
|
+
readonly systemMessage: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface StopDecision {
|
|
91
|
+
readonly decision: "block"
|
|
92
|
+
readonly reason: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type HookOutput =
|
|
96
|
+
| HookDecision
|
|
97
|
+
| ContextOutput
|
|
98
|
+
| HookError
|
|
99
|
+
| StopDecision
|
|
100
|
+
| Record<string, never>
|
|
101
|
+
|
|
102
|
+
function record(value: unknown, name: string): Record<string, unknown> {
|
|
103
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
104
|
+
throw new Error(`${name} must be a JSON object`)
|
|
105
|
+
}
|
|
106
|
+
return value as Record<string, unknown>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stringField(value: Record<string, unknown>, name: string): string {
|
|
110
|
+
const field = value[name]
|
|
111
|
+
if (typeof field !== "string") throw new Error(`${name} must be a string`)
|
|
112
|
+
return field
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decodeEvent(raw: string): HookEvent {
|
|
116
|
+
const event = record(JSON.parse(raw) as unknown, "event")
|
|
117
|
+
const hookEventName = stringField(event, "hook_event_name")
|
|
118
|
+
const common = {
|
|
119
|
+
cwd: stringField(event, "cwd"),
|
|
120
|
+
sessionId: stringField(event, "session_id"),
|
|
121
|
+
transcriptPath: stringField(event, "transcript_path"),
|
|
122
|
+
}
|
|
123
|
+
if (hookEventName === "SessionStart") return { ...common, hookEventName }
|
|
124
|
+
if (hookEventName === "Stop") {
|
|
125
|
+
const lastAssistantMessage = event.last_assistant_message
|
|
126
|
+
if (lastAssistantMessage !== undefined && typeof lastAssistantMessage !== "string") {
|
|
127
|
+
throw new Error("last_assistant_message must be a string")
|
|
128
|
+
}
|
|
129
|
+
const stopHookActive = event.stop_hook_active
|
|
130
|
+
if (typeof stopHookActive !== "boolean") {
|
|
131
|
+
throw new Error("stop_hook_active must be a boolean")
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...common,
|
|
135
|
+
hookEventName,
|
|
136
|
+
stopHookActive,
|
|
137
|
+
...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (hookEventName === "UserPromptSubmit") return { ...common, hookEventName }
|
|
141
|
+
if (hookEventName !== "PreToolUse") {
|
|
142
|
+
throw new Error("hook_event_name must be SessionStart, PreToolUse, Stop, or UserPromptSubmit")
|
|
143
|
+
}
|
|
144
|
+
const toolName = stringField(event, "tool_name")
|
|
145
|
+
const input = record(event.tool_input, "tool_input")
|
|
146
|
+
|
|
147
|
+
if (toolName === "Write") {
|
|
148
|
+
return {
|
|
149
|
+
...common,
|
|
150
|
+
hookEventName,
|
|
151
|
+
toolName,
|
|
152
|
+
filePath: stringField(input, "file_path"),
|
|
153
|
+
content: stringField(input, "content"),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (toolName === "Edit") {
|
|
157
|
+
const replaceAll = input.replace_all
|
|
158
|
+
if (replaceAll !== undefined && typeof replaceAll !== "boolean") {
|
|
159
|
+
throw new Error("replace_all must be a boolean")
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
...common,
|
|
163
|
+
hookEventName,
|
|
164
|
+
toolName,
|
|
165
|
+
filePath: stringField(input, "file_path"),
|
|
166
|
+
oldString: stringField(input, "old_string"),
|
|
167
|
+
newString: stringField(input, "new_string"),
|
|
168
|
+
replaceAll: replaceAll ?? false,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (toolName === "Bash") {
|
|
172
|
+
return { ...common, hookEventName, toolName, command: stringField(input, "command") }
|
|
173
|
+
}
|
|
174
|
+
throw new Error(`unsupported PreToolUse tool: ${toolName}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function allow(warnings: string[] = []): HookDecision {
|
|
178
|
+
return {
|
|
179
|
+
hookSpecificOutput: {
|
|
180
|
+
hookEventName: "PreToolUse",
|
|
181
|
+
permissionDecision: "allow",
|
|
182
|
+
...(warnings.length === 0 ? {} : { additionalContext: warnings.join("\n\n") }),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function deny(reason: string): HookDecision {
|
|
188
|
+
return {
|
|
189
|
+
hookSpecificOutput: {
|
|
190
|
+
hookEventName: "PreToolUse",
|
|
191
|
+
permissionDecision: "deny",
|
|
192
|
+
permissionDecisionReason: reason,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function addContext(
|
|
198
|
+
hookEventName: "SessionStart" | "UserPromptSubmit",
|
|
199
|
+
context: string,
|
|
200
|
+
): ContextOutput {
|
|
201
|
+
return {
|
|
202
|
+
hookSpecificOutput: {
|
|
203
|
+
hookEventName,
|
|
204
|
+
additionalContext: context,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function nonBlockingError(message: string): HookError {
|
|
210
|
+
return {
|
|
211
|
+
continue: true,
|
|
212
|
+
systemMessage: `STE hook error: ${message}. The event is allowed.`,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function nonBlockingWarning(message: string): HookDecision {
|
|
217
|
+
return allow([`STE hook warning: ${message}. The event is allowed.`])
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function hookInternalFailure(cause: Cause.Cause<unknown>): HookOutput {
|
|
221
|
+
return nonBlockingWarning(`internal failure: ${Cause.pretty(cause)}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function proposedEdit(
|
|
225
|
+
previousText: string,
|
|
226
|
+
oldString: string,
|
|
227
|
+
newString: string,
|
|
228
|
+
replaceAll: boolean,
|
|
229
|
+
): string {
|
|
230
|
+
if (oldString.length === 0) throw new Error("old_string must not be empty")
|
|
231
|
+
const firstMatch = previousText.indexOf(oldString)
|
|
232
|
+
if (firstMatch === -1) throw new Error("old_string was not found in the edit file")
|
|
233
|
+
if (replaceAll) return previousText.replaceAll(oldString, () => newString)
|
|
234
|
+
if (previousText.indexOf(oldString, firstMatch + oldString.length) !== -1) {
|
|
235
|
+
throw new Error("old_string is not unique in the edit file")
|
|
236
|
+
}
|
|
237
|
+
return `${previousText.slice(0, firstMatch)}${newString}${previousText.slice(firstMatch + oldString.length)}`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const readEditFile = (path: string) =>
|
|
241
|
+
Effect.tryPromise({
|
|
242
|
+
try: () => readFile(path, "utf8"),
|
|
243
|
+
catch: (cause) => new Error(`cannot read edit file ${path}: ${cause}`),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
interface AssistantReply {
|
|
247
|
+
readonly identity: string
|
|
248
|
+
readonly text: string
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function assistantReply(line: string, path: string, offset: number): AssistantReply | undefined {
|
|
252
|
+
let value: unknown
|
|
253
|
+
try {
|
|
254
|
+
value = JSON.parse(line) as unknown
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
259
|
+
const entry = value as Record<string, unknown>
|
|
260
|
+
if (entry.type !== "assistant") return undefined
|
|
261
|
+
const message = record(entry.message, "assistant transcript message")
|
|
262
|
+
const content = message.content
|
|
263
|
+
if (!Array.isArray(content)) {
|
|
264
|
+
throw new Error(`assistant transcript message in ${path} must contain content blocks`)
|
|
265
|
+
}
|
|
266
|
+
const uuid = entry.uuid
|
|
267
|
+
const identity =
|
|
268
|
+
typeof uuid === "string" && uuid.length > 0
|
|
269
|
+
? `uuid:${uuid}`
|
|
270
|
+
: `offset:${offset}:${createHash("sha256").update(line).digest("hex")}`
|
|
271
|
+
const text = content
|
|
272
|
+
.filter(
|
|
273
|
+
(block): block is { type: "text"; text: string } =>
|
|
274
|
+
typeof block === "object" &&
|
|
275
|
+
block !== null &&
|
|
276
|
+
(block as Record<string, unknown>).type === "text" &&
|
|
277
|
+
typeof (block as Record<string, unknown>).text === "string",
|
|
278
|
+
)
|
|
279
|
+
.map((block) => block.text)
|
|
280
|
+
.join("\n")
|
|
281
|
+
return { identity, text }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const TRANSCRIPT_CHUNK_SIZE = 64 * 1024
|
|
285
|
+
const TRANSCRIPT_ENTRY_HEADER_SIZE = 64 * 1024
|
|
286
|
+
|
|
287
|
+
type TranscriptEntryKind = "assistant" | "user" | "other" | "unknown"
|
|
288
|
+
|
|
289
|
+
interface JsonFrame {
|
|
290
|
+
readonly kind: "array" | "object"
|
|
291
|
+
readonly message: boolean
|
|
292
|
+
key?: string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function jsonStringEnd(text: string, start: number): number | undefined {
|
|
296
|
+
let escaped = false
|
|
297
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
298
|
+
const character = text[index]
|
|
299
|
+
if (escaped) {
|
|
300
|
+
escaped = false
|
|
301
|
+
} else if (character === "\\") {
|
|
302
|
+
escaped = true
|
|
303
|
+
} else if (character === '"') {
|
|
304
|
+
return index
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function transcriptEntryKind(header: string): TranscriptEntryKind {
|
|
311
|
+
const stack: JsonFrame[] = []
|
|
312
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
313
|
+
const character = header[index]
|
|
314
|
+
if (character === '"') {
|
|
315
|
+
const end = jsonStringEnd(header, index)
|
|
316
|
+
if (end === undefined) return "unknown"
|
|
317
|
+
const frame = stack.at(-1)
|
|
318
|
+
if (frame?.kind === "object") {
|
|
319
|
+
const value = JSON.parse(header.slice(index, end + 1)) as string
|
|
320
|
+
let next = end + 1
|
|
321
|
+
while (/\s/.test(header[next] ?? "")) next += 1
|
|
322
|
+
if (header[next] === ":") {
|
|
323
|
+
frame.key = value
|
|
324
|
+
index = next
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (stack.length === 1 && frame.key === "type") {
|
|
328
|
+
if (value === "assistant" || value === "user") return value
|
|
329
|
+
return "other"
|
|
330
|
+
}
|
|
331
|
+
if (frame.message && frame.key === "role") {
|
|
332
|
+
if (value === "assistant" || value === "user") return value
|
|
333
|
+
return "other"
|
|
334
|
+
}
|
|
335
|
+
frame.key = undefined
|
|
336
|
+
}
|
|
337
|
+
index = end
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
if (character === "{") {
|
|
341
|
+
const parent = stack.at(-1)
|
|
342
|
+
const message = parent?.kind === "object" && stack.length === 1 && parent.key === "message"
|
|
343
|
+
if (parent?.kind === "object") parent.key = undefined
|
|
344
|
+
stack.push({ kind: "object", message })
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (character === "[") {
|
|
348
|
+
const parent = stack.at(-1)
|
|
349
|
+
if (parent?.kind === "object") parent.key = undefined
|
|
350
|
+
stack.push({ kind: "array", message: false })
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
if (character === "}" || character === "]") {
|
|
354
|
+
stack.pop()
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (character === ",") {
|
|
358
|
+
const frame = stack.at(-1)
|
|
359
|
+
if (frame?.kind === "object") frame.key = undefined
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return "unknown"
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function readTranscriptRange(
|
|
366
|
+
file: Awaited<ReturnType<typeof open>>,
|
|
367
|
+
path: string,
|
|
368
|
+
position: number,
|
|
369
|
+
length: number,
|
|
370
|
+
): Promise<Buffer> {
|
|
371
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
372
|
+
let offset = 0
|
|
373
|
+
while (offset < length) {
|
|
374
|
+
const { bytesRead } = await file.read(buffer, offset, length - offset, position + offset)
|
|
375
|
+
if (bytesRead === 0) throw new Error(`transcript changed while reading ${path}`)
|
|
376
|
+
offset += bytesRead
|
|
377
|
+
}
|
|
378
|
+
return buffer
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function assistantReplyInRange(
|
|
382
|
+
file: Awaited<ReturnType<typeof open>>,
|
|
383
|
+
path: string,
|
|
384
|
+
start: number,
|
|
385
|
+
end: number,
|
|
386
|
+
): Promise<AssistantReply | undefined> {
|
|
387
|
+
const length = end - start
|
|
388
|
+
const headerLength = Math.min(length, TRANSCRIPT_ENTRY_HEADER_SIZE)
|
|
389
|
+
const header = await readTranscriptRange(file, path, start, headerLength)
|
|
390
|
+
if (transcriptEntryKind(header.toString("utf8")) !== "assistant") return undefined
|
|
391
|
+
const line =
|
|
392
|
+
headerLength === length
|
|
393
|
+
? header.toString("utf8")
|
|
394
|
+
: (await readTranscriptRange(file, path, start, length)).toString("utf8")
|
|
395
|
+
return assistantReply(line, path, start)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function turnIdentityInRange(
|
|
399
|
+
file: Awaited<ReturnType<typeof open>>,
|
|
400
|
+
path: string,
|
|
401
|
+
start: number,
|
|
402
|
+
end: number,
|
|
403
|
+
): Promise<string | undefined> {
|
|
404
|
+
const length = end - start
|
|
405
|
+
const headerLength = Math.min(length, TRANSCRIPT_ENTRY_HEADER_SIZE)
|
|
406
|
+
const header = await readTranscriptRange(file, path, start, headerLength)
|
|
407
|
+
if (transcriptEntryKind(header.toString("utf8")) !== "user") return undefined
|
|
408
|
+
if (headerLength === length) {
|
|
409
|
+
try {
|
|
410
|
+
const value = JSON.parse(header.toString("utf8")) as unknown
|
|
411
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
412
|
+
const uuid = (value as Record<string, unknown>).uuid
|
|
413
|
+
if (typeof uuid === "string" && uuid.length > 0) return `turn-uuid:${uuid}`
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
return `turn-offset:${start}:${createHash("sha256").update(header).digest("hex")}`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return `turn-offset:${start}:${createHash("sha256").update(header).digest("hex")}`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function latestTranscriptEntry<T>(
|
|
423
|
+
path: string,
|
|
424
|
+
readEntry: (
|
|
425
|
+
file: Awaited<ReturnType<typeof open>>,
|
|
426
|
+
path: string,
|
|
427
|
+
start: number,
|
|
428
|
+
end: number,
|
|
429
|
+
) => Promise<T | undefined>,
|
|
430
|
+
): Promise<T | undefined> {
|
|
431
|
+
const file = await open(path, "r")
|
|
432
|
+
try {
|
|
433
|
+
const { size } = await file.stat()
|
|
434
|
+
let position = size
|
|
435
|
+
let entryEnd = size
|
|
436
|
+
|
|
437
|
+
while (position > 0) {
|
|
438
|
+
const length = Math.min(TRANSCRIPT_CHUNK_SIZE, position)
|
|
439
|
+
position -= length
|
|
440
|
+
const chunk = await readTranscriptRange(file, path, position, length)
|
|
441
|
+
let lineEnd = chunk.length
|
|
442
|
+
for (;;) {
|
|
443
|
+
const newline = chunk.lastIndexOf(10, lineEnd - 1)
|
|
444
|
+
if (newline === -1) break
|
|
445
|
+
const entryStart = position + newline + 1
|
|
446
|
+
if (entryStart < entryEnd) {
|
|
447
|
+
const entry = await readEntry(file, path, entryStart, entryEnd)
|
|
448
|
+
if (entry !== undefined) return entry
|
|
449
|
+
}
|
|
450
|
+
entryEnd = position + newline
|
|
451
|
+
lineEnd = newline
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return entryEnd > 0 ? readEntry(file, path, 0, entryEnd) : undefined
|
|
456
|
+
} finally {
|
|
457
|
+
await file.close()
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function assistantReplyFromTranscript(path: string): Promise<AssistantReply> {
|
|
462
|
+
const reply = await latestTranscriptEntry(path, assistantReplyInRange)
|
|
463
|
+
if (reply !== undefined) return reply
|
|
464
|
+
throw new Error(`cannot find an assistant reply in ${path}`)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function assistantReplyFromEvent(text: string, path: string): Promise<AssistantReply> {
|
|
468
|
+
const turnIdentity = await latestTranscriptEntry(path, turnIdentityInRange)
|
|
469
|
+
if (turnIdentity === undefined) throw new Error(`cannot find a reply turn in ${path}`)
|
|
470
|
+
const textHash = createHash("sha256").update(text).digest("hex")
|
|
471
|
+
return { identity: `${turnIdentity}:reply:${textHash}`, text }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const readAssistantReply = (path: string) =>
|
|
475
|
+
Effect.tryPromise({
|
|
476
|
+
try: () => assistantReplyFromTranscript(path),
|
|
477
|
+
catch: (cause) => new Error(`cannot read assistant reply from ${path}: ${cause}`),
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const readEventAssistantReply = (text: string, path: string) =>
|
|
481
|
+
Effect.tryPromise({
|
|
482
|
+
try: () => assistantReplyFromEvent(text, path),
|
|
483
|
+
catch: (cause) => new Error(`cannot read assistant reply turn from ${path}: ${cause}`),
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const replyWasProcessed = (sessionId: string, replyIdentity: string) =>
|
|
487
|
+
Effect.tryPromise({
|
|
488
|
+
try: () => hasProcessedReply(sessionId, replyIdentity),
|
|
489
|
+
catch: (cause) => new Error(`cannot read session state: ${cause}`),
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const updateReplyFeedback = (
|
|
493
|
+
sessionId: string,
|
|
494
|
+
replyIdentity: string,
|
|
495
|
+
feedback: string | undefined,
|
|
496
|
+
) =>
|
|
497
|
+
Effect.tryPromise({
|
|
498
|
+
try: () => setReplyFeedback(sessionId, replyIdentity, feedback),
|
|
499
|
+
catch: (cause) => new Error(`cannot update session state: ${cause}`),
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
const takePendingFeedback = (sessionId: string) =>
|
|
503
|
+
Effect.tryPromise({
|
|
504
|
+
try: () => consumePendingFeedback(sessionId),
|
|
505
|
+
catch: (cause) => new Error(`cannot read session state: ${cause}`),
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const readSessionControl = (sessionId: string) =>
|
|
509
|
+
Effect.tryPromise({
|
|
510
|
+
try: () => getSessionControl(sessionId),
|
|
511
|
+
catch: (cause) => new Error(`cannot read session state: ${cause}`),
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
const loadLintOptions = (cwd: string, tagger: Tagger) => {
|
|
515
|
+
const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
|
|
516
|
+
return Effect.all({
|
|
517
|
+
config: loadConfig(undefined, cwd),
|
|
518
|
+
dictionary: loadDictionary(
|
|
519
|
+
dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath),
|
|
520
|
+
),
|
|
521
|
+
}).pipe(Effect.map(({ config, dictionary }): LintOptions => ({ ...config, dictionary, tagger })))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function splitViolations(violations: readonly Violation[]): {
|
|
525
|
+
readonly hard: Violation[]
|
|
526
|
+
readonly soft: Violation[]
|
|
527
|
+
} {
|
|
528
|
+
return {
|
|
529
|
+
hard: violations.filter((violation) => violation.severity === "hard"),
|
|
530
|
+
soft: violations.filter((violation) => violation.severity === "soft"),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function textDecision(
|
|
535
|
+
operation: "write" | "edit",
|
|
536
|
+
path: string,
|
|
537
|
+
text: string,
|
|
538
|
+
options: LintOptions,
|
|
539
|
+
previousText?: string,
|
|
540
|
+
): HookDecision {
|
|
541
|
+
const classification = classifyPath(path)
|
|
542
|
+
const report = lint(classification.kind, text, {
|
|
543
|
+
...options,
|
|
544
|
+
sourceDialect: classification.sourceDialect,
|
|
545
|
+
previousText,
|
|
546
|
+
})
|
|
547
|
+
const { hard, soft } = splitViolations(report.violations)
|
|
548
|
+
if (hard.length > 0) {
|
|
549
|
+
return deny(formatViolations(path, `STE blocked ${operation} for`, hard))
|
|
550
|
+
}
|
|
551
|
+
return allow(soft.length === 0 ? [] : [formatViolations(path, "STE warnings for", soft)])
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookOutput, Error> {
|
|
555
|
+
return Effect.gen(function* () {
|
|
556
|
+
const reply = yield* event.lastAssistantMessage === undefined
|
|
557
|
+
? readAssistantReply(event.transcriptPath)
|
|
558
|
+
: readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
|
|
559
|
+
if (yield* replyWasProcessed(event.sessionId, reply.identity)) {
|
|
560
|
+
return {} as Record<string, never>
|
|
561
|
+
}
|
|
562
|
+
const options = yield* loadLintOptions(event.cwd, tagger)
|
|
563
|
+
const hard = lint("prose-file", reply.text, options).violations.filter(
|
|
564
|
+
(violation) => violation.severity === "hard",
|
|
565
|
+
)
|
|
566
|
+
const feedback =
|
|
567
|
+
hard.length === 0
|
|
568
|
+
? undefined
|
|
569
|
+
: formatViolations("assistant reply", "STE reply feedback for", hard)
|
|
570
|
+
const currentControl = yield* updateReplyFeedback(event.sessionId, reply.identity, feedback)
|
|
571
|
+
if (
|
|
572
|
+
currentControl === undefined ||
|
|
573
|
+
!currentControl.strict ||
|
|
574
|
+
event.stopHookActive ||
|
|
575
|
+
hard.length === 0
|
|
576
|
+
) {
|
|
577
|
+
return {} as Record<string, never>
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
decision: "block",
|
|
581
|
+
reason: formatViolations("assistant reply", "STE blocked reply for", hard),
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function evaluateEvent(event: PreToolUseEvent, tagger: Tagger): Effect.Effect<HookDecision, Error> {
|
|
587
|
+
if (event.toolName === "Bash") {
|
|
588
|
+
const invocations = findCommitInvocations(event.command)
|
|
589
|
+
if (invocations.length === 0) return Effect.succeed(allow())
|
|
590
|
+
if (invocations.some((invocation) => invocation.requiresExplicitMessage)) {
|
|
591
|
+
return Effect.succeed(
|
|
592
|
+
deny(
|
|
593
|
+
"STE could not check the git commit message. Use git commit with a static -m or --message argument.",
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
return Effect.gen(function* () {
|
|
598
|
+
const options = yield* loadLintOptions(event.cwd, tagger)
|
|
599
|
+
const violations = invocations.flatMap((invocation) =>
|
|
600
|
+
invocation.requiresExplicitMessage
|
|
601
|
+
? []
|
|
602
|
+
: lint("commit-message", blankCommitMetadata(invocation.message), options).violations,
|
|
603
|
+
)
|
|
604
|
+
const { hard, soft } = splitViolations(violations)
|
|
605
|
+
if (hard.length > 0) {
|
|
606
|
+
return deny(formatViolations("commit message", "STE blocked commit for", hard))
|
|
607
|
+
}
|
|
608
|
+
return allow(
|
|
609
|
+
soft.length === 0 ? [] : [formatViolations("commit message", "STE warnings for", soft)],
|
|
610
|
+
)
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return Effect.gen(function* () {
|
|
615
|
+
const options = yield* loadLintOptions(event.cwd, tagger)
|
|
616
|
+
const path = resolve(event.cwd, event.filePath)
|
|
617
|
+
if (event.toolName === "Write") {
|
|
618
|
+
return textDecision("write", path, event.content, options)
|
|
619
|
+
}
|
|
620
|
+
const previousText = yield* readEditFile(path)
|
|
621
|
+
const text = proposedEdit(previousText, event.oldString, event.newString, event.replaceAll)
|
|
622
|
+
return textDecision("edit", path, text, options, previousText)
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function runHookMode(raw: string): Effect.Effect<HookOutput, never, TaggerService> {
|
|
627
|
+
return Effect.try({
|
|
628
|
+
try: () => decodeEvent(raw),
|
|
629
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
630
|
+
}).pipe(
|
|
631
|
+
Effect.matchEffect({
|
|
632
|
+
onFailure: (error) => Effect.succeed(nonBlockingError(error.message)),
|
|
633
|
+
onSuccess: (event) =>
|
|
634
|
+
readSessionControl(event.sessionId).pipe(
|
|
635
|
+
Effect.flatMap((control): Effect.Effect<HookOutput, Error, TaggerService> => {
|
|
636
|
+
if (!control.enabled) {
|
|
637
|
+
return Effect.succeed(
|
|
638
|
+
event.hookEventName === "PreToolUse" ? allow() : ({} as Record<string, never>),
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
if (event.hookEventName === "SessionStart") {
|
|
642
|
+
return loadConfig(undefined, event.cwd).pipe(
|
|
643
|
+
Effect.map((config) => addContext("SessionStart", ruleSummary(config))),
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
if (event.hookEventName === "UserPromptSubmit") {
|
|
647
|
+
return takePendingFeedback(event.sessionId).pipe(
|
|
648
|
+
Effect.map((feedback) =>
|
|
649
|
+
feedback === undefined ? {} : addContext("UserPromptSubmit", feedback),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
if (event.hookEventName === "Stop") {
|
|
654
|
+
return Effect.gen(function* () {
|
|
655
|
+
const tagger = yield* TaggerService
|
|
656
|
+
return yield* evaluateReply(event, tagger)
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
return Effect.gen(function* () {
|
|
660
|
+
const tagger = yield* TaggerService
|
|
661
|
+
return yield* evaluateEvent(event, tagger)
|
|
662
|
+
})
|
|
663
|
+
}),
|
|
664
|
+
Effect.catchAll((error) =>
|
|
665
|
+
Effect.succeed(
|
|
666
|
+
event.hookEventName === "PreToolUse"
|
|
667
|
+
? nonBlockingWarning(error.message)
|
|
668
|
+
: nonBlockingError(error.message),
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
Effect.catchAllCause((cause) =>
|
|
672
|
+
Effect.succeed(
|
|
673
|
+
event.hookEventName === "PreToolUse"
|
|
674
|
+
? hookInternalFailure(cause)
|
|
675
|
+
: nonBlockingError(`internal failure: ${Cause.pretty(cause)}`),
|
|
676
|
+
),
|
|
677
|
+
),
|
|
678
|
+
),
|
|
679
|
+
}),
|
|
680
|
+
)
|
|
681
|
+
}
|