agent-simple-english 0.6.0 → 0.8.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.
@@ -10,8 +10,8 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "simple-english",
13
- "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
14
- "version": "0.6.0",
13
+ "description": "Apply technical and house-style writing rules to writes, edits, git commit messages, and replies. Needs Claude Code 2.1.296 or later.",
14
+ "version": "0.8.0",
15
15
  "author": {
16
16
  "name": "JIA YI"
17
17
  },
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "simple-english",
4
- "version": "0.6.0",
5
- "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
4
+ "version": "0.8.0",
5
+ "description": "Apply technical and house-style writing rules to writes, edits, git commit messages, and replies. Needs Claude Code 2.1.296 or later.",
6
6
  "repository": "https://github.com/jyooi/agent-simple-english",
7
7
  "license": "MIT",
8
8
  "keywords": ["simplified-technical-english", "lint", "hooks"],
package/README.md CHANGED
@@ -38,6 +38,7 @@ A config, dictionary, or rule-data load error makes enabled write, edit, and com
38
38
  ## Install the Claude Code Adapter
39
39
 
40
40
  Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Bun](https://bun.sh) first.
41
+ The plugin needs Claude Code 2.1.296 or later.
41
42
  Then use the standard local plugin flow:
42
43
 
43
44
  ```sh
@@ -73,6 +74,11 @@ The `/ase` command controls the current Claude Code session.
73
74
  New sessions start in enabled mode without a strict reply gate.
74
75
  A change in one session does not change a parallel session.
75
76
 
77
+ The full command name is `/simple-english:ase`, and it always runs.
78
+ The short name `/ase` runs the same command.
79
+ If a user command or a project command has the name `ase`, that command takes the short name.
80
+ Then use the full name.
81
+
76
82
  | Command | Result |
77
83
  | --- | --- |
78
84
  | `/ase on` | Enable write, edit, commit, and reply checks. |
@@ -82,13 +88,44 @@ A change in one session does not change a parallel session.
82
88
  | `/ase strict off` | Disable the strict reply gate and use reply feedback. |
83
89
  | `/ase explain` | Write the last reply again in Simplified Technical English. |
84
90
 
85
- Strict mode blocks a `Stop` event when the reply has a hard violation.
86
- Claude Code then uses the violation details to write the reply again.
87
- The `stop_hook_active` check stops a second block in the same rewrite loop.
88
-
89
- Claude Code shows streamed reply text before the `Stop` hook runs.
90
- Thus, strict mode can reject the completed reply, but it cannot redact text that Claude Code already showed.
91
- The pi Adapter does not have this gap because its `say` tool hides strict reply text before approval.
91
+ Claude Code shows argument suggestions while you type.
92
+ Type `/ase`, press Tab to select the command, then type the first letters of an argument.
93
+ The list shows each argument that starts with those letters, and Tab completes the first one.
94
+ After `strict`, the list suggests `off`.
95
+
96
+ ### Strict reply hold
97
+
98
+ Strict mode holds each reply of the main conversation until the reply check completes.
99
+ Claude Code shows no reply text during the hold.
100
+ A reply with no hard violation then shows unchanged.
101
+ For a reply with a hard violation, Claude Code shows one notice line in place of the text.
102
+ The model then gets the held text and the violation details, and it writes the reply again.
103
+ The Adapter holds and checks that rewrite the same way.
104
+
105
+ The model gets a maximum of two rewrites in one turn.
106
+ The sequence is: the Adapter blocks the first reply, the Adapter blocks the first rewrite, and Claude Code shows the second rewrite.
107
+ The second rewrite shows as the model wrote it, also when it has a hard violation.
108
+
109
+ A step that has text and a tool call uses the same hold, and the tool runs.
110
+ For such a step with a hard violation, Claude Code shows the notice line, and the Adapter keeps the text.
111
+ The Adapter blocks only when the last reply of the turn has a hard violation.
112
+ The model then also gets the kept text and the violation details of the earlier steps.
113
+ If the last reply has no hard violation, the Adapter does not block, and the reply shows one time.
114
+ The kept text of a step with a tool call does not use one of the two rewrites.
115
+ The hold does not apply to subagent replies.
116
+ If the reply check fails, the Adapter shows the reply and adds one warning line.
117
+ After an interrupt during the hold, Claude Code shows no text for that reply.
118
+
119
+ The hold has these limits:
120
+
121
+ - Strict mode shows no streamed text, because a held reply appears only when it is complete.
122
+ - The transcript file contains the held text in the feedback message for the model.
123
+ The `stream-json` output of `claude -p` also contains that message.
124
+ - A `Stop` hook from another source does not run for a blocked reply.
125
+ It runs for the reply that the user sees.
126
+
127
+ The `Stop` command hook stays as a second layer.
128
+ It blocks one `Stop` event when a reply with a hard violation showed after a failed reply check.
92
129
 
93
130
  ### Reply feedback loop
94
131
 
@@ -132,7 +169,7 @@ pi -e .
132
169
 
133
170
  The mode applies to the current pi session.
134
171
  The extension starts in enabled mode without strict reply gating.
135
- Type `/ase ` to see autocomplete suggestions for `on`, `off`, `status`, and `strict`.
172
+ Type `/ase ` to see autocomplete suggestions for `on`, `off`, `status`, `strict`, and `strict off`.
136
173
  The list changes to match the text that you type.
137
174
 
138
175
  | Command | Result |
@@ -171,6 +208,17 @@ The hook also allows the event when configuration, dictionary, rule-data, tagger
171
208
  It adds warning text for these operational failures.
172
209
  Observation-write failures are silent and do not change the hook output or decision.
173
210
 
211
+ ### Claude Code reply check
212
+
213
+ The `reply-check` subcommand reads one JSON object from standard input.
214
+ The object has the `session_id`, `cwd`, and `text` fields.
215
+ The subcommand checks the text as a reply with the config of that working directory.
216
+
217
+ It writes one JSON object, which has a `feedback` field only when the text has a hard violation.
218
+ It also records one denied reply Observation for that text.
219
+ An input or load error causes exit code 2.
220
+ The hooks module of the plugin runs this subcommand for the strict reply hold.
221
+
174
222
  ### Review hook observations
175
223
 
176
224
  Enabled Hook mode logs every write, edit, static commit-message, and reply lint decision to the local XDG state directory.
@@ -454,6 +502,8 @@ See [Configuration](#configuration) for list selection, precedence, and load err
454
502
  Default: hard.
455
503
  Reports a prose paragraph that has more than six sentences.
456
504
  Markdown block boundaries and list items start separate paragraphs.
505
+ An ordered list item starts a list with any start number, also directly below a paragraph line.
506
+ CommonMark keeps such a line in the paragraph when the number is not 1.
457
507
  Sentence counts use the [`sentence-length`](#sentence-length) segmentation rules.
458
508
 
459
509
  #### `phrasal-verb`
@@ -480,6 +530,8 @@ It keeps an abbreviation in the current sentence when lowercase prose follows it
480
530
  It preserves a sentence boundary before capitalized prose, except after a single capital letter or before a short `Fig.` or `No.` designator such as `Fig. A` or `No. 7`.
481
531
  Markdown block boundaries stop abbreviation lookahead.
482
532
 
533
+ The start of a list item ends the sentence before it.
534
+
483
535
  #### `verb-progressive`
484
536
 
485
537
  Default: hard.
package/commands/ase.md CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: ase
2
3
  description: Control writing-rule enforcement for this Claude Code session
3
4
  argument-hint: on|off|status|strict|strict off|explain
4
5
  allowed-tools: Bash
package/hooks/hooks.json CHANGED
@@ -1,5 +1,7 @@
1
1
  {
2
- "description": "Inject active writing rules, gate changes, and return reply feedback on the next prompt.",
2
+ "description": "Inject active writing rules, gate changes, hold strict replies, and return reply feedback on the next prompt.",
3
+
4
+ "modules": ["./register.ts"],
3
5
 
4
6
  "hooks": {
5
7
  "SessionStart": [
@@ -0,0 +1,61 @@
1
+ import { blockHeldReply, holdReply, restoreReply } from "./reply-hold.ts"
2
+
3
+ interface Suggestion {
4
+ readonly text: string
5
+ readonly description: string
6
+ }
7
+
8
+ interface AutocompleteInput {
9
+ readonly text: string
10
+ readonly token: string
11
+ readonly start: number
12
+ }
13
+
14
+ interface AutocompleteResult {
15
+ readonly suggestions: readonly Suggestion[]
16
+ }
17
+
18
+ type AutocompleteHook = (
19
+ engine: unknown,
20
+ input: AutocompleteInput,
21
+ next: (input: AutocompleteInput) => Promise<AutocompleteResult>,
22
+ ) => Promise<AutocompleteResult>
23
+
24
+ const FIRST_WORDS: readonly Suggestion[] = [
25
+ { text: "on", description: "Enable write, edit, commit, and reply checks" },
26
+ { text: "off", description: "Disable all checks and leave strict mode" },
27
+ { text: "status", description: "Show the mode, rule counts, and dictionary state" },
28
+ { text: "strict", description: "Enable the strict reply gate and all other checks" },
29
+ { text: "explain", description: "Write the last reply again in Simplified Technical English" },
30
+ ]
31
+
32
+ const WORDS_AFTER_STRICT: readonly Suggestion[] = [
33
+ { text: "off", description: "Disable the strict reply gate and use reply feedback" },
34
+ ]
35
+
36
+ export function aseSuggestions(text: string, token: string, start: number): readonly Suggestion[] {
37
+ const head = /^\/(?:simple-english:)?ase\s+(.*)$/u.exec(text.slice(0, start))
38
+ if (head === null) return []
39
+ const earlierWords = (head[1] ?? "").trim().toLowerCase()
40
+ const words =
41
+ earlierWords === "" ? FIRST_WORDS : earlierWords === "strict" ? WORDS_AFTER_STRICT : []
42
+ const typed = token.toLowerCase()
43
+ return words.filter((word) => word.text.startsWith(typed) && word.text !== typed)
44
+ }
45
+
46
+ interface On {
47
+ (event: "prompt.autocomplete", hook: AutocompleteHook): unknown
48
+ (event: "turn.step", hook: typeof holdReply): { catch(handler: typeof restoreReply): void }
49
+ (event: "classic.Stop", hook: typeof blockHeldReply): unknown
50
+ }
51
+
52
+ export function register(on: On): void {
53
+ on("prompt.autocomplete", async (_engine, input, next) => ({
54
+ suggestions: [
55
+ ...aseSuggestions(input.text, input.token, input.start),
56
+ ...(await next(input)).suggestions,
57
+ ],
58
+ }))
59
+ on("turn.step", holdReply).catch(restoreReply)
60
+ on("classic.Stop", blockHeldReply)
61
+ }
@@ -0,0 +1,179 @@
1
+ export const REPLY_HOLD_NOTICE = "The writing rules held this reply. A corrected reply follows."
2
+
3
+ const BLOCK_LIMIT = 2
4
+
5
+ type Piece = Readonly<Record<string, unknown>> & { readonly kind: string }
6
+
7
+ interface StepResult {
8
+ readonly answer: string
9
+ readonly toolUses: readonly { readonly name: string; readonly input: unknown }[]
10
+ readonly stopReason: string | null
11
+ readonly usage: unknown
12
+ }
13
+
14
+ interface StepEvent {
15
+ readonly turnId: string
16
+ readonly index: number
17
+ readonly agentId?: string
18
+ }
19
+
20
+ interface StepNext {
21
+ (e: StepEvent): AsyncGenerator<Piece, StepResult>
22
+ readonly signal: { readonly aborted: boolean }
23
+ }
24
+
25
+ interface HoldApi {
26
+ readonly env: { get(name: string): Promise<string | undefined> }
27
+ readonly fs: { read(path: string): Promise<string> }
28
+ readonly session: { id(): Promise<string>; cwd(): Promise<string> }
29
+ readonly process: {
30
+ run(
31
+ argv: readonly string[],
32
+ init?: { cwd?: string; stdin?: string },
33
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }>
34
+ }
35
+ readonly plugin: { readonly root: string }
36
+ readonly ui: { log(text: string): void }
37
+ }
38
+
39
+ interface HeldReply {
40
+ readonly reply: string
41
+ readonly feedback: string
42
+ }
43
+
44
+ type StopResult = Readonly<Record<string, unknown>>
45
+
46
+ let turn = { id: "", blocks: 0, held: [] as HeldReply[], lastStepHeld: false }
47
+
48
+ export async function sessionStatePath(
49
+ sessionId: string,
50
+ stateHome: string | undefined,
51
+ home: string | undefined,
52
+ ): Promise<string> {
53
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sessionId))
54
+ const key = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0"))
55
+ const root = stateHome?.startsWith("/") ? stateHome : `${home}/.local/state`
56
+ return `${root}/simple-english/sessions/${key.join("")}.json`
57
+ }
58
+
59
+ async function strictSession($: HoldApi): Promise<boolean> {
60
+ try {
61
+ const path = await sessionStatePath(
62
+ await $.session.id(),
63
+ await $.env.get("XDG_STATE_HOME"),
64
+ await $.env.get("HOME"),
65
+ )
66
+ const state = JSON.parse(await $.fs.read(path)) as { enabled?: unknown; strict?: unknown }
67
+ return state.enabled === true && state.strict === true
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ async function replyFeedback($: HoldApi, text: string): Promise<string | undefined> {
74
+ const check = await $.process.run(["bun", "src/cli/main.ts", "reply-check"], {
75
+ cwd: $.plugin.root,
76
+ stdin: JSON.stringify({ session_id: await $.session.id(), cwd: await $.session.cwd(), text }),
77
+ })
78
+ if (check.exitCode !== 0) throw new Error(check.stderr.trim() || `exit code ${check.exitCode}`)
79
+ const feedback = (JSON.parse(check.stdout) as { feedback?: unknown }).feedback
80
+ return typeof feedback === "string" ? feedback : undefined
81
+ }
82
+
83
+ async function readAll(
84
+ stream: AsyncGenerator<Piece, StepResult>,
85
+ ): Promise<{ pieces: Piece[]; result: StepResult }> {
86
+ const pieces: Piece[] = []
87
+ let item = await stream.next()
88
+ while (item.done !== true) {
89
+ pieces.push(item.value)
90
+ item = await stream.next()
91
+ }
92
+ return { pieces, result: item.value }
93
+ }
94
+
95
+ function* noticePieces(pieces: readonly Piece[]): Generator<Piece> {
96
+ let replaced = false
97
+ for (const piece of pieces) {
98
+ if (piece.kind !== "text") {
99
+ yield piece
100
+ } else if (!replaced) {
101
+ replaced = true
102
+ yield { ...piece, text: REPLY_HOLD_NOTICE }
103
+ }
104
+ }
105
+ }
106
+
107
+ const blockReason = (held: readonly HeldReply[]): string =>
108
+ [
109
+ "The writing rules held the reply text below. The user did not see it.",
110
+ ...held.map(({ reply, feedback }) => `<held-reply>\n${reply}\n</held-reply>\n\n${feedback}`),
111
+ "Write the full reply again. Correct each violation. Do not mention this message.",
112
+ ].join("\n\n")
113
+
114
+ export async function* holdReply(
115
+ $: HoldApi,
116
+ e: StepEvent,
117
+ next: StepNext,
118
+ ): AsyncGenerator<Piece, StepResult> {
119
+ if (e.agentId !== undefined) return yield* next(e)
120
+ if (turn.id !== e.turnId) turn = { id: e.turnId, blocks: 0, held: [], lastStepHeld: false }
121
+ turn.lastStepHeld = false
122
+ if (turn.blocks >= BLOCK_LIMIT || !(await strictSession($))) return yield* next(e)
123
+
124
+ const { pieces, result } = await readAll(next(e))
125
+ if (next.signal.aborted) return result
126
+ if (result.answer.trim() === "") {
127
+ yield* pieces
128
+ return result
129
+ }
130
+
131
+ let feedback: string | undefined
132
+ try {
133
+ feedback = await replyFeedback($, result.answer)
134
+ } catch (error) {
135
+ if (next.signal.aborted) return result
136
+ const cause = error instanceof Error ? error.message : String(error)
137
+ $.ui.log(`Writing-rule reply check error: ${cause}. The reply is allowed.`)
138
+ }
139
+ if (next.signal.aborted) return result
140
+ if (feedback === undefined) {
141
+ yield* pieces
142
+ return result
143
+ }
144
+
145
+ turn.held.push({ reply: result.answer, feedback })
146
+ turn.lastStepHeld = true
147
+ yield* noticePieces(pieces)
148
+ return result
149
+ }
150
+
151
+ export async function* restoreReply(
152
+ _$: unknown,
153
+ e: StepEvent,
154
+ next: StepNext & { readonly called: boolean },
155
+ ): AsyncGenerator<Piece, StepResult> {
156
+ if (!next.called) return yield* next(e)
157
+ const { result } = await readAll(next(e))
158
+ if (next.signal.aborted) return result
159
+ if (result.answer !== "") yield { kind: "text", index: 0, text: result.answer }
160
+ for (const [offset, use] of result.toolUses.entries()) {
161
+ const index = offset + 1
162
+ yield { kind: "tool", index, id: `toolu_hold_${e.turnId}_${e.index}_${index}`, name: use.name }
163
+ yield { kind: "input", index, json: JSON.stringify(use.input) }
164
+ }
165
+ yield { kind: "stop", stopReason: result.stopReason, usage: result.usage }
166
+ return result
167
+ }
168
+
169
+ export async function blockHeldReply(
170
+ _$: unknown,
171
+ e: unknown,
172
+ next: (e: unknown) => Promise<StopResult>,
173
+ ): Promise<StopResult> {
174
+ const held = turn.held.splice(0)
175
+ if (!turn.lastStepHeld) return next(e)
176
+ turn.lastStepHeld = false
177
+ turn.blocks += 1
178
+ return { block: blockReason(held) }
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-simple-english",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Technical and house-style English lint engine, CLI, and host adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,14 +40,15 @@
40
40
  "scripts": {
41
41
  "test": "vitest run",
42
42
  "bench:markdown": "vitest bench test/engine/markdown.bench.ts",
43
- "lint": "biome check .",
43
+ "lint": "biome check . && bun scripts/check-no-comments.ts",
44
44
  "typecheck": "tsc --noEmit"
45
45
  },
46
46
  "dependencies": {
47
47
  "@lezer/html": "^1.3.12",
48
48
  "@lezer/markdown": "^1.7.2",
49
- "effect": "4.0.0-rc.112",
49
+ "effect": "4.0.1",
50
50
  "micromark": "^4.0.2",
51
+ "micromark-core-commonmark": "^2.0.3",
51
52
  "micromark-extension-frontmatter": "^2.0.0",
52
53
  "micromark-extension-gfm-table": "^2.1.1",
53
54
  "micromark-util-character": "^2.1.1",
package/src/cli/hook.ts CHANGED
@@ -105,14 +105,14 @@ export type HookOutput =
105
105
  | StopDecision
106
106
  | Record<string, never>
107
107
 
108
- function record(value: unknown, name: string): Record<string, unknown> {
108
+ export function record(value: unknown, name: string): Record<string, unknown> {
109
109
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
110
110
  throw new Error(`${name} must be a JSON object`)
111
111
  }
112
112
  return value as Record<string, unknown>
113
113
  }
114
114
 
115
- function stringField(value: Record<string, unknown>, name: string): string {
115
+ export function stringField(value: Record<string, unknown>, name: string): string {
116
116
  const field = value[name]
117
117
  if (typeof field !== "string") throw new Error(`${name} must be a string`)
118
118
  return field
@@ -441,7 +441,7 @@ const takePendingFeedback = (sessionId: string) =>
441
441
  const readSessionControl = (sessionId: string) =>
442
442
  tryAsync("cannot read session state", () => getSessionControl(sessionId))
443
443
 
444
- const loadLintOptions = (cwd: string, tagger: Tagger) =>
444
+ export const loadLintOptions = (cwd: string, tagger: Tagger) =>
445
445
  Effect.gen(function* () {
446
446
  const config = yield* loadConfig(undefined, cwd)
447
447
  const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
package/src/cli/main.ts CHANGED
@@ -13,6 +13,7 @@ import type { LintKind, LintReport } from "../engine/types.ts"
13
13
  import { makeLazyWinkTagger } from "../tagger/wink.ts"
14
14
  import { hookInternalFailure, runHookMode } from "./hook.ts"
15
15
  import { observationStats, reviewObservations } from "./observation-log.ts"
16
+ import { runReplyCheck } from "./reply-check.ts"
16
17
  import { runSessionCommand } from "./session-command.ts"
17
18
  import { tryAsync } from "./try-async.ts"
18
19
 
@@ -61,8 +62,7 @@ const argumentError = (cause: unknown): Error => {
61
62
  return new Error(error.message)
62
63
  }
63
64
 
64
- // parseArgs accepts "--kind --json" with "--json" as the value. Reject that.
65
- const rejectOptionValue = (values: CliArgs): Effect.Effect<void, Error> => {
65
+ const rejectFlagAsOptionValue = (values: CliArgs): Effect.Effect<void, Error> => {
66
66
  if (values.config?.startsWith("--"))
67
67
  return Effect.fail(new Error("--config requires a file path"))
68
68
  if (values.kind?.startsWith("--")) {
@@ -157,6 +157,11 @@ const hookProgram = Effect.gen(function* () {
157
157
  ),
158
158
  )
159
159
 
160
+ const replyCheckProgram = Effect.gen(function* () {
161
+ console.log(JSON.stringify(yield* runReplyCheck(yield* readStdin, tagger)))
162
+ return 0
163
+ })
164
+
160
165
  const sessionProgram = Effect.gen(function* () {
161
166
  console.log(yield* runSessionCommand(args.slice(1)))
162
167
  return 0
@@ -180,7 +185,7 @@ const lintProgram = Effect.gen(function* () {
180
185
  try: () => parseCliArgs(args),
181
186
  catch: argumentError,
182
187
  })
183
- yield* rejectOptionValue(values)
188
+ yield* rejectFlagAsOptionValue(values)
184
189
  const { json = false, config: configPath, kind, help, version } = values
185
190
  if (help) {
186
191
  console.log(USAGE)
@@ -251,11 +256,13 @@ const lintProgram = Effect.gen(function* () {
251
256
  const program: Effect.Effect<number, Error> =
252
257
  args[0] === "hook"
253
258
  ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(hookProgram))
254
- : args[0] === "session"
255
- ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(sessionProgram))
256
- : args[0] === "observe"
257
- ? observeProgram
258
- : lintProgram
259
+ : args[0] === "reply-check"
260
+ ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(replyCheckProgram))
261
+ : args[0] === "session"
262
+ ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(sessionProgram))
263
+ : args[0] === "observe"
264
+ ? observeProgram
265
+ : lintProgram
259
266
 
260
267
  const handled = program.pipe(
261
268
  Effect.catch((error) =>
@@ -0,0 +1,43 @@
1
+ import { Effect } from "effect"
2
+ import { formatViolations, splitViolations } from "../adapter/feedback.ts"
3
+ import { lint } from "../engine/lint.ts"
4
+ import type { Tagger } from "../engine/tagger.ts"
5
+ import { loadLintOptions, record, stringField } from "./hook.ts"
6
+ import { appendObservation } from "./observation-log.ts"
7
+
8
+ export interface ReplyCheck {
9
+ readonly feedback?: string
10
+ }
11
+
12
+ export function runReplyCheck(raw: string, tagger: Tagger): Effect.Effect<ReplyCheck, Error> {
13
+ return Effect.gen(function* () {
14
+ const { sessionId, cwd, text } = yield* Effect.try({
15
+ try: () => {
16
+ const request = record(JSON.parse(raw) as unknown, "request")
17
+ return {
18
+ sessionId: stringField(request, "session_id"),
19
+ cwd: stringField(request, "cwd"),
20
+ text: stringField(request, "text"),
21
+ }
22
+ },
23
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
24
+ })
25
+ const violations = lint("prose-file", text, yield* loadLintOptions(cwd, tagger)).violations
26
+ const { hard } = splitViolations(violations)
27
+ if (hard.length === 0) return {}
28
+ yield* Effect.tryPromise(() =>
29
+ appendObservation({
30
+ event: "reply",
31
+ kind: "prose-file",
32
+ decision: "deny",
33
+ sessionId,
34
+ cwd,
35
+ text,
36
+ violations,
37
+ }),
38
+ ).pipe(Effect.catch(() => Effect.void))
39
+ return {
40
+ feedback: formatViolations("assistant reply", "Writing rules blocked reply for", hard),
41
+ }
42
+ })
43
+ }
@@ -3,6 +3,7 @@ import type { RuleDataExtensions } from "../dictionary/rule-data.ts"
3
3
  import { type RuleId, ruleIds } from "../engine/rules/registry.ts"
4
4
  import type { RuleSetting } from "../engine/types.ts"
5
5
  import {
6
+ DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
6
7
  formatParseErrorIssues,
7
8
  formatParseErrorTree,
8
9
  type ParseError,
@@ -25,8 +26,6 @@ const RulesSchema = Schema.Struct(
25
26
  Object.fromEntries(ruleIds.map((id) => [id, Schema.optionalKey(RuleSettingSchema)])),
26
27
  )
27
28
 
28
- // One refinement over Unknown, rather than Number plus two checks, so that a
29
- // wrong type and a wrong number both report the same text and the value.
30
29
  const MaxSentenceWordsSchema = Schema.Unknown.pipe(
31
30
  Schema.refine(
32
31
  (value): value is number => typeof value === "number" && Number.isInteger(value) && value > 0,
@@ -51,12 +50,10 @@ const SteConfigSchema = Schema.Struct({
51
50
  approvedWordsPath: Schema.optionalKey(NonEmptyTrimmedString),
52
51
  })
53
52
 
54
- const decodeUnknown = Schema.decodeUnknownEffect(SteConfigSchema, {
55
- onExcessProperty: "error",
56
- errors: "all",
57
- // v4 omits the rejected value from an issue unless this option is on.
58
- reportInput: true,
59
- })
53
+ const decodeUnknown = Schema.decodeUnknownEffect(
54
+ SteConfigSchema,
55
+ DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
56
+ )
60
57
 
61
58
  export class ConfigError extends Error {
62
59
  readonly _tag = "ConfigError"
@@ -5,8 +5,10 @@ import phrasalVerbs from "./data/phrasal-verbs.json" with { type: "json" }
5
5
  import type { RuleDataId } from "./rule-data.ts"
6
6
  import { type Dictionary, decodeDictionaryData } from "./schema.ts"
7
7
 
8
- // Jiti adds a non-enumerable default property that strict schema validation rejects.
9
- const decodeBundledDictionary = (data: object) => decodeDictionaryData({ ...data })
8
+ const withoutJitiDefaultProperty = (data: object): object => ({ ...data })
9
+
10
+ const decodeBundledDictionary = (data: object) =>
11
+ decodeDictionaryData(withoutJitiDefaultProperty(data))
10
12
 
11
13
  export const BUNDLED_RULE_DATA: Readonly<Record<RuleDataId, Dictionary>> = {
12
14
  "phrasal-verb": decodeBundledDictionary(phrasalVerbs),
@@ -2,7 +2,11 @@ import { readFile } from "node:fs/promises"
2
2
  import { resolve } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import { Effect, Schema } from "effect"
5
- import { formatParseErrorIssues, type ParseError } from "../schema/parse-error.ts"
5
+ import {
6
+ DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
7
+ formatParseErrorIssues,
8
+ type ParseError,
9
+ } from "../schema/parse-error.ts"
6
10
  import { BUNDLED_RULE_DATA } from "./bundled-rule-data.ts"
7
11
  import type { RuleData, RuleDataExtensions, RuleDataId } from "./rule-data.ts"
8
12
  import {
@@ -41,14 +45,14 @@ const formatParseError = (error: ParseError): string => {
41
45
  return path === "" ? `invalid dictionary data: ${issue.message}` : `${path}: ${issue.message}`
42
46
  }
43
47
 
44
- // v4 omits the rejected value from an issue unless reportInput is on.
45
- const decodeOptions = { onExcessProperty: "error", errors: "all", reportInput: true } as const
46
-
47
- const decodeDictionary = Schema.decodeEffect(Schema.fromJsonString(DictionarySchema), decodeOptions)
48
+ const decodeDictionary = Schema.decodeEffect(
49
+ Schema.fromJsonString(DictionarySchema),
50
+ DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
51
+ )
48
52
 
49
53
  const decodeApprovedWordList = Schema.decodeEffect(
50
54
  Schema.fromJsonString(ApprovedWordListSchema),
51
- decodeOptions,
55
+ DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
52
56
  )
53
57
 
54
58
  const readDictionaryFile = (
@@ -6,8 +6,6 @@ export interface CaseFoldedToken {
6
6
  readonly offset: number
7
7
  }
8
8
 
9
- // ponytail: toLowerCase() does not fold "ss" from sharp s or merge sigma forms.
10
- // Add unicode-case-folding back if a dictionary needs that reach.
11
9
  export const caseFoldKey = (text: string): string => text.toLowerCase()
12
10
 
13
11
  export const tokenizeCaseFolded = (line: string): readonly CaseFoldedToken[] =>
@@ -128,11 +128,6 @@ function retainCharacters(
128
128
  )
129
129
  }
130
130
 
131
- /**
132
- * Character ranges that survive unchanged between two texts, in document order.
133
- * Line-level LCS first, then character-level LCS inside changed line chunks,
134
- * with a cell budget that falls back to prefix and suffix matching on huge edits.
135
- */
136
131
  export function retainedRanges(previousText: string, currentText: string): RetainedRange[] {
137
132
  const previous = lineTokens(previousText)
138
133
  const current = lineTokens(currentText)