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,472 @@
1
+ interface WordToken {
2
+ readonly type: "word"
3
+ readonly value: string
4
+ readonly dynamic: boolean
5
+ }
6
+
7
+ interface OperatorToken {
8
+ readonly type: "operator"
9
+ }
10
+
11
+ interface Heredoc {
12
+ readonly delimiter: string
13
+ readonly stripTabs: boolean
14
+ }
15
+
16
+ type ShellToken = WordToken | OperatorToken
17
+
18
+ export type CommitInvocation =
19
+ | { readonly message: string; readonly requiresExplicitMessage: false }
20
+ | { readonly requiresExplicitMessage: true }
21
+
22
+ interface AnsiEscape {
23
+ readonly value: string
24
+ readonly width: number
25
+ readonly extractable: boolean
26
+ }
27
+
28
+ function numericEscape(command: string, index: number, pattern: RegExp, limit: number): AnsiEscape {
29
+ const digits = command.slice(index + 1, index + 1 + limit).match(pattern)?.[0] ?? ""
30
+ if (digits.length === 0) {
31
+ return { value: `\\${command[index] ?? ""}`, width: 1, extractable: true }
32
+ }
33
+
34
+ const codePoint = Number.parseInt(digits, 16)
35
+ const validCodePoint = codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
36
+ return {
37
+ value: validCodePoint ? String.fromCodePoint(codePoint) : "",
38
+ width: digits.length + 1,
39
+ extractable: validCodePoint,
40
+ }
41
+ }
42
+
43
+ function ansiEscape(command: string, index: number): AnsiEscape {
44
+ const character = command[index] ?? ""
45
+ const escapes: Readonly<Record<string, string>> = {
46
+ a: "\x07",
47
+ b: "\b",
48
+ e: "\x1b",
49
+ E: "\x1b",
50
+ f: "\f",
51
+ n: "\n",
52
+ r: "\r",
53
+ t: "\t",
54
+ v: "\v",
55
+ "\\": "\\",
56
+ "'": "'",
57
+ '"': '"',
58
+ "?": "?",
59
+ }
60
+ const escaped = escapes[character]
61
+ if (escaped !== undefined) return { value: escaped, width: 1, extractable: true }
62
+
63
+ if (/[0-7]/u.test(character)) {
64
+ const digits = command.slice(index, index + 3).match(/^[0-7]{1,3}/u)?.[0] ?? character
65
+ return {
66
+ value: String.fromCharCode(Number.parseInt(digits, 8) & 0xff),
67
+ width: digits.length,
68
+ extractable: true,
69
+ }
70
+ }
71
+ if (character === "x") return numericEscape(command, index, /^[0-9A-Fa-f]{1,2}/u, 2)
72
+ if (character === "u") return numericEscape(command, index, /^[0-9A-Fa-f]{1,4}/u, 4)
73
+ if (character === "U") return numericEscape(command, index, /^[0-9A-Fa-f]{1,8}/u, 8)
74
+ if (character === "c" && command[index + 1] !== undefined) {
75
+ const controlled = command[index + 1] ?? ""
76
+ const codePoint = controlled === "?" ? 0x7f : controlled.toUpperCase().charCodeAt(0) & 0x1f
77
+ return { value: String.fromCharCode(codePoint), width: 2, extractable: true }
78
+ }
79
+ return { value: `\\${character}`, width: 1, extractable: true }
80
+ }
81
+
82
+ function tokenize(command: string): ShellToken[] {
83
+ const tokens: ShellToken[] = []
84
+ let value = ""
85
+ let dynamic = false
86
+ let started = false
87
+ let discardWord = false
88
+ let heredocDeclaration: Pick<Heredoc, "stripTabs"> | undefined
89
+ const heredocs: Heredoc[] = []
90
+ let index = 0
91
+
92
+ const flush = () => {
93
+ if (!started) return
94
+ if (!discardWord) tokens.push({ type: "word", value, dynamic })
95
+ else if (heredocDeclaration !== undefined) {
96
+ heredocs.push({ delimiter: value, stripTabs: heredocDeclaration.stripTabs })
97
+ }
98
+ value = ""
99
+ dynamic = false
100
+ started = false
101
+ discardWord = false
102
+ heredocDeclaration = undefined
103
+ }
104
+
105
+ const redirection = (redirectionOperator: string) => {
106
+ if (started && /^\d+$/u.test(value)) {
107
+ value = ""
108
+ dynamic = false
109
+ started = false
110
+ } else {
111
+ flush()
112
+ }
113
+ discardWord = true
114
+ heredocDeclaration =
115
+ redirectionOperator === "<<" || redirectionOperator === "<<-"
116
+ ? { stripTabs: redirectionOperator === "<<-" }
117
+ : undefined
118
+ index += redirectionOperator.length
119
+ }
120
+
121
+ const operator = (width = 1) => {
122
+ flush()
123
+ discardWord = false
124
+ heredocDeclaration = undefined
125
+ tokens.push({ type: "operator" })
126
+ index += width
127
+ }
128
+
129
+ while (index < command.length) {
130
+ if (heredocs.length > 0 && (index === 0 || command[index - 1] === "\n")) {
131
+ const lineEnd = command.indexOf("\n", index)
132
+ const end = lineEnd === -1 ? command.length : lineEnd
133
+ const line = command.slice(index, end).replace(/\r$/u, "")
134
+ const activeHeredoc = heredocs[0]
135
+ const candidate = activeHeredoc?.stripTabs ? line.replace(/^\t+/u, "") : line
136
+ if (candidate === activeHeredoc?.delimiter) heredocs.shift()
137
+ index = lineEnd === -1 ? command.length : lineEnd + 1
138
+ continue
139
+ }
140
+
141
+ const character = command[index] ?? ""
142
+
143
+ if (/[^\S\r\n]/u.test(character) || character === "\r") {
144
+ flush()
145
+ index++
146
+ continue
147
+ }
148
+ if (character === "\n") {
149
+ operator()
150
+ continue
151
+ }
152
+ if (character === ";" || character === "(" || character === ")") {
153
+ operator()
154
+ continue
155
+ }
156
+ if (
157
+ (character === "{" || character === "}") &&
158
+ !started &&
159
+ (command[index + 1] === undefined || /[\s;&|()]/u.test(command[index + 1] ?? ""))
160
+ ) {
161
+ operator()
162
+ continue
163
+ }
164
+ if (
165
+ character === "<" ||
166
+ character === ">" ||
167
+ (character === "&" && command[index + 1] === ">")
168
+ ) {
169
+ const rest = command.slice(index)
170
+ const redirectionOperator =
171
+ rest.match(/^(?:<<<|<<-|&>>|<<|<>|<&|>>|>\||>&|&>|<|>)/u)?.[0] ?? character
172
+ redirection(redirectionOperator)
173
+ continue
174
+ }
175
+ if (character === "&" || character === "|") {
176
+ operator(command[index + 1] === character ? 2 : 1)
177
+ continue
178
+ }
179
+ if (character === "#" && !started) {
180
+ while (index < command.length && command[index] !== "\n") index++
181
+ continue
182
+ }
183
+ if (character === "\\") {
184
+ started = true
185
+ const next = command[index + 1]
186
+ if (next === "\n") {
187
+ index += 2
188
+ } else if (next === undefined) {
189
+ value += "\\"
190
+ index++
191
+ } else {
192
+ value += next
193
+ index += 2
194
+ }
195
+ continue
196
+ }
197
+ if (character === "'") {
198
+ started = true
199
+ index++
200
+ while (index < command.length && command[index] !== "'") {
201
+ value += command[index]
202
+ index++
203
+ }
204
+ if (command[index] === "'") index++
205
+ continue
206
+ }
207
+ if (character === '"') {
208
+ started = true
209
+ index++
210
+ while (index < command.length && command[index] !== '"') {
211
+ const quoted = command[index] ?? ""
212
+ if (quoted === "\\") {
213
+ const next = command[index + 1]
214
+ if (next === "\n") {
215
+ index += 2
216
+ continue
217
+ }
218
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
219
+ value += next
220
+ index += 2
221
+ continue
222
+ }
223
+ value += "\\"
224
+ index++
225
+ continue
226
+ }
227
+ if (quoted === "$" || quoted === "`") dynamic = true
228
+ value += quoted
229
+ index++
230
+ }
231
+ if (command[index] === '"') index++
232
+ continue
233
+ }
234
+ if (character === "$" && command[index + 1] === "'") {
235
+ started = true
236
+ index += 2
237
+ while (index < command.length && command[index] !== "'") {
238
+ const quoted = command[index] ?? ""
239
+ if (quoted === "\\" && command[index + 1] !== undefined) {
240
+ const ansiSequence = ansiEscape(command, index + 1)
241
+ value += ansiSequence.value
242
+ dynamic ||= !ansiSequence.extractable
243
+ index += ansiSequence.width + 1
244
+ } else {
245
+ value += quoted
246
+ index++
247
+ }
248
+ }
249
+ if (command[index] === "'") index++
250
+ continue
251
+ }
252
+ if (
253
+ character === "$" ||
254
+ character === "`" ||
255
+ character === "*" ||
256
+ character === "?" ||
257
+ character === "[" ||
258
+ character === "{" ||
259
+ character === "}" ||
260
+ (character === "~" && !started)
261
+ ) {
262
+ dynamic = true
263
+ }
264
+ started = true
265
+ value += character
266
+ index++
267
+ }
268
+
269
+ flush()
270
+ return tokens
271
+ }
272
+
273
+ function commandSegments(tokens: readonly ShellToken[]): WordToken[][] {
274
+ const segments: WordToken[][] = []
275
+ let segment: WordToken[] = []
276
+ for (const token of tokens) {
277
+ if (token.type === "operator") {
278
+ if (segment.length > 0) segments.push(segment)
279
+ segment = []
280
+ } else {
281
+ segment.push(token)
282
+ }
283
+ }
284
+ if (segment.length > 0) segments.push(segment)
285
+ return segments
286
+ }
287
+
288
+ function executableName(value: string): string {
289
+ return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
290
+ }
291
+
292
+ function gitCommandIndex(segment: readonly WordToken[]): number | undefined {
293
+ let index = 0
294
+ if (segment[index]?.value === "command") {
295
+ index++
296
+ while (segment[index]?.value.startsWith("-")) index++
297
+ }
298
+ if (segment[index]?.value === "env") {
299
+ index++
300
+ while (segment[index]) {
301
+ const argument = segment[index]
302
+ if (
303
+ argument === undefined ||
304
+ (!argument.value.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/u.test(argument.value))
305
+ ) {
306
+ break
307
+ }
308
+ index++
309
+ }
310
+ }
311
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(segment[index]?.value ?? "")) index++
312
+ return executableName(segment[index]?.value ?? "") === "git" ? index : undefined
313
+ }
314
+
315
+ function commitSubcommandIndex(
316
+ segment: readonly WordToken[],
317
+ gitIndex: number,
318
+ ): number | undefined {
319
+ for (let index = gitIndex + 1; index < segment.length; index++) {
320
+ const argument = segment[index]?.value ?? ""
321
+ if (argument === "commit") return index
322
+ if (!argument.startsWith("-")) return undefined
323
+ if (
324
+ ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"].includes(argument)
325
+ ) {
326
+ index++
327
+ }
328
+ }
329
+ return undefined
330
+ }
331
+
332
+ const SHORT_OPTIONS_WITHOUT_VALUE = new Set(["a", "p", "s", "n", "e", "i", "o", "v", "q", "z"])
333
+ const SHORT_OPTIONS_WITH_VALUE = new Set(["C", "c", "F", "m", "t", "u"])
334
+ const LONG_OPTIONS_WITH_VALUE = new Set([
335
+ "--author",
336
+ "--cleanup",
337
+ "--date",
338
+ "--file",
339
+ "--fixup",
340
+ "--message",
341
+ "--pathspec-from-file",
342
+ "--reedit-message",
343
+ "--reuse-message",
344
+ "--squash",
345
+ "--template",
346
+ "--trailer",
347
+ ])
348
+
349
+ type ShortOption =
350
+ | { readonly type: "message"; readonly attached: string }
351
+ | { readonly type: "next-message" }
352
+ | { readonly type: "skip-next" }
353
+ | { readonly type: "other" }
354
+
355
+ function classifyShortOption(argument: string): ShortOption {
356
+ if (argument === "-u") return { type: "other" }
357
+
358
+ for (let index = 1; index < argument.length; index++) {
359
+ const option = argument[index] ?? ""
360
+ if (SHORT_OPTIONS_WITHOUT_VALUE.has(option)) continue
361
+ if (option === "S") return { type: "other" }
362
+ if (!SHORT_OPTIONS_WITH_VALUE.has(option)) return { type: "other" }
363
+
364
+ const attached = argument.slice(index + 1)
365
+ if (option === "m") {
366
+ return attached.length > 0 ? { type: "message", attached } : { type: "next-message" }
367
+ }
368
+ return attached.length > 0 ? { type: "other" } : { type: "skip-next" }
369
+ }
370
+ return { type: "other" }
371
+ }
372
+
373
+ function invocation(segment: readonly WordToken[]): CommitInvocation | undefined {
374
+ const gitIndex = gitCommandIndex(segment)
375
+ if (gitIndex === undefined) return undefined
376
+ const commitIndex = commitSubcommandIndex(segment, gitIndex)
377
+ if (commitIndex === undefined) return undefined
378
+
379
+ const messageParts: string[] = []
380
+ let messageIsDynamic = false
381
+
382
+ for (let index = commitIndex + 1; index < segment.length; index++) {
383
+ const argument = segment[index]
384
+ if (argument === undefined) continue
385
+ if (argument.value === "--") break
386
+ if (argument.value === "-m" || argument.value === "--message") {
387
+ const message = segment[++index]
388
+ if (message === undefined) return { requiresExplicitMessage: true }
389
+ messageParts.push(message.value)
390
+ messageIsDynamic ||= message.dynamic
391
+ continue
392
+ }
393
+ if (argument.value.startsWith("--message=")) {
394
+ messageParts.push(argument.value.slice("--message=".length))
395
+ messageIsDynamic ||= argument.dynamic
396
+ continue
397
+ }
398
+ if (LONG_OPTIONS_WITH_VALUE.has(argument.value)) {
399
+ index++
400
+ continue
401
+ }
402
+ if (!argument.value.startsWith("-") || argument.value.startsWith("--")) continue
403
+
404
+ const shortOption = classifyShortOption(argument.value)
405
+ if (shortOption.type === "skip-next") {
406
+ index++
407
+ continue
408
+ }
409
+ if (shortOption.type === "other") continue
410
+ if (shortOption.type === "message") {
411
+ messageParts.push(shortOption.attached)
412
+ messageIsDynamic ||= argument.dynamic
413
+ continue
414
+ }
415
+
416
+ const message = segment[++index]
417
+ if (message === undefined) return { requiresExplicitMessage: true }
418
+ messageParts.push(message.value)
419
+ messageIsDynamic ||= message.dynamic
420
+ }
421
+
422
+ if (messageParts.length > 0) {
423
+ return messageIsDynamic
424
+ ? { requiresExplicitMessage: true }
425
+ : { message: messageParts.join("\n\n"), requiresExplicitMessage: false }
426
+ }
427
+ return { requiresExplicitMessage: true }
428
+ }
429
+
430
+ export function findCommitInvocations(command: string): readonly CommitInvocation[] {
431
+ return commandSegments(tokenize(command)).flatMap((segment) => {
432
+ const found = invocation(segment)
433
+ return found === undefined ? [] : [found]
434
+ })
435
+ }
436
+
437
+ function blankLine(line: string): string {
438
+ return " ".repeat(line.length)
439
+ }
440
+
441
+ export function blankCommitMetadata(message: string): string {
442
+ const lines = message.split("\n")
443
+ const prefix = lines[0]?.match(/^[A-Za-z][A-Za-z0-9-]*(?:\([^\r\n)]*\))?!?:[\t ]*/u)?.[0]
444
+ if (prefix !== undefined && lines[0] !== undefined) {
445
+ lines[0] = `:${" ".repeat(Math.max(prefix.length - 1, 0))}${lines[0].slice(prefix.length)}`
446
+ }
447
+
448
+ let trailerEnd = lines.length - 1
449
+ while (trailerEnd > 0 && !/\S/u.test(lines[trailerEnd] ?? "")) trailerEnd--
450
+ let trailerStart = trailerEnd + 1
451
+ let hasTrailer = false
452
+ for (let index = trailerEnd; index > 0; index--) {
453
+ const line = lines[index] ?? ""
454
+ if (/^(?:[A-Za-z0-9][A-Za-z0-9-]*|BREAKING CHANGE)[\t ]*(?::| #)[\t ]+\S/u.test(line)) {
455
+ hasTrailer = true
456
+ trailerStart = index
457
+ continue
458
+ }
459
+ if (/^[\t ]+\S/u.test(line)) {
460
+ trailerStart = index
461
+ continue
462
+ }
463
+ break
464
+ }
465
+ if (hasTrailer) {
466
+ for (let index = trailerStart; index <= trailerEnd; index++) {
467
+ lines[index] = blankLine(lines[index] ?? "")
468
+ }
469
+ }
470
+
471
+ return lines.join("\n")
472
+ }
@@ -0,0 +1,40 @@
1
+ import type { RuleId } from "../engine/rules/registry.ts"
2
+ import type { Violation } from "../engine/types.ts"
3
+
4
+ function suggestedFix(violation: Violation): string {
5
+ if (violation.suggestion !== undefined) return `Use "${violation.suggestion}".`
6
+ if (violation.suggestions !== undefined && violation.suggestions.length > 0) {
7
+ return `Use one of these approved alternatives: ${violation.suggestions.map((item) => `"${item}"`).join(", ")}.`
8
+ }
9
+ const fixes: Readonly<Record<RuleId, string>> = {
10
+ contraction: "Write the contracted words in full.",
11
+ "dictionary-not-approved-word": "Replace the unapproved word with an approved alternative.",
12
+ hedging: "Delete the hedging phrase.",
13
+ marketing: "Replace the phrase with factual language.",
14
+ "paragraph-length": "Split the paragraph into shorter paragraphs.",
15
+ "phrasal-verb": "Replace the phrasal verb with one approved verb.",
16
+ semicolon: "Replace the semicolon with a full stop and write two sentences.",
17
+ "sentence-length": "Split the sentence into shorter sentences.",
18
+ "verb-progressive": "Use a permitted simple verb form.",
19
+ "verb-passive": "Name the actor and use active voice.",
20
+ "verb-perfect": "Use a permitted simple verb form.",
21
+ }
22
+ return fixes[violation.ruleId]
23
+ }
24
+
25
+ export function violationDetails(violations: readonly Violation[]): string {
26
+ return violations
27
+ .map(
28
+ (violation) =>
29
+ `- line ${violation.line}, column ${violation.column} [${violation.ruleId}]: ${violation.message} Suggested fix: ${suggestedFix(violation)}`,
30
+ )
31
+ .join("\n")
32
+ }
33
+
34
+ export function formatViolations(
35
+ path: string,
36
+ heading: string,
37
+ violations: readonly Violation[],
38
+ ): string {
39
+ return `${heading} ${path}:\n${violationDetails(violations)}`
40
+ }
@@ -0,0 +1,79 @@
1
+ import type { SteConfig } from "../config/schema.ts"
2
+ import { DEFAULT_MAX_SENTENCE_WORDS } from "../engine/lint.ts"
3
+ import type { RuleId } from "../engine/rules/registry.ts"
4
+ import type { RuleSetting } from "../engine/types.ts"
5
+
6
+ const DEFAULT_RULE_SETTINGS: Readonly<Record<RuleId, RuleSetting>> = {
7
+ contraction: "hard",
8
+ "dictionary-not-approved-word": "hard",
9
+ hedging: "soft",
10
+ marketing: "soft",
11
+ "paragraph-length": "hard",
12
+ "phrasal-verb": "hard",
13
+ semicolon: "hard",
14
+ "sentence-length": "hard",
15
+ "verb-progressive": "hard",
16
+ "verb-passive": "soft",
17
+ "verb-perfect": "hard",
18
+ }
19
+
20
+ export const RULE_SUMMARIES: Readonly<Record<RuleId, string>> = {
21
+ contraction: "Do not use contractions. Write the words in full.",
22
+ "dictionary-not-approved-word": "Use approved words from the STE dictionary.",
23
+ hedging: "Remove hedging phrases.",
24
+ marketing: "Use factual language instead of marketing language.",
25
+ "paragraph-length": "Use no more than six sentences in one paragraph.",
26
+ "phrasal-verb": "Use an approved single-word verb instead of a phrasal verb.",
27
+ semicolon: "Do not use semicolons. Write two sentences.",
28
+ "sentence-length": "Keep each sentence within the configured word limit.",
29
+ "verb-progressive": "Do not use progressive verb forms.",
30
+ "verb-passive": "Prefer active voice.",
31
+ "verb-perfect": "Do not use perfect verb forms.",
32
+ }
33
+
34
+ export function resolvedRuleSetting(config: SteConfig, ruleId: RuleId): RuleSetting {
35
+ return config.rules?.[ruleId] ?? DEFAULT_RULE_SETTINGS[ruleId]
36
+ }
37
+
38
+ export function formatFailedStatusSummary(
39
+ mode: "disabled" | "enabled" | "strict",
40
+ configError: string,
41
+ ): string {
42
+ return [
43
+ `Mode: ${mode}`,
44
+ `Config: failed (${configError})`,
45
+ "Rules: unavailable",
46
+ "Dictionary: unavailable",
47
+ ].join("\n")
48
+ }
49
+
50
+ export function formatStatusSummary(
51
+ config: SteConfig,
52
+ mode: "disabled" | "enabled" | "strict",
53
+ dictionary: string,
54
+ ): string {
55
+ const counts: Record<RuleSetting, number> = { hard: 0, soft: 0, off: 0 }
56
+ for (const ruleId of Object.keys(RULE_SUMMARIES) as RuleId[]) {
57
+ counts[resolvedRuleSetting(config, ruleId)] += 1
58
+ }
59
+ return [
60
+ `Mode: ${mode}`,
61
+ `Rules: ${counts.hard} hard, ${counts.soft} soft, ${counts.off} off`,
62
+ `Dictionary: ${dictionary}`,
63
+ ].join("\n")
64
+ }
65
+
66
+ export function ruleSummary(config: SteConfig): string {
67
+ const maxSentenceWords = config.maxSentenceWords ?? DEFAULT_MAX_SENTENCE_WORDS
68
+ const rules = (Object.keys(RULE_SUMMARIES) as RuleId[])
69
+ .filter((ruleId) => resolvedRuleSetting(config, ruleId) !== "off")
70
+ .map((ruleId) => {
71
+ const summary =
72
+ ruleId === "sentence-length"
73
+ ? `Keep each sentence to ${maxSentenceWords} words or fewer.`
74
+ : RULE_SUMMARIES[ruleId]
75
+ return `- [${resolvedRuleSetting(config, ruleId)}] ${summary}`
76
+ })
77
+ .join("\n")
78
+ return `## Simplified Technical English\n\nFollow these STE rules in prose that you write or edit:\n${rules}\n\nWrites, edits, and git commit messages reject hard violations. Correct the reported text and retry. Soft violations produce warnings.`
79
+ }