agent-simple-english 0.7.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +48 -10
- package/hooks/hooks.json +1 -1
- package/hooks/register.ts +11 -1
- package/hooks/reply-hold.ts +179 -0
- package/package.json +3 -2
- package/src/cli/hook.ts +3 -3
- package/src/cli/main.ts +15 -8
- package/src/cli/reply-check.ts +43 -0
- package/src/config/schema.ts +5 -8
- package/src/dictionary/bundled-rule-data.ts +4 -2
- package/src/dictionary/load.ts +10 -6
- package/src/engine/case-fold.ts +0 -2
- package/src/engine/diff.ts +0 -5
- package/src/engine/html.ts +11 -28
- package/src/engine/identifiers.ts +13 -10
- package/src/engine/kinds.ts +12 -14
- package/src/engine/lint.ts +9 -8
- package/src/engine/markdown-syntax.ts +12 -0
- package/src/engine/markdown.ts +21 -16
- package/src/engine/paragraphs.ts +4 -6
- package/src/engine/phrase-matcher.ts +6 -5
- package/src/engine/rules/contraction.ts +5 -4
- package/src/engine/rules/dictionary.ts +9 -7
- package/src/engine/rules/marketing.ts +4 -5
- package/src/engine/rules/verb-form.ts +5 -9
- package/src/engine/sentences.ts +8 -9
- package/src/engine/suppression.ts +2 -2
- package/src/engine/types.ts +0 -7
- package/src/extension/index.ts +0 -18
- package/src/schema/parse-error.ts +6 -5
- package/src/schema/primitives.ts +0 -2
- package/src/tagger/wink.ts +6 -12
|
@@ -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,
|
|
14
|
-
"version": "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.
|
|
5
|
-
"description": "Apply technical and house-style writing rules to writes, edits,
|
|
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
|
|
@@ -87,22 +88,44 @@ Then use the full name.
|
|
|
87
88
|
| `/ase strict off` | Disable the strict reply gate and use reply feedback. |
|
|
88
89
|
| `/ase explain` | Write the last reply again in Simplified Technical English. |
|
|
89
90
|
|
|
90
|
-
Claude Code
|
|
91
|
+
Claude Code shows argument suggestions while you type.
|
|
91
92
|
Type `/ase`, press Tab to select the command, then type the first letters of an argument.
|
|
92
93
|
The list shows each argument that starts with those letters, and Tab completes the first one.
|
|
93
94
|
After `strict`, the list suggests `off`.
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
Claude Code 2.1.287 to 2.1.291 also show one load error for the suggestion module at session start.
|
|
97
|
-
The writing-rule hooks continue to operate on those versions.
|
|
96
|
+
### Strict reply hold
|
|
98
97
|
|
|
99
|
-
Strict mode
|
|
100
|
-
Claude Code
|
|
101
|
-
|
|
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.
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
The
|
|
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.
|
|
106
129
|
|
|
107
130
|
### Reply feedback loop
|
|
108
131
|
|
|
@@ -185,6 +208,17 @@ The hook also allows the event when configuration, dictionary, rule-data, tagger
|
|
|
185
208
|
It adds warning text for these operational failures.
|
|
186
209
|
Observation-write failures are silent and do not change the hook output or decision.
|
|
187
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
|
+
|
|
188
222
|
### Review hook observations
|
|
189
223
|
|
|
190
224
|
Enabled Hook mode logs every write, edit, static commit-message, and reply lint decision to the local XDG state directory.
|
|
@@ -468,6 +502,8 @@ See [Configuration](#configuration) for list selection, precedence, and load err
|
|
|
468
502
|
Default: hard.
|
|
469
503
|
Reports a prose paragraph that has more than six sentences.
|
|
470
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.
|
|
471
507
|
Sentence counts use the [`sentence-length`](#sentence-length) segmentation rules.
|
|
472
508
|
|
|
473
509
|
#### `phrasal-verb`
|
|
@@ -494,6 +530,8 @@ It keeps an abbreviation in the current sentence when lowercase prose follows it
|
|
|
494
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`.
|
|
495
531
|
Markdown block boundaries stop abbreviation lookahead.
|
|
496
532
|
|
|
533
|
+
The start of a list item ends the sentence before it.
|
|
534
|
+
|
|
497
535
|
#### `verb-progressive`
|
|
498
536
|
|
|
499
537
|
Default: hard.
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
3
|
|
|
4
4
|
"modules": ["./register.ts"],
|
|
5
5
|
|
package/hooks/register.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { blockHeldReply, holdReply, restoreReply } from "./reply-hold.ts"
|
|
2
|
+
|
|
1
3
|
interface Suggestion {
|
|
2
4
|
readonly text: string
|
|
3
5
|
readonly description: string
|
|
@@ -41,11 +43,19 @@ export function aseSuggestions(text: string, token: string, start: number): read
|
|
|
41
43
|
return words.filter((word) => word.text.startsWith(typed) && word.text !== typed)
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
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 {
|
|
45
53
|
on("prompt.autocomplete", async (_engine, input, next) => ({
|
|
46
54
|
suggestions: [
|
|
47
55
|
...aseSuggestions(input.text, input.token, input.start),
|
|
48
56
|
...(await next(input)).suggestions,
|
|
49
57
|
],
|
|
50
58
|
}))
|
|
59
|
+
on("turn.step", holdReply).catch(restoreReply)
|
|
60
|
+
on("classic.Stop", blockHeldReply)
|
|
51
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.
|
|
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,7 +40,7 @@
|
|
|
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": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@lezer/markdown": "^1.7.2",
|
|
49
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
|
-
|
|
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*
|
|
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] === "
|
|
255
|
-
? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(
|
|
256
|
-
: args[0] === "
|
|
257
|
-
?
|
|
258
|
-
:
|
|
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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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),
|
package/src/dictionary/load.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
+
DECODE_OPTIONS_THAT_REPORT_REJECTED_VALUE,
|
|
52
56
|
)
|
|
53
57
|
|
|
54
58
|
const readDictionaryFile = (
|
package/src/engine/case-fold.ts
CHANGED
|
@@ -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[] =>
|
package/src/engine/diff.ts
CHANGED
|
@@ -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)
|