agent-simple-english 0.5.1 → 0.7.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.
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "name": "simple-english",
13
13
  "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
14
- "version": "0.5.1",
14
+ "version": "0.7.0",
15
15
  "author": {
16
16
  "name": "JIA YI"
17
17
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "simple-english",
4
- "version": "0.5.1",
4
+ "version": "0.7.0",
5
5
  "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
6
6
  "repository": "https://github.com/jyooi/agent-simple-english",
7
7
  "license": "MIT",
package/README.md CHANGED
@@ -73,6 +73,11 @@ The `/ase` command controls the current Claude Code session.
73
73
  New sessions start in enabled mode without a strict reply gate.
74
74
  A change in one session does not change a parallel session.
75
75
 
76
+ The full command name is `/simple-english:ase`, and it always runs.
77
+ The short name `/ase` runs the same command.
78
+ If a user command or a project command has the name `ase`, that command takes the short name.
79
+ Then use the full name.
80
+
76
81
  | Command | Result |
77
82
  | --- | --- |
78
83
  | `/ase on` | Enable write, edit, commit, and reply checks. |
@@ -80,6 +85,16 @@ A change in one session does not change a parallel session.
80
85
  | `/ase status` | Show the mode, rule counts, and dictionary state. |
81
86
  | `/ase strict` | Enable the strict reply gate and all other checks. |
82
87
  | `/ase strict off` | Disable the strict reply gate and use reply feedback. |
88
+ | `/ase explain` | Write the last reply again in Simplified Technical English. |
89
+
90
+ Claude Code 2.1.292 or later shows argument suggestions while you type.
91
+ Type `/ase`, press Tab to select the command, then type the first letters of an argument.
92
+ The list shows each argument that starts with those letters, and Tab completes the first one.
93
+ After `strict`, the list suggests `off`.
94
+
95
+ An earlier Claude Code version shows only the fixed argument hint.
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.
83
98
 
84
99
  Strict mode blocks a `Stop` event when the reply has a hard violation.
85
100
  Claude Code then uses the violation details to write the reply again.
@@ -131,7 +146,7 @@ pi -e .
131
146
 
132
147
  The mode applies to the current pi session.
133
148
  The extension starts in enabled mode without strict reply gating.
134
- Type `/ase ` to see autocomplete suggestions for `on`, `off`, `status`, and `strict`.
149
+ Type `/ase ` to see autocomplete suggestions for `on`, `off`, `status`, `strict`, and `strict off`.
135
150
  The list changes to match the text that you type.
136
151
 
137
152
  | Command | Result |
@@ -360,7 +375,7 @@ The project file is `.simple-english.json` at the repository root.
360
375
  The global file is `$XDG_CONFIG_HOME/simple-english/config.json`.
361
376
  If `XDG_CONFIG_HOME` is unset or not absolute, the global path is `~/.config/simple-english/config.json`.
362
377
 
363
- Global and project files are deep-merged.
378
+ Global and project files merge key by key, including the nested `rules` and `ruleDataExtensions` objects.
364
379
  Global values load first, and project values have precedence.
365
380
  The pi Adapter reads the project file only when pi trusts the project.
366
381
  The `--config` flag uses only its named file.
package/commands/ase.md CHANGED
@@ -1,13 +1,14 @@
1
1
  ---
2
+ name: ase
2
3
  description: Control writing-rule enforcement for this Claude Code session
3
- argument-hint: on|off|status|strict|strict off
4
+ argument-hint: on|off|status|strict|strict off|explain
4
5
  allowed-tools: Bash
5
6
  disable-model-invocation: true
6
7
  ---
7
8
 
8
- Session state:
9
+ Command output:
9
10
 
10
11
  !`cd "${CLAUDE_PLUGIN_ROOT}" && bun "src/cli/main.ts" session "${CLAUDE_SESSION_ID}" "${CLAUDE_PROJECT_DIR}" "$ARGUMENTS"`
11
12
 
12
- Show the session state exactly.
13
- Do not add text.
13
+ If the output starts with "## Rewrite request", do what it asks.
14
+ Otherwise, show the output exactly and add no text.
package/hooks/hooks.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "description": "Inject active writing rules, gate changes, and return reply feedback on the next prompt.",
3
3
 
4
+ "modules": ["./register.ts"],
5
+
4
6
  "hooks": {
5
7
  "SessionStart": [
6
8
  {
@@ -0,0 +1,51 @@
1
+ interface Suggestion {
2
+ readonly text: string
3
+ readonly description: string
4
+ }
5
+
6
+ interface AutocompleteInput {
7
+ readonly text: string
8
+ readonly token: string
9
+ readonly start: number
10
+ }
11
+
12
+ interface AutocompleteResult {
13
+ readonly suggestions: readonly Suggestion[]
14
+ }
15
+
16
+ type AutocompleteHook = (
17
+ engine: unknown,
18
+ input: AutocompleteInput,
19
+ next: (input: AutocompleteInput) => Promise<AutocompleteResult>,
20
+ ) => Promise<AutocompleteResult>
21
+
22
+ const FIRST_WORDS: readonly Suggestion[] = [
23
+ { text: "on", description: "Enable write, edit, commit, and reply checks" },
24
+ { text: "off", description: "Disable all checks and leave strict mode" },
25
+ { text: "status", description: "Show the mode, rule counts, and dictionary state" },
26
+ { text: "strict", description: "Enable the strict reply gate and all other checks" },
27
+ { text: "explain", description: "Write the last reply again in Simplified Technical English" },
28
+ ]
29
+
30
+ const WORDS_AFTER_STRICT: readonly Suggestion[] = [
31
+ { text: "off", description: "Disable the strict reply gate and use reply feedback" },
32
+ ]
33
+
34
+ export function aseSuggestions(text: string, token: string, start: number): readonly Suggestion[] {
35
+ const head = /^\/(?:simple-english:)?ase\s+(.*)$/u.exec(text.slice(0, start))
36
+ if (head === null) return []
37
+ const earlierWords = (head[1] ?? "").trim().toLowerCase()
38
+ const words =
39
+ earlierWords === "" ? FIRST_WORDS : earlierWords === "strict" ? WORDS_AFTER_STRICT : []
40
+ const typed = token.toLowerCase()
41
+ return words.filter((word) => word.text.startsWith(typed) && word.text !== typed)
42
+ }
43
+
44
+ export function register(on: (event: "prompt.autocomplete", hook: AutocompleteHook) => void): void {
45
+ on("prompt.autocomplete", async (_engine, input, next) => ({
46
+ suggestions: [
47
+ ...aseSuggestions(input.text, input.token, input.start),
48
+ ...(await next(input)).suggestions,
49
+ ],
50
+ }))
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-simple-english",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Technical and house-style English lint engine, CLI, and host adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
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
51
  "micromark-extension-frontmatter": "^2.0.0",
52
52
  "micromark-extension-gfm-table": "^2.1.1",
@@ -1,23 +1,8 @@
1
1
  import type { SteConfig } from "../config/schema.ts"
2
2
  import { DEFAULT_MAX_SENTENCE_WORDS } from "../engine/lint.ts"
3
- import type { RuleId } from "../engine/rules/registry.ts"
3
+ import { DEFAULT_SEVERITIES, type RuleId } from "../engine/rules/registry.ts"
4
4
  import type { RuleSetting } from "../engine/types.ts"
5
5
 
6
- const DEFAULT_RULE_SETTINGS: Readonly<Record<RuleId, RuleSetting>> = {
7
- contraction: "hard",
8
- "dictionary-not-approved-word": "hard",
9
- hedging: "soft",
10
- "invalid-suppression": "hard",
11
- marketing: "soft",
12
- "paragraph-length": "hard",
13
- "phrasal-verb": "hard",
14
- semicolon: "hard",
15
- "sentence-length": "hard",
16
- "verb-progressive": "hard",
17
- "verb-passive": "soft",
18
- "verb-perfect": "hard",
19
- }
20
-
21
6
  export const RULE_SUMMARIES: Readonly<Record<RuleId, string>> = {
22
7
  contraction: "Do not use contractions. Write the words in full.",
23
8
  "dictionary-not-approved-word": "Use approved words from the STE dictionary.",
@@ -34,7 +19,7 @@ export const RULE_SUMMARIES: Readonly<Record<RuleId, string>> = {
34
19
  }
35
20
 
36
21
  export function resolvedRuleSetting(config: SteConfig, ruleId: RuleId): RuleSetting {
37
- return config.rules?.[ruleId] ?? DEFAULT_RULE_SETTINGS[ruleId]
22
+ return config.rules?.[ruleId] ?? DEFAULT_SEVERITIES[ruleId]
38
23
  }
39
24
 
40
25
  export function formatFailedStatusSummary(
@@ -87,7 +72,7 @@ function formatRuleGroup(
87
72
  return `### ${heading}\n\n${rules.join("\n")}`
88
73
  }
89
74
 
90
- export function ruleSummary(config: SteConfig): string {
75
+ function enabledRuleSections(config: SteConfig): string | undefined {
91
76
  const maxSentenceWords = config.maxSentenceWords ?? DEFAULT_MAX_SENTENCE_WORDS
92
77
  const ruleIds = Object.keys(RULE_SUMMARIES) as RuleId[]
93
78
  const sections = [
@@ -112,9 +97,22 @@ export function ruleSummary(config: SteConfig): string {
112
97
  maxSentenceWords,
113
98
  ),
114
99
  ].filter((section): section is string => section !== undefined)
100
+ return sections.length === 0 ? undefined : sections.join("\n\n")
101
+ }
102
+
103
+ const NO_RULES_ENABLED = "No writing rules are enabled."
104
+
105
+ export function ruleSummary(config: SteConfig): string {
106
+ const sections = enabledRuleSections(config)
115
107
  const rules =
116
- sections.length === 0
117
- ? "No writing rules are enabled."
118
- : `Apply these enabled rules to prose that you write or edit:\n\n${sections.join("\n\n")}`
119
- return `## Writing rules\n\n${rules}\n\nWrites, edits, and git commit messages reject hard violations. Correct the reported text and retry. Soft violations produce warnings.`
108
+ sections === undefined
109
+ ? NO_RULES_ENABLED
110
+ : `Write all prose in ASD-STE100 Simplified Technical English, including your replies to the user. Apply these enabled rules:\n\n${sections}`
111
+ return `## Writing rules\n\n${rules}\n\nWrites, edits, and git commit messages reject hard violations. Correct the reported text and retry. Soft violations produce warnings. Replies are also checked.`
112
+ }
113
+
114
+ export function explainRequest(config: SteConfig): string {
115
+ const sections = enabledRuleSections(config)
116
+ if (sections === undefined) return NO_RULES_ENABLED
117
+ return `## Rewrite request\n\nWrite your last reply again in ASD-STE100 Simplified Technical English. Keep all facts, code, commands, paths, and numbers the same. Do not add new content.\n\nApply these enabled rules:\n\n${sections}`
120
118
  }
package/src/cli/hook.ts CHANGED
@@ -12,7 +12,6 @@ import { classifyPath } from "../engine/kinds.ts"
12
12
  import { lint } from "../engine/lint.ts"
13
13
  import type { Tagger } from "../engine/tagger.ts"
14
14
  import type { LintKind, LintOptions, ReportViolation } from "../engine/types.ts"
15
- import { TaggerService } from "../tagger/wink.ts"
16
15
  import {
17
16
  appendObservation,
18
17
  type ObservationDraft,
@@ -638,7 +637,7 @@ function recordEvaluation(event: HookEvent, evaluation: HookEvaluation): Effect.
638
637
  )
639
638
  }
640
639
 
641
- export function runHookMode(raw: string): Effect.Effect<HookOutput, never, TaggerService> {
640
+ export function runHookMode(raw: string, tagger: Tagger): Effect.Effect<HookOutput, never> {
642
641
  return Effect.try({
643
642
  try: () => decodeEvent(raw),
644
643
  catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
@@ -647,7 +646,7 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
647
646
  onFailure: (error) => Effect.succeed(nonBlockingError(error.message)),
648
647
  onSuccess: (event) =>
649
648
  readSessionControl(event.sessionId).pipe(
650
- Effect.flatMap((control): Effect.Effect<HookOutput, Error, TaggerService> => {
649
+ Effect.flatMap((control): Effect.Effect<HookOutput, Error> => {
651
650
  if (!control.enabled) {
652
651
  return Effect.succeed(
653
652
  event.hookEventName === "PreToolUse" ? allow() : ({} as Record<string, never>),
@@ -665,18 +664,11 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
665
664
  ),
666
665
  )
667
666
  }
668
- if (event.hookEventName === "Stop") {
669
- return Effect.gen(function* () {
670
- const tagger = yield* TaggerService
671
- const evaluation = yield* evaluateReply(event, tagger)
672
- return yield* recordEvaluation(event, evaluation)
673
- })
674
- }
675
- return Effect.gen(function* () {
676
- const tagger = yield* TaggerService
677
- const evaluation = yield* evaluateEvent(event, tagger)
678
- return yield* recordEvaluation(event, evaluation)
679
- })
667
+ const evaluation =
668
+ event.hookEventName === "Stop"
669
+ ? evaluateReply(event, tagger)
670
+ : evaluateEvent(event, tagger)
671
+ return evaluation.pipe(Effect.flatMap((result) => recordEvaluation(event, result)))
680
672
  }),
681
673
  Effect.catch((error) =>
682
674
  Effect.succeed(
package/src/cli/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { readFile } from "node:fs/promises"
3
+ import { parseArgs } from "node:util"
3
4
  import { Effect, Result } from "effect"
4
5
  import packageManifest from "../../package.json" with { type: "json" }
5
6
  import { splitViolations } from "../adapter/feedback.ts"
@@ -9,10 +10,11 @@ import { loadRuleData } from "../dictionary/load.ts"
9
10
  import { classifyPath, type PathClassification } from "../engine/kinds.ts"
10
11
  import { lint } from "../engine/lint.ts"
11
12
  import type { LintKind, LintReport } from "../engine/types.ts"
12
- import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
13
+ import { makeLazyWinkTagger } from "../tagger/wink.ts"
13
14
  import { hookInternalFailure, runHookMode } from "./hook.ts"
14
15
  import { observationStats, reviewObservations } from "./observation-log.ts"
15
16
  import { runSessionCommand } from "./session-command.ts"
17
+ import { tryAsync } from "./try-async.ts"
16
18
 
17
19
  const KINDS: readonly LintKind[] = [
18
20
  "prose-file",
@@ -33,58 +35,41 @@ Options:
33
35
  --help Print this help.
34
36
  --version Print the package version.`
35
37
 
36
- interface CliArgs {
37
- readonly json: boolean
38
- readonly configPath: string | undefined
39
- readonly kind: string | undefined
40
- readonly kindMissingValue: boolean
41
- readonly help: boolean
42
- readonly version: boolean
43
- readonly paths: readonly string[]
38
+ const CLI_OPTIONS = {
39
+ json: { type: "boolean" },
40
+ config: { type: "string" },
41
+ kind: { type: "string" },
42
+ help: { type: "boolean" },
43
+ version: { type: "boolean" },
44
+ } as const
45
+
46
+ type CliArgs = ReturnType<typeof parseCliArgs>["values"]
47
+
48
+ const parseCliArgs = (args: readonly string[]) =>
49
+ parseArgs({ args: [...args], options: CLI_OPTIONS, allowPositionals: true, strict: true })
50
+
51
+ const argumentError = (cause: unknown): Error => {
52
+ const error = cause as { code?: string; message: string }
53
+ const flag = /'(-{1,2}[\w-]+)/u.exec(error.message)?.[1]
54
+ if (error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") return new Error(`unknown flag "${flag}"`)
55
+ if (error.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && flag === "--config") {
56
+ return new Error("--config requires a file path")
57
+ }
58
+ if (error.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && flag === "--kind") {
59
+ return new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`)
60
+ }
61
+ return new Error(error.message)
44
62
  }
45
63
 
46
- const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
47
- Effect.gen(function* () {
48
- let json = false
49
- let configPath: string | undefined
50
- let kind: string | undefined
51
- let kindMissingValue = false
52
- let help = false
53
- let version = false
54
- const paths: string[] = []
55
- for (let i = 0; i < args.length; i++) {
56
- const arg = args[i] as string
57
- if (arg === "--json") {
58
- json = true
59
- } else if (arg === "--config") {
60
- const value = args[i + 1]
61
- if (value === undefined || value.startsWith("--")) {
62
- yield* Effect.fail(new Error("--config requires a file path"))
63
- }
64
- configPath = value
65
- i++
66
- } else if (arg === "--kind") {
67
- const value = args[i + 1]
68
- if (value === undefined || value.startsWith("--")) {
69
- kindMissingValue = true
70
- } else {
71
- kind = value
72
- i++
73
- }
74
- } else if (arg.startsWith("--kind=")) {
75
- kind = arg.slice("--kind=".length)
76
- } else if (arg === "--help") {
77
- help = true
78
- } else if (arg === "--version") {
79
- version = true
80
- } else if (arg.startsWith("--")) {
81
- yield* Effect.fail(new Error(`unknown flag "${arg}"`))
82
- } else {
83
- paths.push(arg)
84
- }
85
- }
86
- return { json, configPath, kind, kindMissingValue, help, version, paths }
87
- })
64
+ // parseArgs accepts "--kind --json" with "--json" as the value. Reject that.
65
+ const rejectOptionValue = (values: CliArgs): Effect.Effect<void, Error> => {
66
+ if (values.config?.startsWith("--"))
67
+ return Effect.fail(new Error("--config requires a file path"))
68
+ if (values.kind?.startsWith("--")) {
69
+ return Effect.fail(new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`))
70
+ }
71
+ return Effect.void
72
+ }
88
73
 
89
74
  const rejectUnknownFlags = (args: readonly string[]): Effect.Effect<void, Error> => {
90
75
  const flag = args.find((arg) => arg.startsWith("--"))
@@ -122,10 +107,9 @@ const readStdin = Effect.promise(async () => {
122
107
  const readInput = (path: string) =>
123
108
  path === "-"
124
109
  ? readStdin.pipe(Effect.map((text) => ({ path: "<stdin>", text })))
125
- : Effect.tryPromise({
126
- try: () => readFile(path, "utf8"),
127
- catch: (cause) => new Error(`cannot read ${path}: ${cause}`),
128
- }).pipe(Effect.map((text) => ({ path, text })))
110
+ : tryAsync(`cannot read ${path}`, () => readFile(path, "utf8")).pipe(
111
+ Effect.map((text) => ({ path, text })),
112
+ )
129
113
 
130
114
  const toCliReport = (
131
115
  reports: readonly { path: string; report: LintReport }[],
@@ -158,9 +142,10 @@ const render = (report: CliReport, json: boolean): string => {
158
142
  }
159
143
 
160
144
  const args = process.argv.slice(2)
145
+ const tagger = makeLazyWinkTagger()
161
146
 
162
147
  const hookProgram = Effect.gen(function* () {
163
- const output = yield* runHookMode(yield* readStdin).pipe(Effect.provide(WinkTaggerLive))
148
+ const output = yield* runHookMode(yield* readStdin, tagger)
164
149
  console.log(JSON.stringify(output))
165
150
  return 0
166
151
  }).pipe(
@@ -183,24 +168,20 @@ const observeProgram = Effect.gen(function* () {
183
168
  return yield* Effect.fail(new Error("Usage: simple-english observe <review|stats>"))
184
169
  }
185
170
  if (command === "review") {
186
- yield* Effect.tryPromise({
187
- try: () => reviewObservations(),
188
- catch: (cause) => new Error(`cannot review observations: ${cause}`),
189
- })
171
+ yield* tryAsync("cannot review observations", reviewObservations)
190
172
  } else {
191
- console.log(
192
- yield* Effect.tryPromise({
193
- try: () => observationStats(),
194
- catch: (cause) => new Error(`cannot read observation stats: ${cause}`),
195
- }),
196
- )
173
+ console.log(yield* tryAsync("cannot read observation stats", observationStats))
197
174
  }
198
175
  return 0
199
176
  })
200
177
 
201
178
  const lintProgram = Effect.gen(function* () {
202
- const tagger = yield* TaggerService
203
- const { json, configPath, kind, kindMissingValue, help, version, paths } = yield* parseArgs(args)
179
+ const { values, positionals: paths } = yield* Effect.try({
180
+ try: () => parseCliArgs(args),
181
+ catch: argumentError,
182
+ })
183
+ yield* rejectOptionValue(values)
184
+ const { json = false, config: configPath, kind, help, version } = values
204
185
  if (help) {
205
186
  console.log(USAGE)
206
187
  return 0
@@ -209,11 +190,6 @@ const lintProgram = Effect.gen(function* () {
209
190
  console.log(packageManifest.version)
210
191
  return 0
211
192
  }
212
- if (kindMissingValue) {
213
- return yield* Effect.fail(
214
- new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`),
215
- )
216
- }
217
193
  if (kind !== undefined && !isLintKind(kind)) {
218
194
  return yield* Effect.fail(
219
195
  new Error(`unknown kind "${kind}"; expected one of: ${KINDS.join(", ")}`),
@@ -279,7 +255,7 @@ const program: Effect.Effect<number, Error> =
279
255
  ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(sessionProgram))
280
256
  : args[0] === "observe"
281
257
  ? observeProgram
282
- : lintProgram.pipe(Effect.provide(WinkTaggerLive))
258
+ : lintProgram
283
259
 
284
260
  const handled = program.pipe(
285
261
  Effect.catch((error) =>
@@ -1,6 +1,10 @@
1
1
  import { resolve } from "node:path"
2
2
  import { Effect, Result } from "effect"
3
- import { formatFailedStatusSummary, formatStatusSummary } from "../adapter/rule-summary.ts"
3
+ import {
4
+ explainRequest,
5
+ formatFailedStatusSummary,
6
+ formatStatusSummary,
7
+ } from "../adapter/rule-summary.ts"
4
8
  import { loadConfig } from "../config/load.ts"
5
9
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
6
10
  import { loadRuleData } from "../dictionary/load.ts"
@@ -11,8 +15,9 @@ import {
11
15
  setSessionStrict,
12
16
  toggleSessionEnabled,
13
17
  } from "./session-state.ts"
18
+ import { tryAsync } from "./try-async.ts"
14
19
 
15
- const USAGE = "Usage: /ase [on|off|status|strict|strict off]"
20
+ const USAGE = "Usage: /ase [on|off|status|strict|strict off|explain]"
16
21
 
17
22
  type DictionaryState = "loaded" | "not loaded" | `failed (${string})`
18
23
 
@@ -22,28 +27,16 @@ function modeName(control: SessionControl): "disabled" | "enabled" | "strict" {
22
27
  }
23
28
 
24
29
  const readControl = (sessionId: string) =>
25
- Effect.tryPromise({
26
- try: () => getSessionControl(sessionId),
27
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
28
- })
30
+ tryAsync("cannot read session state", () => getSessionControl(sessionId))
29
31
 
30
32
  const updateEnabled = (sessionId: string, enabled: boolean) =>
31
- Effect.tryPromise({
32
- try: () => setSessionEnabled(sessionId, enabled),
33
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
34
- })
33
+ tryAsync("cannot update session state", () => setSessionEnabled(sessionId, enabled))
35
34
 
36
35
  const toggleEnabled = (sessionId: string) =>
37
- Effect.tryPromise({
38
- try: () => toggleSessionEnabled(sessionId),
39
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
40
- })
36
+ tryAsync("cannot update session state", () => toggleSessionEnabled(sessionId))
41
37
 
42
38
  const updateStrict = (sessionId: string, strict: boolean) =>
43
- Effect.tryPromise({
44
- try: () => setSessionStrict(sessionId, strict),
45
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
46
- })
39
+ tryAsync("cannot update session state", () => setSessionStrict(sessionId, strict))
47
40
 
48
41
  function status(sessionId: string, cwd: string): Effect.Effect<string, Error> {
49
42
  return Effect.gen(function* () {
@@ -82,6 +75,7 @@ export function runSessionCommand(args: readonly string[]): Effect.Effect<string
82
75
  )
83
76
  }
84
77
  if (command === "status") return status(sessionId, cwd)
78
+ if (command === "explain") return loadConfig(undefined, cwd).pipe(Effect.map(explainRequest))
85
79
  if (command === "on") {
86
80
  return updateEnabled(sessionId, true).pipe(Effect.as("Writing-rule enforcement enabled."))
87
81
  }
@@ -1,20 +1,19 @@
1
1
  import type { SteConfig } from "./schema.ts"
2
2
 
3
- const isPlainObject = (value: unknown): value is Record<string, unknown> =>
4
- typeof value === "object" && value !== null && !Array.isArray(value)
5
-
6
- const deepMerge = (
7
- base: Record<string, unknown>,
8
- over: Record<string, unknown>,
9
- ): Record<string, unknown> => {
10
- const merged = { ...base }
11
- for (const [key, value] of Object.entries(over)) {
12
- const existing = merged[key]
13
- merged[key] =
14
- isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value
15
- }
16
- return merged
3
+ const mergeKey = <Key extends "rules" | "ruleDataExtensions">(
4
+ key: Key,
5
+ global: SteConfig,
6
+ project: SteConfig,
7
+ ): Partial<Pick<SteConfig, Key>> => {
8
+ const base = global[key]
9
+ const over = project[key]
10
+ if (base === undefined && over === undefined) return {}
11
+ return { [key]: { ...base, ...over } } as Partial<Pick<SteConfig, Key>>
17
12
  }
18
13
 
19
- export const mergeConfigs = (global: SteConfig, project: SteConfig): SteConfig =>
20
- deepMerge(global as Record<string, unknown>, project as Record<string, unknown>) as SteConfig
14
+ export const mergeConfigs = (global: SteConfig, project: SteConfig): SteConfig => ({
15
+ ...global,
16
+ ...project,
17
+ ...mergeKey("rules", global, project),
18
+ ...mergeKey("ruleDataExtensions", global, project),
19
+ })
@@ -2,13 +2,13 @@ import adjectivalParticiples from "./data/adjectival-participles.json" with { ty
2
2
  import hedging from "./data/hedging.json" with { type: "json" }
3
3
  import marketing from "./data/marketing.json" with { type: "json" }
4
4
  import phrasalVerbs from "./data/phrasal-verbs.json" with { type: "json" }
5
- import type { RuleData } from "./rule-data.ts"
6
- import { decodeDictionaryData } from "./schema.ts"
5
+ import type { RuleDataId } from "./rule-data.ts"
6
+ import { type Dictionary, decodeDictionaryData } from "./schema.ts"
7
7
 
8
8
  // Jiti adds a non-enumerable default property that strict schema validation rejects.
9
9
  const decodeBundledDictionary = (data: object) => decodeDictionaryData({ ...data })
10
10
 
11
- export const BUNDLED_RULE_DATA: RuleData = {
11
+ export const BUNDLED_RULE_DATA: Readonly<Record<RuleDataId, Dictionary>> = {
12
12
  "phrasal-verb": decodeBundledDictionary(phrasalVerbs),
13
13
  hedging: decodeBundledDictionary(hedging),
14
14
  marketing: decodeBundledDictionary(marketing),
@@ -3,6 +3,7 @@ import { resolve } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import { Effect, Schema } from "effect"
5
5
  import { formatParseErrorIssues, type ParseError } from "../schema/parse-error.ts"
6
+ import { BUNDLED_RULE_DATA } from "./bundled-rule-data.ts"
6
7
  import type { RuleData, RuleDataExtensions, RuleDataId } from "./rule-data.ts"
7
8
  import {
8
9
  type ApprovedWordList,
@@ -13,15 +14,6 @@ import {
13
14
 
14
15
  export const BUNDLED_DICTIONARY_PATH = fileURLToPath(new URL("./data/pi-ste.json", import.meta.url))
15
16
 
16
- export const BUNDLED_RULE_DATA_PATHS: Readonly<Record<RuleDataId, string>> = {
17
- "phrasal-verb": fileURLToPath(new URL("./data/phrasal-verbs.json", import.meta.url)),
18
- hedging: fileURLToPath(new URL("./data/hedging.json", import.meta.url)),
19
- marketing: fileURLToPath(new URL("./data/marketing.json", import.meta.url)),
20
- "adjectival-participle": fileURLToPath(
21
- new URL("./data/adjectival-participles.json", import.meta.url),
22
- ),
23
- }
24
-
25
17
  type DictionaryLoadLabel = "STE dictionary" | "rule data"
26
18
 
27
19
  export class DictionaryLoadError extends Error {
@@ -106,16 +98,17 @@ const loadExtendedRuleData = (
106
98
  id: RuleDataId,
107
99
  extensionPaths: readonly string[],
108
100
  cwd: string,
109
- ): Effect.Effect<Dictionary, DictionaryLoadError> =>
110
- Effect.all([
111
- loadDictionaryData(BUNDLED_RULE_DATA_PATHS[id], "rule data"),
112
- ...extensionPaths.map((path) => loadDictionaryData(resolve(cwd, path), "rule data")),
113
- ]).pipe(
114
- Effect.map(([bundled, ...extensions]) => ({
101
+ ): Effect.Effect<Dictionary, DictionaryLoadError> => {
102
+ const bundled = BUNDLED_RULE_DATA[id]
103
+ return Effect.all(
104
+ extensionPaths.map((path) => loadDictionaryData(resolve(cwd, path), "rule data")),
105
+ ).pipe(
106
+ Effect.map((extensions) => ({
115
107
  ...bundled,
116
108
  entries: [bundled, ...extensions].flatMap((dictionary) => dictionary.entries),
117
109
  })),
118
110
  )
111
+ }
119
112
 
120
113
  export const loadRuleData = (
121
114
  extensions: RuleDataExtensions = {},
@@ -1,12 +1,5 @@
1
1
  import type { Dictionary } from "./schema.ts"
2
2
 
3
- export const ruleDataIds = [
4
- "phrasal-verb",
5
- "hedging",
6
- "marketing",
7
- "adjectival-participle",
8
- ] as const
9
-
10
- export type RuleDataId = (typeof ruleDataIds)[number]
3
+ export type RuleDataId = "phrasal-verb" | "hedging" | "marketing" | "adjectival-participle"
11
4
  export type RuleData = Readonly<Partial<Record<RuleDataId, Dictionary>>>
12
5
  export type RuleDataExtensions = Readonly<Partial<Record<RuleDataId, readonly string[]>>>