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,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
+ }