agent-simple-english 0.5.0 → 0.6.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 (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +2 -1
  4. package/commands/ase.md +4 -4
  5. package/package.json +1 -2
  6. package/src/adapter/feedback.ts +9 -1
  7. package/src/adapter/rule-summary.ts +20 -22
  8. package/src/cli/hook.ts +26 -116
  9. package/src/cli/main.ts +52 -76
  10. package/src/cli/observation-log.ts +1 -6
  11. package/src/cli/session-command.ts +12 -18
  12. package/src/cli/session-state.ts +4 -19
  13. package/src/cli/try-async.ts +7 -0
  14. package/src/config/load.ts +2 -4
  15. package/src/config/merge.ts +15 -16
  16. package/src/dictionary/bundled-rule-data.ts +3 -3
  17. package/src/dictionary/load.ts +8 -15
  18. package/src/dictionary/rule-data.ts +1 -8
  19. package/src/engine/case-fold.ts +3 -2
  20. package/src/engine/diff.ts +54 -155
  21. package/src/engine/kinds.ts +61 -72
  22. package/src/engine/lint.ts +32 -33
  23. package/src/engine/markdown.ts +51 -83
  24. package/src/engine/paragraphs.ts +33 -121
  25. package/src/engine/phrase-matcher.ts +30 -14
  26. package/src/engine/rules/contraction.ts +2 -1
  27. package/src/engine/rules/dictionary.ts +13 -113
  28. package/src/engine/rules/hedging.ts +2 -1
  29. package/src/engine/rules/marketing.ts +22 -80
  30. package/src/engine/rules/paragraph-length.ts +16 -21
  31. package/src/engine/rules/phrasal-verb.ts +3 -2
  32. package/src/engine/rules/registry.ts +17 -0
  33. package/src/engine/rules/semicolon.ts +2 -1
  34. package/src/engine/rules/sentence-length.ts +12 -19
  35. package/src/engine/rules/verb-form.ts +4 -3
  36. package/src/engine/scan.ts +10 -0
  37. package/src/engine/sentences.ts +3 -37
  38. package/src/engine/suppression.ts +4 -13
  39. package/src/engine/types.ts +2 -3
  40. package/src/extension/index.ts +22 -5
  41. package/src/fs-error.ts +5 -0
  42. package/src/tagger/wink.ts +3 -6
@@ -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.0",
14
+ "version": "0.6.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.0",
4
+ "version": "0.6.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
@@ -80,6 +80,7 @@ A change in one session does not change a parallel session.
80
80
  | `/ase status` | Show the mode, rule counts, and dictionary state. |
81
81
  | `/ase strict` | Enable the strict reply gate and all other checks. |
82
82
  | `/ase strict off` | Disable the strict reply gate and use reply feedback. |
83
+ | `/ase explain` | Write the last reply again in Simplified Technical English. |
83
84
 
84
85
  Strict mode blocks a `Stop` event when the reply has a hard violation.
85
86
  Claude Code then uses the violation details to write the reply again.
@@ -360,7 +361,7 @@ The project file is `.simple-english.json` at the repository root.
360
361
  The global file is `$XDG_CONFIG_HOME/simple-english/config.json`.
361
362
  If `XDG_CONFIG_HOME` is unset or not absolute, the global path is `~/.config/simple-english/config.json`.
362
363
 
363
- Global and project files are deep-merged.
364
+ Global and project files merge key by key, including the nested `rules` and `ruleDataExtensions` objects.
364
365
  Global values load first, and project values have precedence.
365
366
  The pi Adapter reads the project file only when pi trusts the project.
366
367
  The `--config` flag uses only its named file.
package/commands/ase.md CHANGED
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  description: Control writing-rule enforcement for this Claude Code session
3
- argument-hint: on|off|status|strict|strict off
3
+ argument-hint: on|off|status|strict|strict off|explain
4
4
  allowed-tools: Bash
5
5
  disable-model-invocation: true
6
6
  ---
7
7
 
8
- Session state:
8
+ Command output:
9
9
 
10
10
  !`cd "${CLAUDE_PLUGIN_ROOT}" && bun "src/cli/main.ts" session "${CLAUDE_SESSION_ID}" "${CLAUDE_PROJECT_DIR}" "$ARGUMENTS"`
11
11
 
12
- Show the session state exactly.
13
- Do not add text.
12
+ If the output starts with "## Rewrite request", do what it asks.
13
+ Otherwise, show the output exactly and add no text.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-simple-english",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Technical and house-style English lint engine, CLI, and host adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,6 @@
54
54
  "micromark-util-html-tag-name": "^2.0.1",
55
55
  "micromark-util-normalize-identifier": "^2.0.1",
56
56
  "micromark-util-types": "^2.0.2",
57
- "unicode-case-folding": "^1.1.1",
58
57
  "wink-eng-lite-web-model": "^1.8.1",
59
58
  "wink-nlp": "^2.4.0"
60
59
  },
@@ -2,7 +2,6 @@ import type { RuleId } from "../engine/rules/registry.ts"
2
2
  import type { Violation } from "../engine/types.ts"
3
3
 
4
4
  function suggestedFix(violation: Violation): string {
5
- if (violation.suggestion !== undefined) return `Use "${violation.suggestion}".`
6
5
  if (violation.suggestions !== undefined && violation.suggestions.length > 0) {
7
6
  return `Use one of these approved alternatives: ${violation.suggestions.map((item) => `"${item}"`).join(", ")}.`
8
7
  }
@@ -39,3 +38,12 @@ export function formatViolations(
39
38
  ): string {
40
39
  return `${heading} ${path}:\n${violationDetails(violations)}`
41
40
  }
41
+
42
+ export function splitViolations<V extends Violation>(
43
+ violations: readonly V[],
44
+ ): { readonly hard: V[]; readonly soft: V[] } {
45
+ return {
46
+ hard: violations.filter((violation) => violation.severity === "hard"),
47
+ soft: violations.filter((violation) => violation.severity === "soft"),
48
+ }
49
+ }
@@ -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
@@ -3,7 +3,7 @@ import { open, readFile } from "node:fs/promises"
3
3
  import { resolve } from "node:path"
4
4
  import { Cause, Effect } from "effect"
5
5
  import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
6
- import { formatViolations } from "../adapter/feedback.ts"
6
+ import { formatViolations, splitViolations } from "../adapter/feedback.ts"
7
7
  import { ruleSummary } from "../adapter/rule-summary.ts"
8
8
  import { loadConfig } from "../config/load.ts"
9
9
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
@@ -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,
@@ -24,6 +23,7 @@ import {
24
23
  hasProcessedReply,
25
24
  setReplyFeedback,
26
25
  } from "./session-state.ts"
26
+ import { tryAsync } from "./try-async.ts"
27
27
 
28
28
  interface CommonEvent {
29
29
  readonly cwd: string
@@ -244,49 +244,13 @@ function proposedEdit(
244
244
  }
245
245
 
246
246
  const readEditFile = (path: string) =>
247
- Effect.tryPromise({
248
- try: () => readFile(path, "utf8"),
249
- catch: (cause) => new Error(`cannot read edit file ${path}: ${cause}`),
250
- })
247
+ tryAsync(`cannot read edit file ${path}`, () => readFile(path, "utf8"))
251
248
 
252
249
  interface AssistantReply {
253
250
  readonly identity: string
254
251
  readonly text: string
255
252
  }
256
253
 
257
- function assistantReply(line: string, path: string, offset: number): AssistantReply | undefined {
258
- let value: unknown
259
- try {
260
- value = JSON.parse(line) as unknown
261
- } catch {
262
- return undefined
263
- }
264
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
265
- const entry = value as Record<string, unknown>
266
- if (entry.type !== "assistant") return undefined
267
- const message = record(entry.message, "assistant transcript message")
268
- const content = message.content
269
- if (!Array.isArray(content)) {
270
- throw new Error(`assistant transcript message in ${path} must contain content blocks`)
271
- }
272
- const uuid = entry.uuid
273
- const identity =
274
- typeof uuid === "string" && uuid.length > 0
275
- ? `uuid:${uuid}`
276
- : `offset:${offset}:${createHash("sha256").update(line).digest("hex")}`
277
- const text = content
278
- .filter(
279
- (block): block is { type: "text"; text: string } =>
280
- typeof block === "object" &&
281
- block !== null &&
282
- (block as Record<string, unknown>).type === "text" &&
283
- typeof (block as Record<string, unknown>).text === "string",
284
- )
285
- .map((block) => block.text)
286
- .join("\n")
287
- return { identity, text }
288
- }
289
-
290
254
  const TRANSCRIPT_CHUNK_SIZE = 64 * 1024
291
255
  const TRANSCRIPT_ENTRY_HEADER_SIZE = 64 * 1024
292
256
 
@@ -384,23 +348,6 @@ async function readTranscriptRange(
384
348
  return buffer
385
349
  }
386
350
 
387
- async function assistantReplyInRange(
388
- file: Awaited<ReturnType<typeof open>>,
389
- path: string,
390
- start: number,
391
- end: number,
392
- ): Promise<AssistantReply | undefined> {
393
- const length = end - start
394
- const headerLength = Math.min(length, TRANSCRIPT_ENTRY_HEADER_SIZE)
395
- const header = await readTranscriptRange(file, path, start, headerLength)
396
- if (transcriptEntryKind(header.toString("utf8")) !== "assistant") return undefined
397
- const line =
398
- headerLength === length
399
- ? header.toString("utf8")
400
- : (await readTranscriptRange(file, path, start, length)).toString("utf8")
401
- return assistantReply(line, path, start)
402
- }
403
-
404
351
  async function turnIdentityInRange(
405
352
  file: Awaited<ReturnType<typeof open>>,
406
353
  path: string,
@@ -464,12 +411,6 @@ async function latestTranscriptEntry<T>(
464
411
  }
465
412
  }
466
413
 
467
- async function assistantReplyFromTranscript(path: string): Promise<AssistantReply> {
468
- const reply = await latestTranscriptEntry(path, assistantReplyInRange)
469
- if (reply !== undefined) return reply
470
- throw new Error(`cannot find an assistant reply in ${path}`)
471
- }
472
-
473
414
  async function assistantReplyFromEvent(text: string, path: string): Promise<AssistantReply> {
474
415
  const turnIdentity = await latestTranscriptEntry(path, turnIdentityInRange)
475
416
  if (turnIdentity === undefined) throw new Error(`cannot find a reply turn in ${path}`)
@@ -477,45 +418,28 @@ async function assistantReplyFromEvent(text: string, path: string): Promise<Assi
477
418
  return { identity: `${turnIdentity}:reply:${textHash}`, text }
478
419
  }
479
420
 
480
- const readAssistantReply = (path: string) =>
481
- Effect.tryPromise({
482
- try: () => assistantReplyFromTranscript(path),
483
- catch: (cause) => new Error(`cannot read assistant reply from ${path}: ${cause}`),
484
- })
485
-
486
421
  const readEventAssistantReply = (text: string, path: string) =>
487
- Effect.tryPromise({
488
- try: () => assistantReplyFromEvent(text, path),
489
- catch: (cause) => new Error(`cannot read assistant reply turn from ${path}: ${cause}`),
490
- })
422
+ tryAsync(`cannot read assistant reply turn from ${path}`, () =>
423
+ assistantReplyFromEvent(text, path),
424
+ )
491
425
 
492
426
  const replyWasProcessed = (sessionId: string, replyIdentity: string) =>
493
- Effect.tryPromise({
494
- try: () => hasProcessedReply(sessionId, replyIdentity),
495
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
496
- })
427
+ tryAsync("cannot read session state", () => hasProcessedReply(sessionId, replyIdentity))
497
428
 
498
429
  const updateReplyFeedback = (
499
430
  sessionId: string,
500
431
  replyIdentity: string,
501
432
  feedback: string | undefined,
502
433
  ) =>
503
- Effect.tryPromise({
504
- try: () => setReplyFeedback(sessionId, replyIdentity, feedback),
505
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
506
- })
434
+ tryAsync("cannot update session state", () =>
435
+ setReplyFeedback(sessionId, replyIdentity, feedback),
436
+ )
507
437
 
508
438
  const takePendingFeedback = (sessionId: string) =>
509
- Effect.tryPromise({
510
- try: () => consumePendingFeedback(sessionId),
511
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
512
- })
439
+ tryAsync("cannot read session state", () => consumePendingFeedback(sessionId))
513
440
 
514
441
  const readSessionControl = (sessionId: string) =>
515
- Effect.tryPromise({
516
- try: () => getSessionControl(sessionId),
517
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
518
- })
442
+ tryAsync("cannot read session state", () => getSessionControl(sessionId))
519
443
 
520
444
  const loadLintOptions = (cwd: string, tagger: Tagger) =>
521
445
  Effect.gen(function* () {
@@ -532,16 +456,6 @@ const loadLintOptions = (cwd: string, tagger: Tagger) =>
532
456
  return { ...config, dictionary, ruleData, tagger } satisfies LintOptions
533
457
  })
534
458
 
535
- function splitViolations(violations: readonly ReportViolation[]): {
536
- readonly hard: ReportViolation[]
537
- readonly soft: ReportViolation[]
538
- } {
539
- return {
540
- hard: violations.filter((violation) => violation.severity === "hard"),
541
- soft: violations.filter((violation) => violation.severity === "soft"),
542
- }
543
- }
544
-
545
459
  type EvaluationObservation = Omit<ObservationDraft, "sessionId" | "cwd">
546
460
 
547
461
  interface HookEvaluation {
@@ -603,15 +517,18 @@ function textDecision(
603
517
 
604
518
  function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookEvaluation, Error> {
605
519
  return Effect.gen(function* () {
606
- const reply = yield* event.lastAssistantMessage === undefined
607
- ? readAssistantReply(event.transcriptPath)
608
- : readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
520
+ if (event.lastAssistantMessage === undefined) {
521
+ return yield* Effect.fail(
522
+ new Error(`last_assistant_message was not provided for ${event.transcriptPath}`),
523
+ )
524
+ }
525
+ const reply = yield* readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
609
526
  if (yield* replyWasProcessed(event.sessionId, reply.identity)) {
610
527
  return { output: {} as Record<string, never> }
611
528
  }
612
529
  const options = yield* loadLintOptions(event.cwd, tagger)
613
530
  const violations = lint("prose-file", reply.text, options).violations
614
- const hard = violations.filter((violation) => violation.severity === "hard")
531
+ const { hard } = splitViolations(violations)
615
532
  const feedback =
616
533
  hard.length === 0
617
534
  ? undefined
@@ -720,7 +637,7 @@ function recordEvaluation(event: HookEvent, evaluation: HookEvaluation): Effect.
720
637
  )
721
638
  }
722
639
 
723
- export function runHookMode(raw: string): Effect.Effect<HookOutput, never, TaggerService> {
640
+ export function runHookMode(raw: string, tagger: Tagger): Effect.Effect<HookOutput, never> {
724
641
  return Effect.try({
725
642
  try: () => decodeEvent(raw),
726
643
  catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
@@ -729,7 +646,7 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
729
646
  onFailure: (error) => Effect.succeed(nonBlockingError(error.message)),
730
647
  onSuccess: (event) =>
731
648
  readSessionControl(event.sessionId).pipe(
732
- Effect.flatMap((control): Effect.Effect<HookOutput, Error, TaggerService> => {
649
+ Effect.flatMap((control): Effect.Effect<HookOutput, Error> => {
733
650
  if (!control.enabled) {
734
651
  return Effect.succeed(
735
652
  event.hookEventName === "PreToolUse" ? allow() : ({} as Record<string, never>),
@@ -747,18 +664,11 @@ export function runHookMode(raw: string): Effect.Effect<HookOutput, never, Tagge
747
664
  ),
748
665
  )
749
666
  }
750
- if (event.hookEventName === "Stop") {
751
- return Effect.gen(function* () {
752
- const tagger = yield* TaggerService
753
- const evaluation = yield* evaluateReply(event, tagger)
754
- return yield* recordEvaluation(event, evaluation)
755
- })
756
- }
757
- return Effect.gen(function* () {
758
- const tagger = yield* TaggerService
759
- const evaluation = yield* evaluateEvent(event, tagger)
760
- return yield* recordEvaluation(event, evaluation)
761
- })
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)))
762
672
  }),
763
673
  Effect.catch((error) =>
764
674
  Effect.succeed(
package/src/cli/main.ts CHANGED
@@ -1,17 +1,20 @@
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" }
6
+ import { splitViolations } from "../adapter/feedback.ts"
5
7
  import { loadConfig } from "../config/load.ts"
6
8
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
7
9
  import { loadRuleData } from "../dictionary/load.ts"
8
10
  import { classifyPath, type PathClassification } from "../engine/kinds.ts"
9
11
  import { lint } from "../engine/lint.ts"
10
12
  import type { LintKind, LintReport } from "../engine/types.ts"
11
- import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
13
+ import { makeLazyWinkTagger } from "../tagger/wink.ts"
12
14
  import { hookInternalFailure, runHookMode } from "./hook.ts"
13
15
  import { observationStats, reviewObservations } from "./observation-log.ts"
14
16
  import { runSessionCommand } from "./session-command.ts"
17
+ import { tryAsync } from "./try-async.ts"
15
18
 
16
19
  const KINDS: readonly LintKind[] = [
17
20
  "prose-file",
@@ -32,58 +35,41 @@ Options:
32
35
  --help Print this help.
33
36
  --version Print the package version.`
34
37
 
35
- interface CliArgs {
36
- readonly json: boolean
37
- readonly configPath: string | undefined
38
- readonly kind: string | undefined
39
- readonly kindMissingValue: boolean
40
- readonly help: boolean
41
- readonly version: boolean
42
- 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)
43
62
  }
44
63
 
45
- const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
46
- Effect.gen(function* () {
47
- let json = false
48
- let configPath: string | undefined
49
- let kind: string | undefined
50
- let kindMissingValue = false
51
- let help = false
52
- let version = false
53
- const paths: string[] = []
54
- for (let i = 0; i < args.length; i++) {
55
- const arg = args[i] as string
56
- if (arg === "--json") {
57
- json = true
58
- } else if (arg === "--config") {
59
- const value = args[i + 1]
60
- if (value === undefined || value.startsWith("--")) {
61
- yield* Effect.fail(new Error("--config requires a file path"))
62
- }
63
- configPath = value
64
- i++
65
- } else if (arg === "--kind") {
66
- const value = args[i + 1]
67
- if (value === undefined || value.startsWith("--")) {
68
- kindMissingValue = true
69
- } else {
70
- kind = value
71
- i++
72
- }
73
- } else if (arg.startsWith("--kind=")) {
74
- kind = arg.slice("--kind=".length)
75
- } else if (arg === "--help") {
76
- help = true
77
- } else if (arg === "--version") {
78
- version = true
79
- } else if (arg.startsWith("--")) {
80
- yield* Effect.fail(new Error(`unknown flag "${arg}"`))
81
- } else {
82
- paths.push(arg)
83
- }
84
- }
85
- return { json, configPath, kind, kindMissingValue, help, version, paths }
86
- })
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
+ }
87
73
 
88
74
  const rejectUnknownFlags = (args: readonly string[]): Effect.Effect<void, Error> => {
89
75
  const flag = args.find((arg) => arg.startsWith("--"))
@@ -102,7 +88,6 @@ interface FileViolation {
102
88
  readonly suggestions?: readonly string[]
103
89
  readonly line: number
104
90
  readonly column: number
105
- readonly suggestion?: string
106
91
  }
107
92
 
108
93
  interface CliReport {
@@ -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 }[],
@@ -138,7 +122,7 @@ const toCliReport = (
138
122
  violations,
139
123
  summary: {
140
124
  total: violations.length,
141
- hard: violations.filter((violation) => violation.severity === "hard").length,
125
+ hard: splitViolations(violations).hard.length,
142
126
  },
143
127
  skipped,
144
128
  }
@@ -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) =>
@@ -3,6 +3,7 @@ import { mkdir, open, readdir, readFile } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
  import { createInterface } from "node:readline"
5
5
  import type { LintKind, ReportViolation } from "../engine/types.ts"
6
+ import { isFileError } from "../fs-error.ts"
6
7
  import { applicationStateDirectory } from "./state-directory.ts"
7
8
 
8
9
  export type ObservationEvent = "write" | "edit" | "commit-message" | "reply"
@@ -56,12 +57,6 @@ const observationsDirectory = (): string => join(applicationStateDirectory(), "o
56
57
 
57
58
  const verdictsPath = (): string => join(applicationStateDirectory(), "verdicts.jsonl")
58
59
 
59
- function isFileError(cause: unknown, code: string): boolean {
60
- return (
61
- typeof cause === "object" && cause !== null && (cause as NodeJS.ErrnoException).code === code
62
- )
63
- }
64
-
65
60
  async function appendJsonLine(path: string, value: unknown): Promise<void> {
66
61
  await mkdir(applicationStateDirectory(), { recursive: true, mode: 0o700 })
67
62
  const file = await open(path, "a", 0o600)