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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -1
- package/commands/ase.md +4 -4
- package/package.json +1 -2
- package/src/adapter/feedback.ts +9 -1
- package/src/adapter/rule-summary.ts +20 -22
- package/src/cli/hook.ts +26 -116
- package/src/cli/main.ts +52 -76
- package/src/cli/observation-log.ts +1 -6
- package/src/cli/session-command.ts +12 -18
- package/src/cli/session-state.ts +4 -19
- package/src/cli/try-async.ts +7 -0
- package/src/config/load.ts +2 -4
- package/src/config/merge.ts +15 -16
- package/src/dictionary/bundled-rule-data.ts +3 -3
- package/src/dictionary/load.ts +8 -15
- package/src/dictionary/rule-data.ts +1 -8
- package/src/engine/case-fold.ts +3 -2
- package/src/engine/diff.ts +54 -155
- package/src/engine/kinds.ts +61 -72
- package/src/engine/lint.ts +32 -33
- package/src/engine/markdown.ts +51 -83
- package/src/engine/paragraphs.ts +33 -121
- package/src/engine/phrase-matcher.ts +30 -14
- package/src/engine/rules/contraction.ts +2 -1
- package/src/engine/rules/dictionary.ts +13 -113
- package/src/engine/rules/hedging.ts +2 -1
- package/src/engine/rules/marketing.ts +22 -80
- package/src/engine/rules/paragraph-length.ts +16 -21
- package/src/engine/rules/phrasal-verb.ts +3 -2
- package/src/engine/rules/registry.ts +17 -0
- package/src/engine/rules/semicolon.ts +2 -1
- package/src/engine/rules/sentence-length.ts +12 -19
- package/src/engine/rules/verb-form.ts +4 -3
- package/src/engine/scan.ts +10 -0
- package/src/engine/sentences.ts +3 -37
- package/src/engine/suppression.ts +4 -13
- package/src/engine/types.ts +2 -3
- package/src/extension/index.ts +22 -5
- package/src/fs-error.ts +5 -0
- package/src/tagger/wink.ts +3 -6
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
},
|
package/src/adapter/feedback.ts
CHANGED
|
@@ -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
|
|
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] ??
|
|
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
|
-
|
|
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
|
|
117
|
-
?
|
|
118
|
-
: `
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
})
|
|
434
|
+
tryAsync("cannot update session state", () =>
|
|
435
|
+
setReplyFeedback(sessionId, replyIdentity, feedback),
|
|
436
|
+
)
|
|
507
437
|
|
|
508
438
|
const takePendingFeedback = (sessionId: string) =>
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
:
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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)
|
|
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*
|
|
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
|
|
203
|
-
|
|
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
|
|
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)
|