agent-simple-english 0.1.0 → 0.2.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 +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +174 -78
  4. package/commands/ste.md +1 -1
  5. package/hooks/hooks.json +1 -1
  6. package/package.json +13 -2
  7. package/src/adapter/feedback.ts +2 -1
  8. package/src/adapter/rule-summary.ts +46 -5
  9. package/src/cli/hook.ts +148 -52
  10. package/src/cli/main.ts +81 -9
  11. package/src/cli/observation-log.ts +257 -0
  12. package/src/cli/session-command.ts +14 -6
  13. package/src/cli/session-state.ts +3 -8
  14. package/src/cli/state-directory.ts +9 -0
  15. package/src/config/schema.ts +23 -0
  16. package/src/dictionary/README.md +61 -7
  17. package/src/dictionary/bundled-rule-data.ts +13 -0
  18. package/src/dictionary/configured.ts +16 -0
  19. package/src/dictionary/data/adjectival-participles.json +15 -0
  20. package/src/dictionary/data/hedging.json +22 -0
  21. package/src/dictionary/data/marketing.json +40 -0
  22. package/src/dictionary/data/phrasal-verbs.json +55 -0
  23. package/src/dictionary/form.ts +2 -0
  24. package/src/dictionary/load.ts +84 -6
  25. package/src/dictionary/rule-data.ts +12 -0
  26. package/src/dictionary/schema.ts +18 -1
  27. package/src/engine/case-fold.ts +17 -0
  28. package/src/engine/comments.ts +273 -23
  29. package/src/engine/diff-match.ts +189 -0
  30. package/src/engine/kinds.ts +19 -2
  31. package/src/engine/lint.ts +209 -238
  32. package/src/engine/markdown-syntax.ts +171 -0
  33. package/src/engine/markdown.ts +591 -245
  34. package/src/engine/paragraphs.ts +47 -6
  35. package/src/engine/phrase-matcher.ts +62 -0
  36. package/src/engine/rules/dictionary.ts +38 -3
  37. package/src/engine/rules/hedging.ts +19 -16
  38. package/src/engine/rules/marketing.ts +122 -56
  39. package/src/engine/rules/paragraph-length.ts +6 -1
  40. package/src/engine/rules/phrasal-verb.ts +36 -41
  41. package/src/engine/rules/registry.ts +1 -0
  42. package/src/engine/rules/verb-form.ts +26 -3
  43. package/src/engine/sentences.ts +359 -20
  44. package/src/engine/suppression.ts +174 -0
  45. package/src/engine/tokens.ts +2 -2
  46. package/src/engine/types.ts +19 -5
  47. package/src/extension/index.ts +63 -35
package/src/cli/hook.ts CHANGED
@@ -6,12 +6,18 @@ import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-me
6
6
  import { formatViolations } from "../adapter/feedback.ts"
7
7
  import { ruleSummary } from "../adapter/rule-summary.ts"
8
8
  import { loadConfig } from "../config/load.ts"
9
- import { loadDictionary } from "../dictionary/load.ts"
9
+ import { loadConfiguredDictionary } from "../dictionary/configured.ts"
10
+ import { loadRuleData } from "../dictionary/load.ts"
10
11
  import { classifyPath } from "../engine/kinds.ts"
11
12
  import { lint } from "../engine/lint.ts"
12
13
  import type { Tagger } from "../engine/tagger.ts"
13
- import type { LintOptions, Violation } from "../engine/types.ts"
14
+ import type { LintKind, LintOptions, ReportViolation } from "../engine/types.ts"
14
15
  import { TaggerService } from "../tagger/wink.ts"
16
+ import {
17
+ type ObservationDraft,
18
+ type ObservationEvent,
19
+ appendObservation,
20
+ } from "./observation-log.ts"
15
21
  import {
16
22
  consumePendingFeedback,
17
23
  getSessionControl,
@@ -209,12 +215,12 @@ function addContext(
209
215
  function nonBlockingError(message: string): HookError {
210
216
  return {
211
217
  continue: true,
212
- systemMessage: `STE hook error: ${message}. The event is allowed.`,
218
+ systemMessage: `Writing-rule hook error: ${message}. The event is allowed.`,
213
219
  }
214
220
  }
215
221
 
216
222
  function nonBlockingWarning(message: string): HookDecision {
217
- return allow([`STE hook warning: ${message}. The event is allowed.`])
223
+ return allow([`Writing-rule hook warning: ${message}. The event is allowed.`])
218
224
  }
219
225
 
220
226
  export function hookInternalFailure(cause: Cause.Cause<unknown>): HookOutput {
@@ -511,19 +517,24 @@ const readSessionControl = (sessionId: string) =>
511
517
  catch: (cause) => new Error(`cannot read session state: ${cause}`),
512
518
  })
513
519
 
514
- const loadLintOptions = (cwd: string, tagger: Tagger) => {
515
- const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
516
- return Effect.all({
517
- config: loadConfig(undefined, cwd),
518
- dictionary: loadDictionary(
519
- dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath),
520
- ),
521
- }).pipe(Effect.map(({ config, dictionary }): LintOptions => ({ ...config, dictionary, tagger })))
522
- }
520
+ const loadLintOptions = (cwd: string, tagger: Tagger) =>
521
+ Effect.gen(function* () {
522
+ const config = yield* loadConfig(undefined, cwd)
523
+ const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
524
+ const { dictionary, ruleData } = yield* Effect.all({
525
+ dictionary: loadConfiguredDictionary(
526
+ config,
527
+ cwd,
528
+ dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath),
529
+ ),
530
+ ruleData: loadRuleData(config.ruleDataExtensions, cwd),
531
+ })
532
+ return { ...config, dictionary, ruleData, tagger } satisfies LintOptions
533
+ })
523
534
 
524
- function splitViolations(violations: readonly Violation[]): {
525
- readonly hard: Violation[]
526
- readonly soft: Violation[]
535
+ function splitViolations(violations: readonly ReportViolation[]): {
536
+ readonly hard: ReportViolation[]
537
+ readonly soft: ReportViolation[]
527
538
  } {
528
539
  return {
529
540
  hard: violations.filter((violation) => violation.severity === "hard"),
@@ -531,13 +542,48 @@ function splitViolations(violations: readonly Violation[]): {
531
542
  }
532
543
  }
533
544
 
545
+ type EvaluationObservation = Omit<ObservationDraft, "sessionId" | "cwd">
546
+
547
+ interface HookEvaluation {
548
+ readonly output: HookOutput
549
+ readonly observation?: EvaluationObservation
550
+ }
551
+
552
+ function evaluated(
553
+ output: HookOutput,
554
+ event: ObservationEvent,
555
+ kind: LintKind,
556
+ text: string,
557
+ violations: readonly ReportViolation[],
558
+ path?: string,
559
+ ): HookEvaluation {
560
+ const preToolDenied =
561
+ "hookSpecificOutput" in output &&
562
+ output.hookSpecificOutput !== undefined &&
563
+ "permissionDecision" in output.hookSpecificOutput &&
564
+ output.hookSpecificOutput.permissionDecision === "deny"
565
+ const decision =
566
+ ("decision" in output && output.decision === "block") || preToolDenied ? "deny" : "allow"
567
+ return {
568
+ output,
569
+ observation: {
570
+ event,
571
+ kind,
572
+ text,
573
+ violations,
574
+ decision,
575
+ ...(path === undefined ? {} : { path }),
576
+ },
577
+ }
578
+ }
579
+
534
580
  function textDecision(
535
581
  operation: "write" | "edit",
536
582
  path: string,
537
583
  text: string,
538
584
  options: LintOptions,
539
585
  previousText?: string,
540
- ): HookDecision {
586
+ ): HookEvaluation {
541
587
  const classification = classifyPath(path)
542
588
  const report = lint(classification.kind, text, {
543
589
  ...options,
@@ -545,68 +591,99 @@ function textDecision(
545
591
  previousText,
546
592
  })
547
593
  const { hard, soft } = splitViolations(report.violations)
548
- if (hard.length > 0) {
549
- return deny(formatViolations(path, `STE blocked ${operation} for`, hard))
550
- }
551
- return allow(soft.length === 0 ? [] : [formatViolations(path, "STE warnings for", soft)])
594
+ const output =
595
+ hard.length > 0
596
+ ? deny(formatViolations(path, `Writing rules blocked ${operation} for`, hard))
597
+ : allow(soft.length === 0 ? [] : [formatViolations(path, "Writing-rule warnings for", soft)])
598
+ return evaluated(output, operation, classification.kind, text, report.violations, path)
552
599
  }
553
600
 
554
- function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookOutput, Error> {
601
+ function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookEvaluation, Error> {
555
602
  return Effect.gen(function* () {
556
603
  const reply = yield* event.lastAssistantMessage === undefined
557
604
  ? readAssistantReply(event.transcriptPath)
558
605
  : readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
559
606
  if (yield* replyWasProcessed(event.sessionId, reply.identity)) {
560
- return {} as Record<string, never>
607
+ return { output: {} as Record<string, never> }
561
608
  }
562
609
  const options = yield* loadLintOptions(event.cwd, tagger)
563
- const hard = lint("prose-file", reply.text, options).violations.filter(
564
- (violation) => violation.severity === "hard",
565
- )
610
+ const violations = lint("prose-file", reply.text, options).violations
611
+ const hard = violations.filter((violation) => violation.severity === "hard")
566
612
  const feedback =
567
613
  hard.length === 0
568
614
  ? undefined
569
- : formatViolations("assistant reply", "STE reply feedback for", hard)
615
+ : formatViolations("assistant reply", "Writing-rule reply feedback for", hard)
570
616
  const currentControl = yield* updateReplyFeedback(event.sessionId, reply.identity, feedback)
571
- if (
617
+ const output: HookOutput =
572
618
  currentControl === undefined ||
573
619
  !currentControl.strict ||
574
620
  event.stopHookActive ||
575
621
  hard.length === 0
576
- ) {
577
- return {} as Record<string, never>
578
- }
579
- return {
580
- decision: "block",
581
- reason: formatViolations("assistant reply", "STE blocked reply for", hard),
582
- }
622
+ ? ({} as Record<string, never>)
623
+ : {
624
+ decision: "block",
625
+ reason: formatViolations("assistant reply", "Writing rules blocked reply for", hard),
626
+ }
627
+ return evaluated(output, "reply", "prose-file", reply.text, violations)
628
+ })
629
+ }
630
+
631
+ function restoreCommitSnippets(
632
+ message: string,
633
+ prepared: string,
634
+ violations: readonly ReportViolation[],
635
+ ): ReportViolation[] {
636
+ return violations.map((violation) => {
637
+ const offset = prepared.indexOf(violation.snippet)
638
+ return offset === -1
639
+ ? violation
640
+ : { ...violation, snippet: message.slice(offset, offset + violation.snippet.length) }
583
641
  })
584
642
  }
585
643
 
586
- function evaluateEvent(event: PreToolUseEvent, tagger: Tagger): Effect.Effect<HookDecision, Error> {
644
+ function evaluateEvent(
645
+ event: PreToolUseEvent,
646
+ tagger: Tagger,
647
+ ): Effect.Effect<HookEvaluation, Error> {
587
648
  if (event.toolName === "Bash") {
588
649
  const invocations = findCommitInvocations(event.command)
589
- if (invocations.length === 0) return Effect.succeed(allow())
650
+ if (invocations.length === 0) return Effect.succeed({ output: allow() })
590
651
  if (invocations.some((invocation) => invocation.requiresExplicitMessage)) {
591
- return Effect.succeed(
592
- deny(
593
- "STE could not check the git commit message. Use git commit with a static -m or --message argument.",
652
+ return Effect.succeed({
653
+ output: deny(
654
+ "Writing rules could not check the git commit message. Use git commit with a static -m or --message argument.",
594
655
  ),
595
- )
656
+ })
596
657
  }
597
658
  return Effect.gen(function* () {
598
659
  const options = yield* loadLintOptions(event.cwd, tagger)
599
- const violations = invocations.flatMap((invocation) =>
600
- invocation.requiresExplicitMessage
601
- ? []
602
- : lint("commit-message", blankCommitMetadata(invocation.message), options).violations,
660
+ const messages = invocations.flatMap((invocation) =>
661
+ invocation.requiresExplicitMessage ? [] : [invocation.message],
603
662
  )
663
+ const violations = messages.flatMap((message) => {
664
+ const prepared = blankCommitMetadata(message)
665
+ return restoreCommitSnippets(
666
+ message,
667
+ prepared,
668
+ lint("commit-message", prepared, options).violations,
669
+ )
670
+ })
604
671
  const { hard, soft } = splitViolations(violations)
605
- if (hard.length > 0) {
606
- return deny(formatViolations("commit message", "STE blocked commit for", hard))
607
- }
608
- return allow(
609
- soft.length === 0 ? [] : [formatViolations("commit message", "STE warnings for", soft)],
672
+ const output =
673
+ hard.length > 0
674
+ ? deny(formatViolations("commit message", "Writing rules blocked commit for", hard))
675
+ : allow(
676
+ soft.length === 0
677
+ ? []
678
+ : [formatViolations("commit message", "Writing-rule warnings for", soft)],
679
+ )
680
+ return evaluated(
681
+ output,
682
+ "commit-message",
683
+ "commit-message",
684
+ messages.join("\n\n"),
685
+ violations,
686
+ event.cwd,
610
687
  )
611
688
  })
612
689
  }
@@ -623,6 +700,23 @@ function evaluateEvent(event: PreToolUseEvent, tagger: Tagger): Effect.Effect<Ho
623
700
  })
624
701
  }
625
702
 
703
+ function recordEvaluation(event: HookEvent, evaluation: HookEvaluation): Effect.Effect<HookOutput> {
704
+ const observation = evaluation.observation
705
+ if (observation === undefined) return Effect.succeed(evaluation.output)
706
+ return Effect.tryPromise({
707
+ try: () =>
708
+ appendObservation({
709
+ ...observation,
710
+ sessionId: event.sessionId,
711
+ cwd: event.cwd,
712
+ }),
713
+ catch: () => undefined,
714
+ }).pipe(
715
+ Effect.catchAll(() => Effect.void),
716
+ Effect.as(evaluation.output),
717
+ )
718
+ }
719
+
626
720
  export function runHookMode(raw: string): Effect.Effect<HookOutput, never, TaggerService> {
627
721
  return Effect.try({
628
722
  try: () => decodeEvent(raw),
@@ -653,12 +747,14 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
653
747
  if (event.hookEventName === "Stop") {
654
748
  return Effect.gen(function* () {
655
749
  const tagger = yield* TaggerService
656
- return yield* evaluateReply(event, tagger)
750
+ const evaluation = yield* evaluateReply(event, tagger)
751
+ return yield* recordEvaluation(event, evaluation)
657
752
  })
658
753
  }
659
754
  return Effect.gen(function* () {
660
755
  const tagger = yield* TaggerService
661
- return yield* evaluateEvent(event, tagger)
756
+ const evaluation = yield* evaluateEvent(event, tagger)
757
+ return yield* recordEvaluation(event, evaluation)
662
758
  })
663
759
  }),
664
760
  Effect.catchAll((error) =>
package/src/cli/main.ts CHANGED
@@ -1,22 +1,38 @@
1
1
  #!/usr/bin/env bun
2
2
  import { readFile } from "node:fs/promises"
3
3
  import { Effect, Either } from "effect"
4
+ import packageManifest from "../../package.json" with { type: "json" }
4
5
  import { loadConfig } from "../config/load.ts"
5
- import { loadDictionary } from "../dictionary/load.ts"
6
+ import { loadConfiguredDictionary } from "../dictionary/configured.ts"
7
+ import { loadRuleData } from "../dictionary/load.ts"
6
8
  import { classifyPath } from "../engine/kinds.ts"
7
9
  import { lint } from "../engine/lint.ts"
8
10
  import type { LintKind, LintReport } from "../engine/types.ts"
9
11
  import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
10
12
  import { hookInternalFailure, runHookMode } from "./hook.ts"
13
+ import { observationStats, reviewObservations } from "./observation-log.ts"
11
14
  import { runSessionCommand } from "./session-command.ts"
12
15
 
13
16
  const KINDS: readonly LintKind[] = ["prose-file", "slash-source", "hash-source", "commit-message"]
14
17
 
18
+ const USAGE = `Usage: simple-english [options] [paths...]
19
+ simple-english observe review
20
+ simple-english observe stats
21
+
22
+ Options:
23
+ --json Write a JSON report.
24
+ --config <path> Use one config file.
25
+ --kind <kind> Use one content kind.
26
+ --help Print this help.
27
+ --version Print the package version.`
28
+
15
29
  interface CliArgs {
16
30
  readonly json: boolean
17
31
  readonly configPath: string | undefined
18
32
  readonly kind: string | undefined
19
33
  readonly kindMissingValue: boolean
34
+ readonly help: boolean
35
+ readonly version: boolean
20
36
  readonly paths: readonly string[]
21
37
  }
22
38
 
@@ -26,16 +42,20 @@ const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
26
42
  let configPath: string | undefined
27
43
  let kind: string | undefined
28
44
  let kindMissingValue = false
45
+ let help = false
46
+ let version = false
29
47
  const paths: string[] = []
30
48
  for (let i = 0; i < args.length; i++) {
31
49
  const arg = args[i] as string
32
50
  if (arg === "--json") {
33
51
  json = true
34
52
  } else if (arg === "--config") {
35
- configPath = args[++i]
36
- if (configPath === undefined) {
53
+ const value = args[i + 1]
54
+ if (value === undefined || value.startsWith("--")) {
37
55
  yield* Effect.fail(new Error("--config requires a file path"))
38
56
  }
57
+ configPath = value
58
+ i++
39
59
  } else if (arg === "--kind") {
40
60
  const value = args[i + 1]
41
61
  if (value === undefined || value.startsWith("--")) {
@@ -46,13 +66,24 @@ const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
46
66
  }
47
67
  } else if (arg.startsWith("--kind=")) {
48
68
  kind = arg.slice("--kind=".length)
69
+ } else if (arg === "--help") {
70
+ help = true
71
+ } else if (arg === "--version") {
72
+ version = true
73
+ } else if (arg.startsWith("--")) {
74
+ yield* Effect.fail(new Error(`unknown flag "${arg}"`))
49
75
  } else {
50
76
  paths.push(arg)
51
77
  }
52
78
  }
53
- return { json, configPath, kind, kindMissingValue, paths }
79
+ return { json, configPath, kind, kindMissingValue, help, version, paths }
54
80
  })
55
81
 
82
+ const rejectUnknownFlags = (args: readonly string[]): Effect.Effect<void, Error> => {
83
+ const flag = args.find((arg) => arg.startsWith("--"))
84
+ return flag === undefined ? Effect.void : Effect.fail(new Error(`unknown flag "${flag}"`))
85
+ }
86
+
56
87
  const isLintKind = (value: string): value is LintKind =>
57
88
  (KINDS as readonly string[]).includes(value)
58
89
 
@@ -61,6 +92,7 @@ interface FileViolation {
61
92
  readonly ruleId: string
62
93
  readonly severity: string
63
94
  readonly message: string
95
+ readonly snippet: string
64
96
  readonly suggestions?: readonly string[]
65
97
  readonly line: number
66
98
  readonly column: number
@@ -130,9 +162,38 @@ const sessionProgram = Effect.gen(function* () {
130
162
  return 0
131
163
  })
132
164
 
165
+ const observeProgram = Effect.gen(function* () {
166
+ const command = args[1]
167
+ if (args.length !== 2 || (command !== "review" && command !== "stats")) {
168
+ return yield* Effect.fail(new Error("Usage: simple-english observe <review|stats>"))
169
+ }
170
+ if (command === "review") {
171
+ yield* Effect.tryPromise({
172
+ try: () => reviewObservations(),
173
+ catch: (cause) => new Error(`cannot review observations: ${cause}`),
174
+ })
175
+ } else {
176
+ console.log(
177
+ yield* Effect.tryPromise({
178
+ try: () => observationStats(),
179
+ catch: (cause) => new Error(`cannot read observation stats: ${cause}`),
180
+ }),
181
+ )
182
+ }
183
+ return 0
184
+ })
185
+
133
186
  const lintProgram = Effect.gen(function* () {
134
187
  const tagger = yield* TaggerService
135
- const { json, configPath, kind, kindMissingValue, paths } = yield* parseArgs(args)
188
+ const { json, configPath, kind, kindMissingValue, help, version, paths } = yield* parseArgs(args)
189
+ if (help) {
190
+ console.log(USAGE)
191
+ return 0
192
+ }
193
+ if (version) {
194
+ console.log(packageManifest.version)
195
+ return 0
196
+ }
136
197
  if (kindMissingValue) {
137
198
  return yield* Effect.fail(
138
199
  new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`),
@@ -145,12 +206,20 @@ const lintProgram = Effect.gen(function* () {
145
206
  }
146
207
  const config = yield* loadConfig(configPath)
147
208
  const loadedDictionary = yield* Effect.either(
148
- loadDictionary(process.env.SIMPLE_ENGLISH_DICTIONARY),
209
+ loadConfiguredDictionary(config, process.cwd(), process.env.SIMPLE_ENGLISH_DICTIONARY),
149
210
  )
211
+ const loadedRuleData = yield* Effect.either(loadRuleData(config.ruleDataExtensions))
212
+ if (Either.isLeft(loadedDictionary) && config.approvedWordsPath !== undefined) {
213
+ return yield* Effect.fail(loadedDictionary.left)
214
+ }
150
215
  const dictionary = Either.getOrUndefined(loadedDictionary)
216
+ const ruleData = Either.getOrUndefined(loadedRuleData)
151
217
  if (Either.isLeft(loadedDictionary)) {
152
218
  yield* Effect.sync(() => console.error(loadedDictionary.left.message))
153
219
  }
220
+ if (Either.isLeft(loadedRuleData)) {
221
+ yield* Effect.sync(() => console.error(loadedRuleData.left.message))
222
+ }
154
223
  const inputs =
155
224
  paths.length === 0
156
225
  ? [{ path: "<stdin>", text: yield* readStdin }]
@@ -164,6 +233,7 @@ const lintProgram = Effect.gen(function* () {
164
233
  report: lint(kind ?? classification.kind, text, {
165
234
  ...config,
166
235
  dictionary,
236
+ ruleData,
167
237
  tagger,
168
238
  sourceDialect: classification.sourceDialect,
169
239
  }),
@@ -180,10 +250,12 @@ const lintProgram = Effect.gen(function* () {
180
250
 
181
251
  const program: Effect.Effect<number, Error> =
182
252
  args[0] === "hook"
183
- ? hookProgram
253
+ ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(hookProgram))
184
254
  : args[0] === "session"
185
- ? sessionProgram
186
- : lintProgram.pipe(Effect.provide(WinkTaggerLive))
255
+ ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(sessionProgram))
256
+ : args[0] === "observe"
257
+ ? observeProgram
258
+ : lintProgram.pipe(Effect.provide(WinkTaggerLive))
187
259
 
188
260
  const handled = program.pipe(
189
261
  Effect.catchAll((error) =>