pi-simple-english 0.8.1 → 0.10.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/README.md CHANGED
@@ -78,6 +78,16 @@ The `write-simple-english` skill carries the rules, so the agent
78
78
  writes clean prose the first time. The `lint-simple-english` skill
79
79
  tells the agent to lint the prose it writes and fix every finding.
80
80
 
81
+ ## Toggle
82
+
83
+ Turn the linter off for the session with `/se off`. That hides the
84
+ `se_lint` tool and stops the edit enforcement. Turn it back on with
85
+ `/se on`, or run `/se` to flip the current state. The status line
86
+ shows `● simple-english` while it is on.
87
+
88
+ The toggle lasts for the session. To stop the linter for every
89
+ session, uninstall the package: `pi uninstall npm:pi-simple-english`.
90
+
81
91
  ## Versions
82
92
 
83
93
  The package version tracks the gem version. The changelog lives in
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * se_lint - lint Markdown prose and code comments with simple_english.
3
3
  *
4
- * Two layers. The se_lint tool lints on demand. Enforcement runs the
4
+ * Three layers. The se_lint tool lints on demand. Enforcement runs the
5
5
  * same lint after every edit or write and appends the findings to the
6
6
  * tool result, so the agent sees them in the turn that made them. It
7
- * never blocks the edit. The contract mirrors the claude-code hook in
8
- * integrations/claude-code/hooks/lint.sh: a clean file stays silent,
9
- * a failed lint says so instead of reading as clean, and a missing
10
- * binary names the install step.
7
+ * never blocks the edit. The /se command gates both layers per session:
8
+ * off hides the tool from the model and skips enforcement. The contract
9
+ * mirrors the claude-code hook in integrations/claude-code/hooks/lint.sh:
10
+ * a clean file stays silent, a failed lint says so instead of reading as
11
+ * clean, and a missing binary names the install step.
11
12
  */
12
13
 
13
14
  import { execFile } from "node:child_process";
@@ -15,6 +16,7 @@ import { Type } from "typebox";
15
16
  import type {
16
17
  AgentToolResult,
17
18
  ExtensionAPI,
19
+ ExtensionContext,
18
20
  ToolResultEvent,
19
21
  ToolResultEventResult,
20
22
  } from "@earendil-works/pi-coding-agent";
@@ -24,6 +26,9 @@ const OUTPUT_LIMIT = 4000;
24
26
  // ever proves slow in practice, cut to 30s and pre-warm via `se serve`.
25
27
  const TIMEOUT_MS = 120_000;
26
28
 
29
+ const STATE_ENTRY_TYPE = "se-lint-state";
30
+ const STATUS_KEY = "se";
31
+
27
32
  interface SeResult {
28
33
  stdout: string;
29
34
  code: number;
@@ -36,6 +41,8 @@ interface SeDetails {
36
41
  full?: string;
37
42
  }
38
43
 
44
+ type SeLintState = { enabled: boolean };
45
+
39
46
  function seLint(path: string, signal?: AbortSignal): Promise<SeResult> {
40
47
  return new Promise((resolve, reject) => {
41
48
  execFile(
@@ -81,60 +88,131 @@ async function lintFeedback(path: string): Promise<string | undefined> {
81
88
  return `se found ${findings} lint finding(s) in ${path}. Fix them, then lint again:\n${text}`;
82
89
  }
83
90
 
91
+ // The last state entry on the branch wins. Earlier toggles are history,
92
+ // not the current state.
93
+ function savedEnabled(ctx: ExtensionContext): boolean | undefined {
94
+ let saved: boolean | undefined;
95
+ for (const entry of ctx.sessionManager.getBranch()) {
96
+ if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) {
97
+ continue;
98
+ }
99
+ const data = entry.data as Partial<SeLintState> | undefined;
100
+ if (typeof data?.enabled === "boolean") {
101
+ saved = data.enabled;
102
+ }
103
+ }
104
+ return saved;
105
+ }
106
+
84
107
  export default function (pi: ExtensionAPI) {
85
- pi.registerTool({
86
- name: "se_lint",
87
- label: "Lint prose",
88
- description:
89
- "Lint Markdown prose and code comments against plain-English rules (short sentences, active voice, no jargon, no filler). " +
90
- "Call this after writing or editing any documentation file (.md) or prose-heavy text to verify it reads clean. " +
91
- "Also call it on demand when asked to lint or simplify text. " +
92
- "Input: a file or directory path. Returns findings with line numbers and suggested rewrites, or 'clean' when nothing is flagged. " +
93
- "After fixing findings, re-run until it reports clean.",
94
- parameters: Type.Object({
95
- path: Type.String({ description: "File or directory to lint, relative to the CWD or absolute" }),
96
- }),
97
-
98
- async execute(_toolCallId, params, signal): Promise<AgentToolResult<SeDetails>> {
99
- let stdout: string;
100
- let code: number;
101
- try {
102
- ({ stdout, code } = await seLint(params.path, signal));
103
- } catch (err) {
104
- const e = err as NodeJS.ErrnoException;
105
- if (e.code === "ENOENT") {
106
- throw new Error(
107
- "`se` not found. Install it first: `gem install simple_english`, then retry.",
108
- );
108
+ let enabled = true;
109
+
110
+ const updateStatus = (ctx: ExtensionContext): void => {
111
+ if (!enabled) {
112
+ ctx.ui.setStatus(STATUS_KEY, undefined);
113
+ return;
114
+ }
115
+ const dot = ctx.ui.theme.fg("success", "●");
116
+ ctx.ui.setStatus(STATUS_KEY, `${dot} ${ctx.ui.theme.fg("accent", "simple-english")}`);
117
+ };
118
+
119
+ const registerLintTool = (exposure: "direct" | "hidden"): void => {
120
+ pi.registerTool({
121
+ name: "se_lint",
122
+ label: "Lint prose",
123
+ description:
124
+ "Lint Markdown prose and code comments against plain-English rules (short sentences, active voice, no jargon, no filler). " +
125
+ "Call this after writing or editing any documentation file (.md) or prose-heavy text to verify it reads clean. " +
126
+ "Also call it on demand when asked to lint or simplify text. " +
127
+ "Input: a file or directory path. Returns findings with line numbers and suggested rewrites, or 'clean' when nothing is flagged. " +
128
+ "After fixing findings, re-run until it reports clean.",
129
+ parameters: Type.Object({
130
+ path: Type.String({ description: "File or directory path to lint, relative to the CWD or absolute" }),
131
+ }),
132
+ exposure,
133
+
134
+ async execute(_toolCallId, params, signal): Promise<AgentToolResult<SeDetails>> {
135
+ let stdout: string;
136
+ let code: number;
137
+ try {
138
+ ({ stdout, code } = await seLint(params.path, signal));
139
+ } catch (err) {
140
+ const e = err as NodeJS.ErrnoException;
141
+ if (e.code === "ENOENT") {
142
+ throw new Error(
143
+ "`se` not found. Install it first: `gem install simple_english`, then retry.",
144
+ );
145
+ }
146
+ throw new Error(`se lint failed: ${e.message}`);
109
147
  }
110
- throw new Error(`se lint failed: ${e.message}`);
111
- }
112
148
 
113
- if (code !== 0 && code !== 1) {
114
- throw new Error(`se lint exited with code ${code}: ${stdout.trim()}`);
115
- }
149
+ if (code !== 0 && code !== 1) {
150
+ throw new Error(`se lint exited with code ${code}: ${stdout.trim()}`);
151
+ }
116
152
 
117
- const full = stdout.trim();
118
- if (!full) {
153
+ const full = stdout.trim();
154
+ if (!full) {
155
+ return {
156
+ content: [{ type: "text" as const, text: `clean: no findings in ${params.path}` }],
157
+ details: { path: params.path, exitCode: code, findings: 0, full: undefined },
158
+ };
159
+ }
160
+
161
+ const findings = full.split("\n").length;
162
+ const text =
163
+ full.length > OUTPUT_LIMIT
164
+ ? `${full.slice(0, OUTPUT_LIMIT)}\n... truncated (${full.length} chars, ${findings} findings). Full output: run \`se lint ${params.path}\` with bash.`
165
+ : `${findings} finding(s) in ${params.path}:\n${full}`;
119
166
  return {
120
- content: [{ type: "text" as const, text: `clean: no findings in ${params.path}` }],
121
- details: { path: params.path, exitCode: code, findings: 0, full: undefined },
167
+ content: [{ type: "text" as const, text }],
168
+ details: { path: params.path, exitCode: code, findings, full },
122
169
  };
170
+ },
171
+ });
172
+ };
173
+
174
+ const setEnabled = (next: boolean, ctx: ExtensionContext): void => {
175
+ enabled = next;
176
+ pi.appendEntry(STATE_ENTRY_TYPE, { enabled } satisfies SeLintState);
177
+ registerLintTool(next ? "direct" : "hidden");
178
+ updateStatus(ctx);
179
+ ctx.ui.notify(`Prose lint ${next ? "on" : "off"}`, "info");
180
+ };
181
+
182
+ const restoreState = (ctx: ExtensionContext): void => {
183
+ enabled = savedEnabled(ctx) ?? true;
184
+ registerLintTool(enabled ? "direct" : "hidden");
185
+ updateStatus(ctx);
186
+ };
187
+
188
+ registerLintTool("direct");
189
+
190
+ pi.registerCommand("se", {
191
+ description: "Toggle prose linting (se_lint tool and edit enforcement) for this session",
192
+ handler: async (args, ctx) => {
193
+ const argument = args.trim().toLowerCase();
194
+
195
+ if (argument === "") {
196
+ setEnabled(!enabled, ctx);
197
+ return;
198
+ }
199
+
200
+ if (argument === "on") {
201
+ setEnabled(true, ctx);
202
+ return;
123
203
  }
124
204
 
125
- const findings = full.split("\n").length;
126
- const text =
127
- full.length > OUTPUT_LIMIT
128
- ? `${full.slice(0, OUTPUT_LIMIT)}\n... truncated (${full.length} chars, ${findings} findings). Full output: run \`se lint ${params.path}\` with bash.`
129
- : `${findings} finding(s) in ${params.path}:\n${full}`;
130
- return {
131
- content: [{ type: "text" as const, text }],
132
- details: { path: params.path, exitCode: code, findings, full },
133
- };
205
+ if (argument === "off" || argument === "stop") {
206
+ setEnabled(false, ctx);
207
+ return;
208
+ }
209
+
210
+ ctx.ui.notify("Usage: /se [on|off]", "warning");
134
211
  },
135
212
  });
136
213
 
137
214
  pi.on("tool_result", async (event: ToolResultEvent): Promise<ToolResultEventResult | undefined> => {
215
+ if (!enabled) return;
138
216
  if (event.toolName !== "edit" && event.toolName !== "write") return;
139
217
  // A failed edit wrote nothing: there is nothing to lint.
140
218
  if (event.isError) return;
@@ -144,4 +222,7 @@ export default function (pi: ExtensionAPI) {
144
222
  if (!feedback) return;
145
223
  return { content: [...event.content, { type: "text" as const, text: feedback }] };
146
224
  });
225
+
226
+ pi.on("session_start", async (_event, ctx) => restoreState(ctx));
227
+ pi.on("session_tree", async (_event, ctx) => restoreState(ctx));
147
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simple-english",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Lint prose and code comments with the simple_english plain-English rules.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,7 +7,8 @@ description: Lint prose with simple_english after writing or editing documentati
7
7
 
8
8
  Lint prose the linter can see. Call the lint tool after writing or
9
9
  editing any documentation file or prose-heavy text. Read the findings.
10
- Fix every finding. Re-run until the result is clean.
10
+ Fix every finding, also in prose you did not write. Re-run
11
+ until the result is clean.
11
12
 
12
13
  The tool names differ by agent:
13
14