ASD-STE100 (Simplified Technical English) and 80% Karpathy Mode plugin, mod, and skill for Claude Code. Enforces clean, unambiguous technical explanations.

Claude Code Plugin · Mod · Agent Skill · Linter & CLI Controlled technical English specification to minimize cognitive load, eliminate ambiguity, and optimize LLM reasoning and human oversight.
As AI coding and autonomous agents perform more legwork, human engineers spend significantly more time reviewing, understanding, and validating LLM outputs.
Former OpenAI Chief Scientist and Tesla AI Director Andrej Karpathy highlighted this paradigm shift:
"Writing. Something I've had success with: Ask your LLM to explain something in ASD-STE100, it's a controlled language specification originally developed for aerospace maintenance documentation. LLMs well-versed in this language and it comes with heavy constraints on clean writing style that I often find a lot more readable. Sometimes I've tried to soften it a bit e.g. ask for '80% of the way to ASD-STE100' because the spec is quite stringent...
In summary:
- As LLMs get better, they will do more and more of the legwork autonomously, and a lot more of our work will rise up the abstractions into oversight and understanding.
- Luckily, LLMs can help here too because as intelligence and code are increasingly abundant, you can ask for large, custom, discardable software artifacts (e.g. web apps, video explainers) that would have never made sense to create before."
This repository packages ASD-STE100 into a complete ecosystem:
hooks/register.js): An internal Claude Code extension that adds /asd commands, UI status indicators, the STE pane, and STE rules in the system prompt..claude-plugin/plugin.json): Installable plugin compliant with the new Claude Code plugin architecture.skills/asd-ste100/SKILL.md): A rich skill loaded by Claude Code, Google Antigravity, and other coding assistants.lib/ste-engine.js): Checks sentence and paragraph length, passive voice (with the Issue 9 descriptive-text exception), verb forms, multi-word nouns, unapproved words, phrasal verbs, semicolons, contractions, and Latin abbreviations. It uses lightweight heuristics (no part-of-speech tagging) and a curated word list, not the official ~900-word dictionary.bin/ste.js): Lint text files or format system prompts directly from your terminal.Requirements: Claude Code v2.1.287 or later for the mod (/asd command, tools, STE pane, system prompt rules, spinner indicator). Run claude --version to check. The skill works on any version that supports plugins.
This repository is its own plugin marketplace. One plugin install gives you both the skill and the mod (the /asd command and the STE pane). Install it from GitHub:
claude plugin marketplace add JAICHANGPARK/ASD-STE100
claude plugin install asd-ste100@asd-ste100
Or, inside a Claude Code session:
/plugin marketplace add JAICHANGPARK/ASD-STE100
/plugin install asd-ste100@asd-ste100
/reload-plugins
To try it for one session without installing (for example, from a local clone):
claude --plugin-dir /path/to/ASD-STE100
To update later, run claude plugin update asd-ste100@asd-ste100.
Older Claude Code (v2.1.286 and earlier): mods were early access. The skill loads, but the mod prints
hooks module not loadedand/asddoes not exist. Update Claude Code, or start it withCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Remove that variable after you update.
To see what the mod hooks and calls before you install it, run claude plugin validate . in a clone.
/asd Command (Mod)Type / to see the /asd command:
/asd on # Activate automatic STE prompt enhancement
/asd 80 # Use Karpathy's 80% Pragmatic Mode (recommended); changes only the mode
/asd strict # Use 100% Strict ASD-STE100 Mode; changes only the mode
/asd pane # Show the last answer as an STE document in a side pane
/asd auto [on|off] # Rewrite each answer as an STE document in the pane (default on)
/asd session [lite] # Write an STE record of the whole session in the pane (on request only)
/asd check <text> # Lint and score a sentence or paragraph
/asd rewrite <text> # Rewrite any text into clean STE format
/asd status # Display active mode and status
/asd off # Deactivate STE mode
The mod keeps Claude's answer as Claude wrote it. After each answer, the STE pane opens and shows that answer converted into an ASD-STE100 document. You do not need /asd on for the pane. Use /asd on only when you want Claude to write the answer itself in STE (the same effect as the skill). Use /asd off to go back.
/asd 80 and /asd strict change only the mode that the pane and STE mode use. Only /asd on makes the answer itself STE. /asd on, /asd 80, /asd strict, and /asd off save the state machine-wide. The saved state applies to every project and overrides the defaultMode and autoInject plugin options.
/asd session writes a record of the whole Claude Code session as an ASD-STE100 document, in the STE pane (key s). The record has the sections Purpose, Decisions, Completed work (numbered steps in time order), and Open items, in the language of the conversation.
/asd session asks the session's own model once, over the conversation that it already has in its prompt cache. This gives the most accurate record./asd session lite sends the conversation text to a small model (haiku). It costs less. The mod also uses it when the full way fails./asd session, or with g on the pane's session tab. It never runs by itself.After each answer, the STE pane opens beside the transcript. A small model (haiku) rewrites the answer as an ASD-STE100 document, and the pane shows that STE version as a page of a maintenance manual:
┌───────────────────────────────────────┬──────────────────┐
│ ASD-STE100 STE MAINTENANCE MANUAL │ TASK 00-01-03 │
│ FLUTTER WIDGETS │ PAGE BLOCK 201 │
├───────────────────────────────────────┴──────────────────┤
│ MAINTENANCE PRACTICES │
├───────────────────┬─────────────┬────────┬───────────────┤
│ EFFECTIVITY ALL │ MODE 80% STE│ REV 1 │ DATE 2026-10-02│
└───────────────────┴─────────────┴────────┴───────────────┘
STE version
1. GENERAL
A. In Flutter, everything on the screen is a widget.
2. PROCEDURE
(1) Run the app in debug mode.
┌──────────────── WARNING ────────────────┐
│ Do not put a large tree in one build(). │
└─────────────────────────────────────────┘
Original → STE: avg words 19 → 7 · too long 3 → 0
┌──────────────────┬──────────────────┬────────────────────┐
│ ASD-STE100 ISSUE 9│ SCORE 97/100 │ 00-01-03 PAGE 201 │
└──────────────────┴──────────────────┴────────────────────┘
001 description and operation, 201 maintenance practices when the text has steps), effectivity, mode, revision and date.1., sentences A., procedure steps (1), list items (a). One idea in each sentence.utilize →USE).r adds one to the revision.The header shows the STE check score of the text in view, and one line that says what the rewrite changed, for example Original → STE: avg words 19 → 7 · too long 3 → 0 · tables 1 → 0 · phrasal verbs 2 → 0. Pane keys: v STE version, o original answer, c check (score and findings), r rewrite again, x close.
The rewrite costs one small model call for each answer. Use /asd auto off to stop it, and press r when you want a rewrite. The pane docks beside the transcript in the fullscreen layout. Claude Code shows a pane that opens by itself only when the terminal is 144 columns or wider; in a narrower terminal, type /asd pane. Use /asd pane to open it, and /asd pane off to stop it from opening by itself.
The mod also gives Claude two tools: mcp__asd-ste100__validate_ste and mcp__asd-ste100__rewrite_ste.
When active, Claude Code displays a live indicator beside the spinner:
Thinking · STE [PRAGMATIC 80%]…
The indicator appears in the terminal and in the Desktop app. In claude -p and the VS Code chat panel, the hooks run, but nothing is drawn.
# Lint a piece of text:
./bin/ste.js check "You should utilize this script prior to commencing the process."
# Output:
# ========================================
# ASD-STE100 Compliance Report (PRAGMATIC MODE)
# ========================================
# Score: 68 / 100
# Issues:
# - [UNAPPROVED_WORD] "prior to" is unapproved in ASD-STE100. Use "before" instead.
# - [UNAPPROVED_WORD] "should" is unapproved in ASD-STE100. Use "must (or explain optional choice)" instead.
# - [UNAPPROVED_WORD] "utilize" is unapproved in ASD-STE100. Use "use" instead.
# - [UNAPPROVED_WORD] "commencing" is unapproved in ASD-STE100. Use "starting" instead.
# Generate system prompt instructions for any LLM:
./bin/ste.js prompt --diagram
The skill file is located at: skills/asd-ste100/SKILL.md
After you install the plugin, Claude Code loads the skill as asd-ste100:asd-ste100. Claude uses it automatically when a request matches, or you can call it with /asd-ste100:asd-ste100.
Whenever you want an agent to explain or rewrite technical logic, simply ask:
"Explain the architecture of our authentication service in 80% ASD-STE100 with a Mermaid diagram."
Strict mode follows ASD-STE100 Issue 9 (January 2025): 53 writing rules in 9 sections and a dictionary of about 900 approved words. The official standard is free on request at asd-ste100.org. This repository summarizes the rules and does not include the dictionary.
| Rule Dimension | Strict Mode (100% ASD-STE100) | 80% Pragmatic Mode (Karpathy) [Default] |
|---|---|---|
| Primary Use Case | Aerospace, defense, ISO hardware manuals | Software engineering, AI systems, architecture review |
| Sentence Length | Instructions: ≤ 20 words<br>Descriptions: ≤ 25 words | Target 15–22 words (hard cap at 25 words) |
| Voice | Active voice. Passive only in descriptive text when the agent is unknown. Imperative for steps. | Active voice with direct subject-verb-object clarity |
| Vocabulary | Approved ASD-STE100 dictionary words, plus technical nouns and technical verbs | Replaces bureaucratic jargon with simple verbs (use, start, stop, before), but allows modern tech nouns (API, cache, Docker) |
| Noun Clusters | Max 3 consecutive nouns | Max 3 consecutive nouns (separated by prepositions) |
| Modal Verbs | Replace shall, should, could, might with must, can, or an exact condition | Direct directives (must, can, or specific conditional rules) |
| Punctuation | No semicolons, contractions, or Latin abbreviations (e.g., i.e., etc.) | Avoid semicolons |
| Paragraphs | One topic, max 6 sentences | One topic, max 6 sentences |
| Safety | WARNING / CAUTION, a command first, then the risk | Same, for data loss, security risks, and outages |
| Cognitive Modality | Standard technical text and tables | Pairs text with Mermaid diagrams or Interactive HTML |
Andrej Karpathy emphasized expanding beyond plain text into high-abstraction artifacts:
examples/interactive_explainer.html)examples/manim_3b1b_explainer.py)ASD-STE100/
├── .claude-plugin/
│ ├── plugin.json # Claude Code Plugin Manifest
│ └── marketplace.json # Marketplace entry (install from GitHub)
├── hooks/
│ ├── hooks.json # Mod Hook configuration
│ └── register.js # Claude Code Mod implementation (/asd command, UI, tools)
├── skills/
│ └── asd-ste100/
│ └── SKILL.md # Authoritative Agent Skill documentation
├── lib/
│ └── ste-engine.js # Core ASD-STE100 rule engine & validator
├── bin/
│ └── ste.js # Terminal CLI utility
├── test/
│ └── ste.test.js # Automated unit test suite
├── examples/
│ ├── before_and_after.md # Real-world before/after technical transformations
│ ├── interactive_explainer.html # Interactive browser-based STE explainer
│ └── manim_3b1b_explainer.py # 3b1b / Manim video explainer script
├── package.json
└── README.md
Run the automated test suite:
npm test
See CHANGELOG.md.
MIT License. Open source and free for personal and commercial use.
hooks/register.js 501 lines1/**
2 * ASD-STE100 Claude Code Mod
3 *
4 * Provides:
5 * - /asd command (on, off, 80, strict, check, rewrite, status)
6 * - Automatic prompt guidance injection when active
7 * - UI status indicator & spinner suffix
8 * - STE pane: the last answer as an ASD-STE100 document beside the transcript (/asd pane)
9 * - Built-in Claude tools: mcp__asd-ste100__validate_ste & mcp__asd-ste100__rewrite_ste
10 */
11
12import { atom, read, update } from 'claude-code';
13import { validateText, buildSystemPrompt } from '../lib/ste-engine.js';
14import { PANE_ID, drawPane } from './pane.jsx';
15import { proseOf } from './sheet.jsx';
16import { STE_REQUEST } from './view.jsx';
17
18let steActive = false;
19let steMode = 'pragmatic'; // 'pragmatic' (80% Karpathy mode) or 'strict' (100% ASD-STE100)
20let paneAuto = true; // Open the STE pane by itself after each answer
21let autoRewrite = true; // Rewrite each answer into an ASD-STE100 document for the pane
22
23// STE pane values. $.state keeps them across a reload of the module, and a write redraws the pane.
24const view = atom({ plugin: 'asd-ste100', key: 'view' }, null);
25const report = atom({ plugin: 'asd-ste100', key: 'report' }, null);
26const tab = atom({ plugin: 'asd-ste100', key: 'tab' }, 'view');
27const isAsked = atom({ plugin: 'asd-ste100', key: 'isAsked' }, false);
28const isRewriting = atom({ plugin: 'asd-ste100', key: 'isRewriting' }, false);
29const paneOpen = atom({ plugin: 'asd-ste100', key: 'paneOpen' }, false);
30const record = atom({ plugin: 'asd-ste100', key: 'record' }, null);
31const recordReport = atom({ plugin: 'asd-ste100', key: 'recordReport' }, null);
32const isRecording = atom({ plugin: 'asd-ste100', key: 'isRecording' }, false);
33
34// Word limit for one sentence of a report
35const limitOf = (sentence) => (sentence.isProcedural && steMode === 'strict' ? 20 : 25);
36
37const modeLabel = () => (steMode === 'strict' ? 'STRICT 100%' : 'PRAGMATIC 80%');
38const modeName = (mode) => (mode === 'strict' ? 'Strict ASD-STE100' : '80% ASD-STE100 (Karpathy Pragmatic Mode)');
39
40// Ask the model for the rewritten text only, without commentary
41// The structure of an ASD-STE100 document, for the answer itself and for the pane's rewrite
42const documentRules = (mode) => `- Start with a short title line: "# <title>".
43- Give each topic its own "## <heading>". Write a maximum of 6 sentences in each paragraph.
44- Write one idea in each sentence. Do not join two clauses with ", and" or ", so".
45- Write a maximum of ${mode === 'strict' ? 20 : 25} words in each sentence. Use the active voice.
46- Do not use phrasal verbs ("set up" -> "install", "look up" -> "find", "break down" -> "divide").
47- Use simple, literal words. Do not use idioms.
48- Write procedures as numbered steps, with one command in each step.
49- Write risks as "WARNING:" (injury, data loss) or "CAUTION:" (damage) lines.
50- Keep code blocks, code names and technical names unchanged.`;
51
52// The system prompt section while STE mode is on. The structure applies to every explanation.
53const directiveOf = (mode) => `${buildSystemPrompt({ mode })}
54
55ASD-STE100 MODE IS ON. Write every explanation in the chat as an ASD-STE100 document:
56${documentRules(mode)}
57Write in the language of the person's prompt, and apply these rules to that language.
58These rules apply even when the prompt asks for another form, for example "one long paragraph" or "a detailed essay". Give the same detail, but in this structure. The rules apply to your prose only, not to code, commands or file contents that you write with tools.`;
59
60// Ask the model for the answer as an ASD-STE100 document, for the pane
61// The language of a text by its script. The rewrite names it, as "the same language" alone let a model pick a wrong one.
62const langOf = (text) => (/[\uac00-\ud7a3]/.test(text) ? 'Korean'
63 : /[\u3040-\u30ff]/.test(text) ? 'Japanese'
64 : /[\u4e00-\u9fff]/.test(text) ? 'Chinese'
65 : 'English');
66let lastPromptLang = null; // The language of the person's last prompt, for the session record
67
68// Ask for a record of the whole session as an ASD-STE100 document
69const recordPrompt = (mode, lang) => `Write a record of this whole conversation as an ASD-STE100 document in ${modeName(mode)}.
70Write the record in ${lang}. Apply the ASD-STE100 rules to ${lang}: short sentences, one idea in each sentence, the active voice, and simple, literal words.
71Use these sections, and leave out a section that has no content:
72- "## Purpose": what the person wanted.
73- "## Decisions": what the person and the assistant decided, and why.
74- "## Completed work": what the assistant did, as numbered steps in time order.
75- "## Open items": what is not done, as a list.
76Write risks as "WARNING:" or "CAUTION:" lines.
77Follow these rules:
78${documentRules(mode)}
79Write only facts from the conversation. Do not add new facts. Output only the document.`;
80
81const MAX_TRANSCRIPT = 150000;
82
83// The session as plain text, cut from the start when it is too long
84async function transcriptOf($) {
85 const rows = await $.session.messages();
86 const text = rows
87 .filter(m => m.text && m.text.trim())
88 .map(m => `${m.role === 'user' ? 'PERSON' : 'ASSISTANT'}: ${m.text.trim()}`)
89 .join('\n\n');
90 return text.length > MAX_TRANSCRIPT ? `[earlier part of the conversation left out]\n${text.slice(-MAX_TRANSCRIPT)}` : text;
91}
92
93// Make the session record. It runs only when the person asks (/asd session, or g in the pane), as it uses tokens.
94// The full way forks the main model over the cached conversation; lite sends the transcript to a small model.
95async function makeRecord($, lite = false) {
96 if (await read($, isRecording)) return;
97 await update($, isRecording, () => true);
98 await update($, tab, () => 'session');
99 let response = null;
100 let source = lite ? 'lite' : 'fork';
101 const lastView = await read($, view);
102 const recordLang = lastPromptLang || (lastView ? langOf(lastView.original) : 'English');
103 if (!lite) {
104 response = await $.model.fork({ prompt: recordPrompt(steMode, recordLang) });
105 if (!response.isAnswered && response.reason !== 'aborted') source = 'lite';
106 }
107 if (source === 'lite') {
108 const transcript = await transcriptOf($);
109 response = transcript
110 ? await $.model.complete({
111 model: 'haiku',
112 system: buildSystemPrompt({ mode: steMode }),
113 prompt: `${recordPrompt(steMode, recordLang)}\n\nThe conversation:\n\n${transcript}`,
114 maxTokens: 4000,
115 timeoutMs: 120000
116 })
117 : { isAnswered: false, reason: 'nothing-to-fork' };
118 }
119 if (response.isAnswered) {
120 const text = response.text.trim();
121 const date = new Date(await $.clock.now()).toISOString().slice(0, 10);
122 await update($, record, (r) => ({ text, source, date, rev: ((r && r.rev) || 0) + 1 }));
123 await update($, recordReport, () => reportOf(text));
124 } else {
125 $.ui.toast(response.reason === 'nothing-to-fork'
126 ? 'STE session record: the session has no conversation yet.'
127 : `STE session record failed: ${response.reason}`);
128 }
129 await update($, isRecording, () => false);
130}
131
132const documentPrompt = (mode, text) => `Rewrite the text below as an ASD-STE100 document in ${modeName(mode)}.
133Write the document in ${langOf(text)}, the language of the text. Do not translate it into another language. Apply the ASD-STE100 rules to ${langOf(text)}: short sentences, one idea in each sentence, the active voice, and simple, literal words.
134Follow these rules:
135${documentRules(mode)}
136Output only the document.
137
138${text}`;
139
140const rewritePrompt = (mode, text) =>
141 `Rewrite the following text into ${modeName(mode)}. Output only the rewritten text. Do not add headings, explanations, or notes.\n\n${text}`;
142
143// Lint report of an answer, without its code, tables and links
144const reportOf = (text) => {
145 const prose = proseOf(text);
146 const checked = prose ? validateText(prose, { mode: steMode }) : null;
147 return checked && checked.totalSentences > 0 ? checked : null;
148};
149
150async function openPane($) {
151 await update($, paneOpen, () => true);
152 const opened = await $.ui.open({ id: PANE_ID, title: 'STE' });
153 // A pane the mod opens by itself waits below 144 terminal columns. Say so, not an empty screen.
154 if (opened && opened.isPlaced === false) {
155 $.ui.toast('STE pane is ready. Make the terminal 144 columns or wider, or type /asd pane.');
156 }
157 return opened;
158}
159
160// Rewrite the original answer in STE for the pane
161// force: rewrite again when the pane already shows an STE version (the r key)
162async function rewriteView($, force = false) {
163 const current = await read($, view);
164 if (!current || (!force && current.source === 'rewrite')) return;
165 const original = current.original;
166 await update($, isRewriting, () => true);
167 const response = await $.model.complete({
168 model: 'haiku',
169 system: buildSystemPrompt({ mode: steMode }),
170 prompt: documentPrompt(steMode, original),
171 maxTokens: 3000,
172 timeoutMs: 90000
173 });
174 // Keep the result only if the pane still shows the same answer
175 const now = await read($, view);
176 if (now && now.original === original) {
177 if (response.isAnswered) {
178 const text = response.text.trim();
179 await update($, view, (v) => ({ ...v, text, source: 'rewrite', rev: (v.rev || 0) + 1 }));
180 await update($, report, () => reportOf(text));
181 } else {
182 $.ui.toast(`STE rewrite failed: ${response.reason}`);
183 }
184 await update($, isRewriting, () => false);
185 }
186}
187
188// Show the current state in the status line and redraw the spinner suffix
189function refreshUi($) {
190 $.ui.status(steActive ? `STE [${modeLabel()}]: Active` : undefined);
191 $.ui.invalidate('ui.render');
192}
193
194export function register(on, options = {}) {
195 // userConfig values are defaults. A mode or state saved with /asd (machine-wide $.store) overrides them.
196 if (options.defaultMode === 'strict') {
197 steMode = 'strict';
198 }
199 if (options.autoInject) {
200 steActive = true;
201 }
202
203 // Session start: register command, tools, and restore saved state
204 on('session.start', async ($, e, next) => {
205 try {
206 await $.command.register({
207 name: 'asd',
208 description: 'Control ASD-STE100 writing mode, check text, or rewrite content',
209 argumentHint: '[on|off|80|strict|pane [on|off]|auto [on|off]|session [lite]|check <text>|rewrite <text>|status]'
210 });
211
212 await $.tool.register({
213 name: 'validate_ste',
214 description: 'Validate text against ASD-STE100 (Simplified Technical English) rules and return quality metrics and suggestions.',
215 inputSchema: {
216 type: 'object',
217 properties: {
218 text: { type: 'string', description: 'Text to analyze' },
219 mode: { type: 'string', enum: ['pragmatic', 'strict'], description: 'Validation mode (default: pragmatic / 80%)' }
220 },
221 required: ['text']
222 }
223 });
224
225 await $.tool.register({
226 name: 'rewrite_ste',
227 description: 'Rewrite text into ASD-STE100 simplified technical style.',
228 inputSchema: {
229 type: 'object',
230 properties: {
231 text: { type: 'string', description: 'Original text to rewrite' },
232 mode: { type: 'string', enum: ['pragmatic', 'strict'] },
233 format: { type: 'string', enum: ['text', 'diagram', 'html'] }
234 },
235 required: ['text']
236 }
237 });
238
239 // Restore persisted state
240 const savedActive = await $.store.get('ste_active');
241 if (typeof savedActive === 'boolean') steActive = savedActive;
242 const savedMode = await $.store.get('ste_mode');
243 if (savedMode === 'strict' || savedMode === 'pragmatic') steMode = savedMode;
244 const savedPane = await $.store.get('ste_pane');
245 if (typeof savedPane === 'boolean') paneAuto = savedPane;
246 const savedRewrite = await $.store.get('ste_autorewrite');
247 if (typeof savedRewrite === 'boolean') autoRewrite = savedRewrite;
248
249 if (steActive) refreshUi($);
250 } catch (err) {
251 // Avoid failing session start if registration encounters issues
252 $.ui.log(`ASD-STE100 mod initialization notice: ${err.message}`);
253 }
254
255 return next(e);
256 });
257
258 // Handle /asd command
259 on('command.run', { command: 'asd' }, async ($, e) => {
260 const rawArgs = (e.args || '').trim();
261 const first = rawArgs.split(/\s+/)[0] || '';
262 const sub = first.toLowerCase();
263 // Keep the original line breaks of the text after the subcommand
264 const rest = rawArgs.slice(first.length).trim();
265
266 if (sub === 'on') {
267 steActive = true;
268 await $.store.set('ste_active', true);
269 refreshUi($);
270 return { text: `[ASD-STE100] Activated. Mode: ${modeLabel()}. Claude now answers in ${modeName(steMode)}. Use /asd 80 or /asd strict to change the mode.` };
271 }
272
273 if (sub === 'off') {
274 steActive = false;
275 await $.store.set('ste_active', false);
276 refreshUi($);
277 return { text: '[ASD-STE100] Deactivated. Standard generation resumed.' };
278 }
279
280 if (sub === '80' || sub === 'pragmatic') {
281 steMode = 'pragmatic';
282 await $.store.set('ste_mode', 'pragmatic');
283 refreshUi($);
284 return { text: '[ASD-STE100] Mode: 80% Pragmatic (Karpathy style): sentences <= 25 words, active voice, one idea in each sentence. The STE pane uses this mode. Use /asd on to make the answer itself STE.' };
285 }
286
287 if (sub === '100' || sub === 'strict') {
288 steMode = 'strict';
289 await $.store.set('ste_mode', 'strict');
290 refreshUi($);
291 return { text: '[ASD-STE100] Mode: 100% Strict (ASD-STE100 Issue 9): max 20 words for procedures, max 25 for descriptions, approved vocabulary, active voice. The STE pane uses this mode. Use /asd on to make the answer itself STE.' };
292 }
293
294 if (sub === 'session') {
295 const lite = rest.toLowerCase() === 'lite';
296 await openPane($);
297 // Run after the command returns, so the record does not belong to the command's dispatch
298 $.clock.after(0, () => { void makeRecord($, lite); });
299 return { text: `[ASD-STE100] Writing an STE record of this session in the pane${lite ? ' (lite: small model)' : ''}. This uses tokens.` };
300 }
301
302 if (sub === 'auto') {
303 const arg = rest.toLowerCase();
304 autoRewrite = arg === 'on' ? true : arg === 'off' ? false : !autoRewrite;
305 await $.store.set('ste_autorewrite', autoRewrite);
306 return { text: `[ASD-STE100] Auto rewrite ${autoRewrite ? 'ON: the pane shows each answer rewritten as an ASD-STE100 document' : 'OFF: press r in the pane to rewrite an answer'}.` };
307 }
308
309 if (sub === 'pane') {
310 const arg = rest.toLowerCase();
311 if (arg === 'on' || arg === 'off') {
312 paneAuto = arg === 'on';
313 await $.store.set('ste_pane', paneAuto);
314 return { text: `[ASD-STE100] The STE pane ${paneAuto ? 'opens by itself after each answer' : 'opens only with /asd pane'}.` };
315 }
316 await openPane($);
317 return { text: '[ASD-STE100] STE pane opened. v: view · c: check · r: rewrite · x: close' };
318 }
319
320 if (sub === 'status') {
321 return {
322 text: `[ASD-STE100 Status]
323- State: ${steActive ? 'ACTIVE (automatically formatting prompts)' : 'INACTIVE'}
324- Mode: ${modeLabel()}
325- Pane: ${paneAuto ? 'opens by itself after each answer' : 'opens only with /asd pane'}
326- Auto rewrite: ${autoRewrite ? 'ON (one small model call for each answer)' : 'OFF'}
327- Commands: /asd [on|off|80|strict|pane [on|off]|check <text>|rewrite <text>]`
328 };
329 }
330
331 if (sub === 'check') {
332 if (!rest) {
333 return { text: 'Usage: /asd check <text to analyze>' };
334 }
335 const report = validateText(rest, { mode: steMode });
336 let output = `[ASD-STE100 Quality Report - ${modeLabel()}]\nScore: ${report.score}/100 | Sentences: ${report.totalSentences} | Avg Words: ${report.averageWordsPerSentence} | Issues: ${report.totalIssues}\n`;
337 report.sentences.forEach(s => {
338 const icon = s.issues.length === 0 ? '✔' : '⚠';
339 output += `\n${icon} [S${s.index} (${s.wordCount} words)]: "${s.text}"\n`;
340 s.issues.forEach(i => {
341 output += ` • [${i.type}] ${i.message}\n`;
342 });
343 });
344 return { text: output.trim() };
345 }
346
347 if (sub === 'rewrite') {
348 if (!rest) {
349 return { text: 'Usage: /asd rewrite <text to convert to STE>' };
350 }
351 // Call model to rewrite
352 const systemInstruction = buildSystemPrompt({ mode: steMode });
353 const response = await $.model.complete({
354 model: 'haiku',
355 system: systemInstruction,
356 prompt: rewritePrompt(steMode, rest),
357 maxTokens: 1000,
358 timeoutMs: 30000
359 });
360
361 if (response && response.isAnswered) {
362 return { text: `[Rewritten in ASD-STE100 (${modeLabel()})]:\n\n${response.text.trim()}` };
363 }
364 return { text: `Could not rewrite via model. Validation report for original text:\n` + JSON.stringify(validateText(rest, { mode: steMode }), null, 2) };
365 }
366
367 // Default help
368 return {
369 text: `[ASD-STE100 Simplified Technical English Assistant]
370Inspired by Andrej Karpathy's controlled language guidance for LLM understanding.
371
372Commands:
373 /asd on - Activate automatic STE prompt enhancement
374 /asd off - Deactivate STE enhancement
375 /asd 80 - Set to 80% Pragmatic Mode (Karpathy style, readable & fast)
376 /asd strict - Set to 100% Strict ASD-STE100 standard
377 /asd pane - Show the last answer as an STE document in a side pane
378 /asd pane [on|off] - Open the pane by itself after each answer, or not
379 /asd auto [on|off] - Rewrite each answer as an ASD-STE100 document in the pane, or not
380 /asd session [lite] - Write an STE record of the whole session in the pane (on request only; uses tokens)
381 /asd check <text> - Lint text and inspect compliance score
382 /asd rewrite <text> - Rewrite text into STE format
383 /asd status - Check current mode and settings`
384 };
385 });
386
387 // Handle validate_ste tool call
388 on('tool.call', { tool: 'mcp__asd-ste100__validate_ste' }, async ($, e) => {
389 const report = validateText(e.text || '', { mode: e.mode || steMode });
390 return { result: JSON.stringify(report, null, 2) };
391 });
392
393 // Handle rewrite_ste tool call
394 on('tool.call', { tool: 'mcp__asd-ste100__rewrite_ste' }, async ($, e) => {
395 const mode = e.mode || steMode;
396 const format = e.format || 'text';
397 const system = buildSystemPrompt({ mode, targetFormat: format });
398 const response = await $.model.complete({
399 model: 'haiku',
400 system,
401 prompt: rewritePrompt(mode, e.text || ''),
402 maxTokens: 1500,
403 timeoutMs: 30000
404 });
405 return { result: response && response.isAnswered ? response.text.trim() : 'Rewriting failed or model timed out.' };
406 });
407
408 // The answer is in STE when the person asks for it or STE mode is on
409 on('prompt.submit', async ($, e, next) => {
410 await update($, isAsked, () => steActive || STE_REQUEST.test(e.text));
411 // A slash command says nothing about the person's language
412 if (!e.text.trim().startsWith('/')) lastPromptLang = langOf(e.text);
413 return next(e);
414 });
415
416 // While STE mode is on, add the STE rules to the system prompt. The person's prompt stays as typed.
417 on('prompt.compose', async ($, e, next) => {
418 const composed = await next(e);
419 if (!steActive) return composed;
420 return {
421 ...composed,
422 sections: [
423 ...composed.sections,
424 { id: 'asd-ste100:directive', text: directiveOf(steMode), scope: 'session' }
425 ]
426 };
427 });
428
429 // Enhance spinner while thinking if STE is active
430 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
431 if (!steActive) return next(e);
432 const suffix = ` · STE [${modeLabel()}]`;
433 return next({
434 ...e,
435 props: {
436 ...e.props,
437 suffix: (e.props && e.props.suffix ? e.props.suffix : '') + suffix
438 }
439 });
440 });
441
442 // Keep the final answer of each main-loop turn for the STE pane
443 on('turn.complete', async ($, e, next) => {
444 if (!e.agentId && !e.isAborted && e.answer.trim()) {
445 const asked = await read($, isAsked);
446 const previous = await read($, view);
447 const date = new Date(await $.clock.now()).toISOString().slice(0, 10);
448 await update($, view, () => ({
449 text: e.answer, original: e.answer, source: 'answer', isAsked: asked,
450 task: ((previous && previous.task) || 0) + 1, rev: 0, date
451 }));
452 await update($, report, () => reportOf(e.answer));
453 await update($, tab, () => 'view');
454 await update($, isRewriting, () => autoRewrite);
455 // Open it each time: a pane that is already open only keeps its place, and a state left from a resumed session cannot keep it closed
456 if (paneAuto) await openPane($);
457 // Rewrite after the turn ends, so the rewrite does not belong to the turn's dispatch
458 if (autoRewrite) $.clock.after(0, () => { void rewriteView($); });
459 }
460 return next(e);
461 });
462
463 on('ui.close', async ($, e, next) => {
464 if (e.id === PANE_ID) await update($, paneOpen, () => false);
465 return next(e);
466 });
467
468 // Draw the STE pane
469 on('ui.render', { component: 'Pane', requestId: 'ste-sheet' }, async ($, e) => {
470 const els = { h, ...$.ui.resolve(e) };
471 try {
472 return await drawPaneFor($, e, els);
473 } catch (err) {
474 // Show the error in the pane, not an empty pane
475 const { Box, Text } = els;
476 return h(Box, { flexDirection: 'column' },
477 h(Text, { color: 'red' }, 'The STE pane could not draw this answer.'),
478 h(Text, { dimColor: true }, String((err && err.message) || err)));
479 }
480 });
481}
482
483async function drawPaneFor($, e, els) {
484 return drawPane(els, {
485 view: await read($, view),
486 report: await read($, report),
487 tab: await read($, tab),
488 mode: steMode,
489 modeLabel: modeLabel(),
490 limitOf,
491 isRewriting: await read($, isRewriting),
492 record: await read($, record),
493 recordReport: await read($, recordReport),
494 isRecording: await read($, isRecording),
495 onTab: (id) => update($, tab, () => id),
496 onRewrite: () => rewriteView($, true),
497 onRecord: () => makeRecord($),
498 onClose: () => $.ui.close({ id: PANE_ID })
499 }, e.props.bodyColumns);
500}
501lib/ste-engine.js 627 lines1/**
2 * ASD-STE100 (Simplified Technical English) Rule Engine & Validator
3 *
4 * Implements core checking rules based on the ASD-STE100 Standard (Issue 9, January 2025)
5 * and Andrej Karpathy's "80% Pragmatic Mode" for LLM clarity.
6 */
7
8// Common unapproved words in ASD-STE100 and their recommended replacements
9const UNAPPROVED_WORDS = {
10 'utilize': 'use',
11 'utilizes': 'uses',
12 'utilized': 'used',
13 'utilizing': 'using',
14 'utilization': 'use',
15 'terminate': 'stop',
16 'terminates': 'stops',
17 'terminated': 'stopped',
18 'terminating': 'stopping',
19 'commence': 'start',
20 'commences': 'starts',
21 'commenced': 'started',
22 'commencing': 'starting',
23 'initiate': 'start',
24 'initiates': 'starts',
25 'initiated': 'started',
26 'initiating': 'starting',
27 'shut': 'close',
28 'shuts': 'closes',
29 'shutting': 'closing',
30 'modify': 'change',
31 'modifies': 'changes',
32 'modified': 'changed',
33 'modifying': 'changing',
34 'modification': 'change',
35 'prior to': 'before',
36 'subsequent to': 'after',
37 'in order to': 'to',
38 'in the event that': 'if',
39 'in the event of': 'if there is',
40 'as well as': 'and',
41 'adequate': 'sufficient (or give exact value)',
42 'adequately': 'sufficiently',
43 'properly': 'correctly (or give exact instruction)',
44 'etc': 'list all items (do not use etc.)',
45 'and/or': 'and | or (choose one)',
46 'shall': 'must (or use imperative)',
47 'should': 'must (or explain optional choice)',
48 'ought to': 'must',
49 'could': 'can',
50 'might': 'can (or state condition)',
51 'accomplish': 'do / complete',
52 'accomplishes': 'does / completes',
53 'accomplished': 'done / completed',
54 'obtain': 'get',
55 'obtains': 'gets',
56 'obtained': 'got',
57 'obtaining': 'getting',
58 'fabricate': 'make',
59 'fabricates': 'makes',
60 'fabricated': 'made',
61 'approximately': 'about',
62 'subsequently': 'then / after that',
63 'eliminate': 'remove',
64 'eliminates': 'removes',
65 'eliminated': 'removed',
66 'eliminating': 'removing',
67 'execute': 'do / run / start',
68 'executes': 'does / runs / starts',
69 'executed': 'done / ran / started',
70 'transmit': 'send',
71 'transmits': 'sends',
72 'transmitted': 'sent',
73 'transmitting': 'sending',
74 'verify': 'make sure / check',
75 'verifies': 'makes sure / checks',
76 'verified': 'made sure / checked',
77 'inspect': 'examine',
78 'inspects': 'examines',
79 'inspected': 'examined',
80 'ascertain': 'make sure / find',
81 'furthermore': 'also',
82 'moreover': 'also',
83 'nevertheless': 'but / however',
84 'nonetheless': 'but / however',
85 'in view of the fact that': 'because',
86 'for the purpose of': 'to',
87 'with the exception of': 'except',
88 'at the present time': 'now',
89 'has the capability to': 'can',
90 'is able to': 'can',
91 'it is recommended that': 'we recommend that (or use imperative)',
92 'it is necessary to': 'you must',
93 'facilitate': 'help / make easy',
94 'facilitates': 'helps',
95 'facilitated': 'helped',
96 'hazardous': 'dangerous',
97 'magnitude': 'size / value',
98 'necessitate': 'need / require',
99 'numerous': 'many',
100 'optimum': 'best',
101 'portion': 'part',
102 'portions': 'parts',
103 'precaution': 'safety rule',
104 'preclude': 'prevent',
105 'precludes': 'prevents',
106 'precluded': 'prevented',
107 'prescribed': 'specified',
108 'proximity': 'near',
109 'rectify': 'correct / repair',
110 'rectifies': 'corrects / repairs',
111 'rectified': 'corrected / repaired',
112 'remainder': 'rest',
113 'retain': 'keep',
114 'retains': 'keeps',
115 'retained': 'kept',
116 'retaining': 'keeping',
117 'supersede': 'replace',
118 'supersedes': 'replaces',
119 'superseded': 'replaced',
120 'vicinity': 'near / area',
121 'vital': 'important / necessary'
122};
123
124// Common past participles used in passive voice detection
125const PASSIVE_PARTICIPLES = new Set([
126 'given', 'taken', 'done', 'seen', 'made', 'written', 'executed', 'performed',
127 'sent', 'received', 'found', 'installed', 'removed', 'replaced', 'adjusted',
128 'checked', 'tested', 'connected', 'disconnected', 'set', 'opened', 'closed',
129 'transmitted', 'processed', 'calculated', 'generated', 'modified', 'changed',
130 'verified', 'examined', 'configured', 'initialized', 'stopped', 'started',
131 'loaded', 'saved', 'deleted', 'created', 'updated', 'selected', 'cleaned'
132]);
133
134const BE_VERBS = new Set([
135 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'become', 'becomes', 'became'
136]);
137
138// Participles that usually act as adjectives after "be" ("The users are tired.")
139const ADJECTIVAL_PARTICIPLES = new Set([
140 'tired', 'interested', 'excited', 'pleased', 'bored', 'worried', 'surprised',
141 'satisfied', 'concerned', 'involved', 'supposed', 'scared', 'confused', 'married'
142]);
143
144// Words ending in -ing that are not progressive verb forms ("The type is string.")
145const NON_PROGRESSIVE_ING = new Set([
146 'nothing', 'something', 'anything', 'everything', 'string', 'thing', 'ring',
147 'king', 'during', 'spring', 'morning', 'evening', 'ceiling', 'building', 'missing', 'interesting'
148]);
149
150// Nouns that end in -ed ("RSS feed", "network speed")
151const EED_NOUNS = new Set(['speed', 'feed', 'seed', 'need', 'breed', 'greed']);
152
153const HAVE_VERBS =new Set(['has', 'have', 'had']);
154
155// Abbreviations that end with a period but do not end a sentence
156const ABBREVIATIONS = new Set([
157 'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'vs', 'e.g', 'i.e', 'approx', 'fig'
158]);
159
160/**
161 * Split text into individual sentences.
162 * Decimals ("2.5") and common abbreviations ("Mr.", "e.g.") do not end a sentence.
163 */
164function splitSentences(text) {
165 if (!text) return [];
166 const sentences = [];
167 for (const line of text.split(/\n+/)) {
168 let current = '';
169 for (const part of line.split(/(?<=[.!?])\s+/)) {
170 current = current ? `${current} ${part}` : part;
171 const lastWord = (current.match(/(\S+)\.$/) || [])[1];
172 if (lastWord && ABBREVIATIONS.has(lastWord.toLowerCase())) continue;
173 sentences.push(current);
174 current = '';
175 }
176 if (current) sentences.push(current);
177 }
178 return sentences.map(s => s.trim()).filter(Boolean);
179}
180
181/**
182 * Split sentence into clean tokens (words).
183 */
184function tokenizeWords(sentence) {
185 return sentence
186 .replace(/[^\w\s-]/g, ' ')
187 .split(/\s+/)
188 .filter(w => w.length > 0);
189}
190
191/**
192 * Detect passive voice in a sentence.
193 */
194function detectPassiveVoice(words) {
195 const issues = [];
196 const cleanWords = words.map(w => w.replace(/[^\w-]/g, ''));
197 const lower = cleanWords.map(w => w.toLowerCase());
198 for (let i = 0; i < lower.length - 1; i++) {
199 if (BE_VERBS.has(lower[i])) {
200 // Check next word or next-next word (in case of adverb e.g. "is carefully tested")
201 const nextWord = lower[i + 1];
202 const nextNextWord = lower[i + 2];
203
204 const isPastForm = (w) => Boolean(w && !ADJECTIVAL_PARTICIPLES.has(w) &&
205 (PASSIVE_PARTICIPLES.has(w) || (w.endsWith('ed') && w.length > 3)));
206
207 if (isPastForm(nextWord)) {
208 issues.push({
209 phrase: `${cleanWords[i]} ${cleanWords[i + 1]}`,
210 index: i,
211 suggestion: 'Convert to active voice (state who or what performs the action).'
212 });
213 } else if (nextNextWord && isPastForm(nextNextWord)) {
214 issues.push({
215 phrase: `${cleanWords[i]} ${cleanWords[i + 1]} ${cleanWords[i + 2]}`,
216 index: i,
217 suggestion: 'Convert to active voice (state who or what performs the action).'
218 });
219 }
220 }
221 }
222 return issues;
223}
224
225/**
226 * Detect verb tenses that ASD-STE100 does not permit:
227 * progressive ("is running") and perfect ("has processed").
228 */
229function detectUnapprovedTenses(words) {
230 const issues = [];
231 const lower = words.map(w => w.toLowerCase());
232 for (let i = 0; i < lower.length - 1; i++) {
233 const next = lower[i + 1];
234 if (BE_VERBS.has(lower[i]) && lower[i] !== 'being' && next.endsWith('ing') && next.length > 4 && !NON_PROGRESSIVE_ING.has(next)) {
235 issues.push({ phrase: `${words[i]} ${words[i + 1]}`, tense: 'progressive', suggestion: 'Use the present simple (e.g. "is running" -> "runs").' });
236 }
237 if (HAVE_VERBS.has(lower[i]) && (next.endsWith('ed') || PASSIVE_PARTICIPLES.has(next) || next === 'been')) {
238 issues.push({ phrase: `${words[i]} ${words[i + 1]}`, tense: 'perfect', suggestion: 'Use the past simple (e.g. "has processed" -> "processed").' });
239 }
240 }
241 return issues;
242}
243
244// Phrasal verbs (STE 9.3) and their single-verb replacements
245const PHRASAL_VERBS = [
246 [/\b(set|sets|setting) up\b/gi, 'install / prepare / configure'],
247 [/\b(carry|carries|carried|carrying) out\b/gi, 'do'],
248 [/\b(find|finds|found|finding) out\b/gi, 'find / learn'],
249 [/\b(figure|figures|figured|figuring) out\b/gi, 'find / calculate'],
250 [/\b(look|looks|looked|looking) into\b/gi, 'examine'],
251 [/\b(come|comes|came|coming) up with\b/gi, 'make / find'],
252 [/\b(get|gets|got|getting) rid of\b/gi, 'remove'],
253 [/\b(fill|fills|filled|filling) out\b/gi, 'complete'],
254 [/\b(give|gives|gave|giving) up\b/gi, 'stop']
255];
256
257/**
258 * Detect punctuation and style issues: semicolons (STE 8.1), contractions (STE 4.2),
259 * Latin abbreviations (grammar rules) and phrasal verbs (STE 9.3).
260 * Contractions and Latin abbreviations are reported in strict mode only.
261 */
262function detectStyleIssues(sentence, isStrict) {
263 const issues = [];
264 if (sentence.includes(';')) {
265 issues.push({
266 type: 'SEMICOLON',
267 severity: isStrict ? 'error' : 'info',
268 rule: 'STE 8.1: No Semicolons',
269 message: 'Semicolon detected. ASD-STE100 does not permit semicolons.',
270 suggestion: 'Write two sentences.'
271 });
272 }
273 if (isStrict) {
274 const contractions = sentence.match(/\b\w+(n't|'re|'ll|'ve|'d|'m)\b|\b(it|that|there|what|here)'s\b/gi) || [];
275 for (const c of contractions) {
276 issues.push({
277 type: 'CONTRACTION',
278 severity: 'warning',
279 rule: 'STE 4.2: No Contractions',
280 message: `Contraction detected: "${c}".`,
281 suggestion: 'Write the full form (e.g. "don\'t" -> "do not").'
282 });
283 }
284 const latin = sentence.match(/\b(e\.g|i\.e|viz|cf)\.?(?=\s|,|$)/gi) || [];
285 for (const l of latin) {
286 issues.push({
287 type: 'LATIN_ABBREVIATION',
288 severity: 'warning',
289 rule: 'STE Grammar: Latin Abbreviations',
290 message: `Latin abbreviation detected: "${l}".`,
291 suggestion: 'Use "for example" or "that is".'
292 });
293 }
294 }
295 for (const [regex, replacement] of PHRASAL_VERBS) {
296 for (const match of sentence.match(regex) || []) {
297 issues.push({
298 type: 'PHRASAL_VERB',
299 severity: isStrict ? 'warning' : 'info',
300 rule: 'STE 9.3: No Phrasal Verbs',
301 message: `Phrasal verb detected: "${match}". Use "${replacement}" instead.`,
302 word: match,
303 replacement
304 });
305 }
306 }
307 return issues;
308}
309
310/**
311 * Check unapproved words and multi-word phrases.
312 */
313function detectUnapprovedWords(sentence) {
314 const issues = [];
315 const lowerSentence = ` ${sentence.toLowerCase()} `;
316
317 // Check multi-word phrases and entries with punctuation ("and/or") first
318 for (const [phrase, replacement] of Object.entries(UNAPPROVED_WORDS)) {
319 if (/[^a-z]/.test(phrase)) {
320 const regex = new RegExp(`\\b${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
321 let match;
322 while ((match = regex.exec(sentence)) !== null) {
323 issues.push({
324 word: match[0],
325 replacement,
326 rule: 'STE 1.1: Approved Words',
327 explanation: `"${match[0]}" is unapproved in ASD-STE100. Use "${replacement}" instead.`
328 });
329 }
330 }
331 }
332
333 // Check single words
334 const words = tokenizeWords(sentence);
335 for (const word of words) {
336 const wLower = word.toLowerCase();
337 // Object.hasOwn: a word such as "constructor" must not match the built-in properties of an object
338 if (Object.hasOwn(UNAPPROVED_WORDS, wLower) && !/[^a-z]/.test(wLower)) {
339 issues.push({
340 word,
341 replacement: UNAPPROVED_WORDS[wLower],
342 rule: 'STE 1.1: Approved Words',
343 explanation: `"${word}" is unapproved in ASD-STE100. Use "${UNAPPROVED_WORDS[wLower]}" instead.`
344 });
345 }
346 }
347
348 return issues;
349}
350
351/**
352 * Detect excessive noun clusters (4 or more nouns without prepositions).
353 * Simplified heuristic checking consecutive capitalized or noun-like words.
354 */
355function detectNounClusters(words) {
356 const issues = [];
357 // Exclude common grammatical function words
358 const nonNouns = new Set([
359 'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'of', 'from',
360 'into', 'onto', 'over', 'under', 'up', 'down', 'out', 'off', 'about', 'through',
361 'between', 'before', 'after', 'during', 'until', 'while', 'because', 'than', 'as',
362 'and', 'or', 'but', 'so', 'not', 'no', 'all', 'each', 'every', 'some', 'any',
363 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'has', 'have', 'had',
364 'do', 'does', 'did', 'will', 'must', 'can', 'may',
365 'if', 'when', 'then', 'now', 'there', 'here', 'also', 'only', 'very',
366 'that', 'which', 'who', 'this', 'these', 'those',
367 'it', 'its', 'they', 'them', 'their', 'you', 'your', 'we', 'our', 'he', 'she', 'his', 'her'
368 ]);
369
370 let currentCluster = [];
371 for (const word of words) {
372 const clean = word.toLowerCase();
373 // Adverbs ("-ly"), past-tense verbs ("-ed") and numbers also break a noun cluster
374 const isPastVerb = clean.endsWith('ed') && clean.length > 4 && !EED_NOUNS.has(clean);
375 if (!nonNouns.has(clean) && clean.length > 1 && !/^\d+$/.test(clean) && !clean.endsWith('ly') && !isPastVerb) {
376 currentCluster.push(word);
377 } else {
378 if (currentCluster.length >= 4) {
379 issues.push({
380 cluster: currentCluster.join(' '),
381 length: currentCluster.length,
382 rule: 'STE 2.1: Multi-Word Nouns',
383 suggestion: `Noun cluster has ${currentCluster.length} words (max allowed is 3). Insert prepositions (e.g. "of", "for") to separate.`
384 });
385 }
386 currentCluster = [];
387 }
388 }
389 if (currentCluster.length >= 4) {
390 issues.push({
391 cluster: currentCluster.join(' '),
392 length: currentCluster.length,
393 rule: 'STE 2.1: Multi-Word Nouns',
394 suggestion: `Noun cluster has ${currentCluster.length} words (max allowed is 3). Insert prepositions to separate.`
395 });
396 }
397 return issues;
398}
399
400/**
401 * Validate a text against ASD-STE100 guidelines.
402 *
403 * @param {string} text - Input text
404 * @param {object} options - { mode: 'strict' | 'pragmatic', sentenceType: 'procedural' | 'descriptive' }
405 */
406function validateText(text, options = {}) {
407 const mode = options.mode || 'pragmatic'; // default to Karpathy's 80% pragmatic mode
408 const isStrict = mode === 'strict';
409 // Strict: 20 procedural / 25 descriptive. Pragmatic (80%): hard cap of 25 for all sentences.
410 const maxWordsProcedural = isStrict ? 20 : 25;
411 const maxWordsDescriptive = 25;
412
413 const sentences = splitSentences(text);
414 const totalSentences = sentences.length;
415 let totalWords = 0;
416 const sentenceReports = [];
417 let totalIssues = 0;
418
419 sentences.forEach((sentence, index) => {
420 const words = tokenizeWords(sentence);
421 const wordCount = words.length;
422 totalWords += wordCount;
423
424 // Heuristic: imperative starts often indicate procedural
425 const isProcedural = /^(click|press|turn|open|close|remove|install|check|start|stop|connect|disconnect|run|set|make|do)\b/i.test(words[0] || '');
426 const limit = isProcedural ? maxWordsProcedural : maxWordsDescriptive;
427
428 const issues = [];
429
430 // 1. Sentence length check
431 if (wordCount > limit) {
432 issues.push({
433 type: 'SENTENCE_LENGTH',
434 severity: 'warning',
435 rule: isProcedural ? 'STE 5.1: Sentence Length (Procedures)' : 'STE 6: Sentence Length (Descriptions)',
436 message: `Sentence has ${wordCount} words. Maximum allowed for ${isProcedural ? 'procedural' : 'descriptive'} text is ${limit} words.`,
437 suggestion: 'Split into two or more short sentences.'
438 });
439 }
440
441 // 2. Passive voice check. STE 3.6 permits the passive in descriptive text only when the agent is unknown.
442 const passiveIssues = detectPassiveVoice(words);
443 const namesAgent = /\bby\b/i.test(sentence);
444 for (const p of passiveIssues) {
445 const permitted = !isProcedural && !namesAgent;
446 issues.push({
447 type: 'PASSIVE_VOICE',
448 severity: permitted ? 'info' : (isStrict ? 'error' : 'warning'),
449 permitted,
450 rule: 'STE 3.6: Active Voice',
451 message: permitted
452 ? `Passive construction: "${p.phrase}". Permitted in descriptive text only if the agent is unknown.`
453 : `Passive construction detected: "${p.phrase}".`,
454 suggestion: p.suggestion
455 });
456 }
457
458 // 2b. Punctuation, contractions, Latin abbreviations and phrasal verbs
459 for (const s of detectStyleIssues(sentence, isStrict)) {
460 issues.push(s);
461 }
462
463 // 3. Unapproved words
464 const unapproved = detectUnapprovedWords(sentence);
465 for (const u of unapproved) {
466 // In pragmatic mode, some softer words like 'should' or 'etc' might be tolerated if user asks, but standard replacements are always helpful
467 issues.push({
468 type: 'UNAPPROVED_WORD',
469 severity: isStrict ? 'error' : 'info',
470 rule: u.rule,
471 message: u.explanation,
472 word: u.word,
473 replacement: u.replacement
474 });
475 }
476
477 // 4. Verb tenses (strict mode only: pragmatic mode tolerates progressive and perfect forms)
478 if (isStrict) {
479 for (const t of detectUnapprovedTenses(words)) {
480 issues.push({
481 type: 'VERB_TENSE',
482 severity: 'warning',
483 rule: 'STE 3.1: Verb Forms',
484 message: `Unapproved ${t.tense} tense: "${t.phrase}".`,
485 suggestion: t.suggestion
486 });
487 }
488 }
489
490 // 5. Noun clusters
491 const nounClusters = detectNounClusters(words);
492 for (const nc of nounClusters) {
493 issues.push({
494 type: 'NOUN_CLUSTER',
495 severity: 'warning',
496 rule: nc.rule,
497 message: nc.suggestion,
498 cluster: nc.cluster
499 });
500 }
501
502 // Permitted constructions are reported for review but do not lower the score
503 totalIssues += issues.filter(i => !i.permitted).length;
504
505 sentenceReports.push({
506 index: index + 1,
507 text: sentence,
508 wordCount,
509 isProcedural,
510 issues
511 });
512 });
513
514 // Paragraph length (Section 6): one topic and maximum 6 sentences per paragraph
515 let offset = 0;
516 for (const paragraph of text.split(/\n\s*\n/)) {
517 const count = splitSentences(paragraph).length;
518 if (count > 6 && sentenceReports[offset + count - 1]) {
519 sentenceReports[offset + count - 1].issues.push({
520 type: 'PARAGRAPH_LENGTH',
521 severity: isStrict ? 'warning' : 'info',
522 rule: 'STE 6: Paragraph Length',
523 message: `Paragraph has ${count} sentences. Maximum is 6 sentences per paragraph.`,
524 suggestion: 'Split the paragraph. Keep one topic in each paragraph.'
525 });
526 totalIssues += 1;
527 }
528 offset += count;
529 }
530
531 // Calculate compliance score (0 - 100)
532 const penalty = totalIssues * 8;
533 const score = Math.max(0, Math.min(100, Math.round(100 - (totalSentences > 0 ? (penalty / totalSentences) : 0))));
534
535 return {
536 mode,
537 score,
538 totalSentences,
539 totalWords,
540 averageWordsPerSentence: totalSentences > 0 ? +(totalWords / totalSentences).toFixed(1) : 0,
541 totalIssues,
542 sentences: sentenceReports
543 };
544}
545
546/**
547 * Generate a comprehensive prompt instruction for LLMs to generate ASD-STE100 output.
548 *
549 * @param {object} config - { mode: 'strict' | 'pragmatic', targetFormat: 'text' | 'diagram' | 'html' }
550 */
551function buildSystemPrompt(config = {}) {
552 const mode = config.mode || 'pragmatic';
553 const targetFormat = config.targetFormat || 'text';
554
555 if (mode === 'strict') {
556 return `You must write all explanations in strict ASD-STE100 (Simplified Technical English, Issue 9).
557Follow these exact constraints:
5581. WORD CHOICE:
559 - Use only approved words with their approved meanings.
560 - Do NOT use: "utilize" (use "use"), "terminate" (use "stop"), "initiate" (use "start"), "modify" (use "change"), "prior to" (use "before"), "as well as" (use "and").
561 - Eliminate vague words: "adequate", "properly", "etc.", "and/or".
5622. SENTENCE LENGTH:
563 - Maximum 20 words for procedural instructions.
564 - Maximum 25 words for descriptive/explanatory sentences.
5653. SENTENCE STRUCTURE & VOICE:
566 - Write ONE thought per sentence.
567 - Use the ACTIVE VOICE (for example, write "The server sends the request" NOT "The request is sent by the server"). In descriptive text, use the passive voice only when the agent is unknown. In procedures, always use the active voice.
568 - For instructions, use the imperative mood ("Push the button", "Open the file").
5694. NOUN CLUSTERS:
570 - Do NOT use more than 3 consecutive nouns. Separate long noun chains using prepositions.
5715. VERBS:
572 - Use only: infinitive, imperative, simple present, simple past, simple future ("will"), and the past participle as an adjective.
573 - Do NOT use "-ing" forms (except in technical nouns), perfect tenses, or phrasal verbs ("set up", "carry out").
5746. CLARITY AND PUNCTUATION:
575 - Avoid complex clauses. Use bullet lists or tables for steps or multi-item data.
576 - Do not omit articles or "that". Do not use contractions, semicolons, or Latin abbreviations (e.g., i.e., etc.).
577 - Write a maximum of 6 sentences per paragraph, with one topic in each paragraph.
5787. SAFETY:
579 - For risks, write "WARNING:" or "CAUTION:", then a clear command, then a short explanation of the risk.`;
580 }
581
582 // Karpathy 80% Pragmatic Mode
583 return `You must explain this using the "80% ASD-STE100" writing style (Simplified Technical English relaxed for high readability and technical concepts, as recommended by Andrej Karpathy).
584
585Key constraints:
5861. CLEAN, DIRECT SENTENCES:
587 - Keep sentences short and punchy (target 15-22 words, absolute maximum 25 words).
588 - One clear idea per sentence.
5892. ACTIVE VOICE:
590 - Always state who or what performs the action. Avoid passive constructions.
5913. CLEAR VOCABULARY:
592 - Prefer simple, unambiguous verbs ("use" instead of "utilize", "stop" instead of "terminate", "start" instead of "commence/initiate", "before" instead of "prior to").
593 - Strip out fluff, filler, and bureaucratic hedges ("it should be noted that", "in order to", "etc.").
5944. COGNITIVE SPEED:
595 - Structure explanations so readers can scan and parse them instantly.
596 - Limit noun chains to 3 words max. Break them up with prepositions.
597 - Use bold lead-ins and structured bullet points for multi-step logic.
598${targetFormat === 'diagram' ? '5. DIAGRAMS: Complement the text with a clear, concise Mermaid diagram.' : ''}
599${targetFormat === 'html' ? '5. WEB PAGE: Format the output as a clean, responsive, interactive single-file HTML component.' : ''}`;
600}
601
602export {
603 UNAPPROVED_WORDS,
604 splitSentences,
605 tokenizeWords,
606 detectPassiveVoice,
607 detectUnapprovedWords,
608 detectNounClusters,
609 detectUnapprovedTenses,
610 detectStyleIssues,
611 validateText,
612 buildSystemPrompt
613};
614
615export default {
616 UNAPPROVED_WORDS,
617 splitSentences,
618 tokenizeWords,
619 detectPassiveVoice,
620 detectUnapprovedWords,
621 detectNounClusters,
622 detectUnapprovedTenses,
623 detectStyleIssues,
624 validateText,
625 buildSystemPrompt
626};
627hooks/pane.jsx 236 lines1/**
2 * STE pane: the last answer as an ASD-STE100 document, docked beside the transcript.
3 *
4 * v view the answer rewritten as an ASD-STE100 document, laid out as a manual page
5 * o original the answer as Claude wrote it
6 * c check the score and the findings of the text in view
7 * r rewrite rewrite the answer again
8 * s session the STE record of the whole session; g makes it (on request only, as it uses tokens)
9 * x close
10 */
11
12import { validateText } from '../lib/ste-engine.js';
13import { clip, findingRows, findingsOf, lengthRows, proseOf, widthOf } from './sheet.jsx';
14import { blocksOf, manualRows, titleOf, viewRows } from './view.jsx';
15
16export const PANE_ID = 'ste-sheet';
17
18const scoreColor = (score) => (score >= 90 ? 'green' : score >= 70 ? 'yellow' : 'red');
19
20function checkRows(els, report, limitOf, columns) {
21 const { h, Text } = els;
22 const filled = Math.round((report.score / 100) * columns);
23 const over = report.sentences.filter(s => s.wordCount > limitOf(s)).length;
24 return [
25 <Text key="score">
26 <Text dimColor>score </Text>
27 <Text bold color={scoreColor(report.score)}>{`${report.score}/100`}</Text>
28 </Text>,
29 <Text key="gauge">
30 <Text color={scoreColor(report.score)}>{'█'.repeat(filled)}</Text>
31 <Text dimColor>{'░'.repeat(columns - filled)}</Text>
32 </Text>,
33 <Text key="stats" dimColor>{clip(`${report.totalSentences} sentences · avg ${report.averageWordsPerSentence} words · ${report.totalIssues} issues`, columns)}</Text>,
34 <Text key="gap1"> </Text>,
35 <Text key="len" bold>{'Longest sentences '}<Text dimColor>{over ? `${over} over limit` : 'all within limit'}</Text></Text>,
36 ...lengthRows(els, report, limitOf, Math.max(8, columns - 14)),
37 <Text key="gap2"> </Text>,
38 <Text key="find" bold>Findings</Text>,
39 ...findingRows(els, findingsOf(report), columns, 30),
40 ];
41}
42
43// What one text measures for the change summary
44export function statsOf(text, mode, limitOf) {
45 const prose = proseOf(text);
46 const report = prose ? validateText(prose, { mode }) : { sentences: [], averageWordsPerSentence: 0 };
47 const count = (type) => report.sentences.reduce((n, s) => n + s.issues.filter(i => i.type === type && !i.permitted).length, 0);
48 return {
49 sentences: report.sentences.length,
50 avgWords: Math.round(report.averageWordsPerSentence),
51 tooLong: report.sentences.filter(s => s.wordCount > limitOf(s)).length,
52 tables: blocksOf(text).filter(b => b.kind === 'table').length,
53 phrasal: count('PHRASAL_VERB'),
54 passive: count('PASSIVE_VOICE'),
55 unapproved: count('UNAPPROVED_WORD'),
56 };
57}
58
59// One line that says what the STE rewrite changed, for example "avg words 19 → 7"
60function changeRows(els, before, after, columns) {
61 const { h, Text } = els;
62 const items = [
63 ['sentences', before.sentences, after.sentences],
64 ['avg words', before.avgWords, after.avgWords],
65 ['too long', before.tooLong, after.tooLong],
66 ['tables', before.tables, after.tables],
67 ['phrasal verbs', before.phrasal, after.phrasal],
68 ['passive', before.passive, after.passive],
69 ['unapproved words', before.unapproved, after.unapproved],
70 ].filter(([, a, b]) => a !== b);
71 if (items.length === 0) {
72 return [<Text key="chg-none" dimColor>Original → STE: no measured change</Text>];
73 }
74 const parts = items.map(([label, a, b], n) => (
75 <Text key={`chg-${n}`}>
76 {n > 0 ? <Text dimColor> · </Text> : null}
77 <Text dimColor>{`${label} `}</Text>
78 <Text>{`${a} → `}</Text>
79 <Text color={label === 'sentences' ? undefined : b < a ? 'green' : 'yellow'}>{String(b)}</Text>
80 </Text>
81 ));
82 return [<Text key="chg"><Text dimColor>Original → STE: </Text>{parts}</Text>];
83}
84
85// A row of a text grid: each cell is [label, value], drawn between │ marks to the given widths
86function gridRow({ h, Text }, key, cells, widths) {
87 const parts = [<Text key="l" dimColor>│</Text>];
88 cells.forEach(([label, value, style = {}], i) => {
89 const room = widths[i] - 2;
90 const lab = label ? `${label} ` : '';
91 const val = clip(String(value), Math.max(1, room - lab.length));
92 parts.push(<Text key={`c${i}`}>{' '}<Text dimColor>{lab}</Text><Text {...style}>{val}</Text>{' '.repeat(Math.max(0, room - lab.length - widthOf(val)))}{' '}</Text>);
93 parts.push(<Text key={`s${i}`} dimColor>│</Text>);
94 });
95 return <Text key={key} wrap="truncate-end">{parts}</Text>;
96}
97
98// A border line of a text grid: left, cross and right marks, with ─ to the given widths
99const gridLine = ({ h, Text }, key, widths, [left, cross, right]) => (
100 <Text key={key} dimColor wrap="truncate-end">{left + widths.map(w => '─'.repeat(w)).join(cross) + right}</Text>
101);
102
103// Split `total` cells into widths for n columns, with the n + 1 border marks
104function splitWidths(total, shares) {
105 const room = total - shares.length - 1;
106 const sum = shares.reduce((a, b) => a + b, 0);
107 const widths = shares.map(x => Math.max(4, Math.floor((room * x) / sum)));
108 widths[widths.length - 1] += room - widths.reduce((a, b) => a + b, 0);
109 return widths;
110}
111
112/**
113 * Draw the pane as a page of a maintenance manual:
114 * title block ASD-STE100, task number, mode, document title, status
115 * body the STE document, numbered 1. / A. / (1) / (a)
116 * info block change summary, score, revision, date, page
117 * Below 50 cells the page has no outer frame, so the text keeps its width.
118 * @param {object} els - the surface's element table ($.ui.resolve(e)) plus `h`
119 * @param {object} pane - { view, report, tab, mode, modeLabel, limitOf, isRewriting, onTab, onRewrite, onClose }
120 * view: { text, original, source: 'answer' | 'rewrite', isAsked, task, rev, date } | null
121 * @param {number} columns - cells across the pane body
122 */
123export function drawPane(els, pane, columns) {
124 const { h, Box, Text, Button } = els;
125 const { view, report, tab, mode, modeLabel, limitOf, isRewriting, record, recordReport, isRecording, onTab, onRewrite, onRecord, onClose } = pane;
126 const width = Math.max(20, columns);
127 const isFramed = width >= 50;
128 const inner = width;
129 // The body sits inside one cell of padding on each side of a framed page
130 const bodyWidth = isFramed ? width - 2 : width;
131
132 let status;
133 let body = [];
134 let info = [];
135 const isSession = tab === 'session';
136 if (isSession) {
137 if (isRecording) {
138 status = <Text color="yellow">Writing the STE record of this session…</Text>;
139 } else if (record) {
140 status = <Text><Text color="green">Session record</Text><Text dimColor>{record.source === 'lite' ? ' · lite (small model)' : ' · full context'} · g: write again</Text></Text>;
141 } else {
142 status = <Text dimColor>No session record yet. Press g or type /asd session to write one. This uses tokens.</Text>;
143 }
144 if (record) body = manualRows(els, record.text, { mode, limitOf, columns: bodyWidth });
145 } else if (!view) {
146 status = <Text dimColor>The pane shows the next answer as an ASD-STE100 document.</Text>;
147 } else if (tab === 'original') {
148 status = <Text dimColor>Original answer, as Claude wrote it</Text>;
149 body = viewRows(els, view.original, { mode, limitOf, columns: bodyWidth });
150 } else if (isRewriting && view.source !== 'rewrite') {
151 status = <Text color="yellow">Rewriting the answer as an ASD-STE100 document…</Text>;
152 body = [<Text key="wait" dimColor>Press o to read the original answer now.</Text>];
153 } else {
154 status = view.source === 'rewrite'
155 ? <Text color="green">STE version</Text>
156 : <Text><Text color="yellow">Original answer, not rewritten</Text><Text dimColor> · r: rewrite</Text></Text>;
157 body = tab === 'check' && report
158 ? checkRows(els, report, limitOf, bodyWidth)
159 : manualRows(els, view.text, { mode, limitOf, columns: bodyWidth });
160 if (view.source === 'rewrite') {
161 info = changeRows(els, statsOf(view.original, mode, limitOf), statsOf(view.text, mode, limitOf), inner);
162 }
163 }
164
165 // The page shows the session record on the session tab, else the last answer
166 const doc = isSession
167 ? { text: record ? record.text : '', task: `00-02-${String((record && record.rev) || 0).padStart(2, '0')}`, rev: record ? record.rev : 0, date: record && record.date, report: recordReport, fallback: 'Session record' }
168 : { text: view ? view.text : '', task: `00-01-${String((view && view.task) || 0).padStart(2, '0')}`, rev: view ? view.rev || 0 : 0, date: view && view.date, report, fallback: 'Last answer' };
169 const taskNo = doc.task;
170 const title = (titleOf(doc.text) || doc.fallback).toUpperCase();
171 // AMM page blocks: 001 description and operation, 201 maintenance practices (a text with steps)
172 const hasSteps = blocksOf(doc.text).some(b => b.kind === 'step');
173 const block = hasSteps ? ['201', 'MAINTENANCE PRACTICES'] : ['001', 'DESCRIPTION & OPERATION'];
174 const rev = String(doc.rev);
175 const pageReport = doc.report;
176 const score = pageReport ? `${pageReport.score}/100` : '—';
177
178 let header;
179 let footer;
180 if (isFramed) {
181 const top = splitWidths(inner, [3, 1]);
182 const cells = splitWidths(inner, [3, 2, 1, 2]);
183 const foot = splitWidths(inner, [3, 2, 3]);
184 header = [
185 gridLine(els, 'h0', top, ['┌', '┬', '┐']),
186 gridRow(els, 'h1', [['', 'ASD-STE100 STE MAINTENANCE MANUAL', { bold: true }], ['TASK', taskNo, { bold: true }]], top),
187 gridRow(els, 'h2', [['', title, { bold: true, color: 'cyan' }], ['PAGE BLOCK', block[0]]], top),
188 gridLine(els, 'h3', top, ['├', '┴', '┤']),
189 gridRow(els, 'h4', [['', block[1]]], [inner - 2]),
190 gridLine(els, 'h5', cells, ['├', '┬', '┤']),
191 gridRow(els, 'h6', [['EFFECTIVITY', 'ALL'], ['MODE', /STRICT/.test(modeLabel) ? 'STRICT 100%' : '80% STE'], ['REV', rev, { bold: true }], ['DATE', doc.date || '—']], cells),
192 gridLine(els, 'h7', cells, ['└', '┴', '┘']),
193 ];
194 footer = [
195 gridLine(els, 'f0', foot, ['┌', '┬', '┐']),
196 gridRow(els, 'f1', [['', 'STE100 ISSUE 9'], ['SCORE', score, pageReport ? { color: scoreColor(pageReport.score), bold: true } : {}], ['', `${taskNo} PAGE ${block[0]}`]], foot),
197 gridLine(els, 'f2', foot, ['└', '┴', '┘']),
198 ];
199 } else {
200 header = [
201 <Text key="h1"><Text inverse bold> ASD-STE100 </Text><Text bold>{` TASK ${taskNo}`}</Text></Text>,
202 <Text key="h2" bold color="cyan">{title}</Text>,
203 <Text key="h3" dimColor>{`${block[1]} · ${modeLabel} · REV ${rev}`}</Text>,
204 ];
205 footer = [<Text key="f1" dimColor>{`SCORE ${score} · PAGE ${block[0]}`}</Text>];
206 }
207
208 const page = (
209 <Box key="page" flexDirection="column" width={width}>
210 {header}
211 {status}
212 <Text key="gap1"> </Text>
213 <Box flexDirection="column" paddingX={isFramed ? 1 : 0}>{body}</Box>
214 <Text key="gap2"> </Text>
215 {info}
216 {footer}
217 </Box>
218 );
219
220 return (
221 <Box flexDirection="column" width={width}>
222 {page}
223 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
224 <Button key="tab-view" plain hotkey="v" label="view" dimColor={tab !== 'view'} onPress={() => onTab('view')} />
225 <Button key="tab-original" plain hotkey="o" label="original" dimColor={tab !== 'original'} onPress={() => onTab('original')} />
226 <Button key="tab-check" plain hotkey="c" label="check" dimColor={tab !== 'check'} onPress={() => onTab('check')} />
227 <Button key="tab-session" plain hotkey="s" label="session" dimColor={tab !== 'session'} onPress={() => onTab('session')} />
228 {isSession
229 ? <Button key="record" plain hotkey="g" label={record ? 'write again' : 'write record'} dimColor={isRecording} onPress={onRecord} />
230 : <Button key="rewrite" plain hotkey="r" label="rewrite" dimColor={!view || isRewriting} onPress={onRewrite} />}
231 <Button key="close" plain hotkey="x" label="close" role="dismiss" onPress={onClose} />
232 </Box>
233 </Box>
234 );
235}
236hooks/sheet.jsx 130 lines1/**
2 * STE check: the parts of the pane's check tab.
3 *
4 * - the prose of an answer, without code, tables and links
5 * - sentence lengths against the word limit
6 * - findings (unapproved words, passive voice, style)
7 */
8
9const RULE_LABELS = {
10 SENTENCE_LENGTH: 'length',
11 PASSIVE_VOICE: 'passive',
12 UNAPPROVED_WORD: 'word',
13 VERB_TENSE: 'verb form',
14 NOUN_CLUSTER: 'noun cluster',
15 PARAGRAPH_LENGTH: 'paragraph',
16};
17
18// Remove the parts of a markdown answer that are not prose: code, tables, links, list marks
19export function proseOf(answer) {
20 return (answer || '')
21 .replace(/```[\s\S]*?```/g, '\n\n')
22 .replace(/`[^`\n]*`/g, 'CODE')
23 .split('\n')
24 .filter(line => !/^\s*\|/.test(line))
25 .map(line => line
26 .replace(/^\s*#{1,6}\s+/, '')
27 .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '')
28 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
29 .replace(/https?:\/\/\S+/g, 'URL')
30 .replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1'))
31 .join('\n')
32 .trim();
33}
34
35// One row per finding, with repeats of the same word or phrase folded together
36export function findingsOf(report) {
37 const rows = new Map();
38 for (const s of report.sentences) {
39 for (const i of s.issues) {
40 if (i.permitted || i.type === 'SENTENCE_LENGTH') continue;
41 const item = i.word || i.cluster || (i.message.match(/"([^"]+)"/) || [])[1] || i.message;
42 const alt = i.replacement || i.suggestion || (i.type === 'NOUN_CLUSTER' ? i.message : '');
43 const id = `${i.type}:${item.toLowerCase()}`;
44 const row = rows.get(id) || { type: i.type, item, alt, severity: i.severity, count: 0 };
45 row.count += 1;
46 rows.set(id, row);
47 }
48 }
49 const rank = { error: 0, warning: 1, info: 2 };
50 return [...rows.values()].sort((a, b) => rank[a.severity] - rank[b.severity] || b.count - a.count);
51}
52
53// Terminal cells of a character: 2 for Hangul, CJK, full-width forms and emoji, else 1
54const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
55const cellsOf = (ch) => (WIDE.test(ch) || ch.codePointAt(0) > 0xffff ? 2 : 1);
56
57// Terminal cells of a text
58export const widthOf = (text) => Array.from(text).reduce((n, ch) => n + cellsOf(ch), 0);
59
60// The text cut to at most n cells, with … when it is cut
61export const clip = (text, n) => {
62 if (widthOf(text) <= n) return text;
63 let out = '';
64 let used = 0;
65 for (const ch of Array.from(text)) {
66 if (used + cellsOf(ch) > n - 1) break;
67 out += ch;
68 used += cellsOf(ch);
69 }
70 return `${out}…`;
71};
72
73// The text filled with spaces to n cells
74export const padTo = (text, n) => text + ' '.repeat(Math.max(0, n - widthOf(text)));
75const pad = (text, n) => padTo(clip(text, n), n);
76
77// The longest sentences as bars, with the limit marked
78export function lengthRows({ h, Text }, report, limitOf, barWidth) {
79 const longest = [...report.sentences].sort((a, b) => b.wordCount - a.wordCount).slice(0, 4)
80 .sort((a, b) => a.index - b.index);
81 const scale = Math.max(30, ...longest.map(s => s.wordCount));
82 return longest.map(s => {
83 const limit = limitOf(s);
84 const filled = Math.round((s.wordCount / scale) * barWidth);
85 const mark = Math.round((limit / scale) * barWidth);
86 const inside = Math.min(filled, mark);
87 const over = Math.max(0, filled - mark);
88 const rest = Math.max(0, barWidth - Math.max(filled, mark));
89 const isOver = s.wordCount > limit;
90 return (
91 <Text key={`s${s.index}`}>
92 <Text dimColor>{pad(`S${s.index}`, 4)}</Text>
93 <Text color="blue">{'█'.repeat(inside)}</Text>
94 <Text dimColor>{'░'.repeat(Math.max(0, mark - inside))}</Text>
95 <Text color={isOver ? 'red' : 'blue'}>│</Text>
96 <Text color="red">{'█'.repeat(over)}</Text>
97 <Text dimColor>{'░'.repeat(rest)}</Text>
98 <Text color={isOver ? 'red' : undefined}>{` ${s.wordCount}/${limit}`}</Text>
99 {isOver ? <Text color="red"> ✕</Text> : null}
100 </Text>
101 );
102 });
103}
104
105// A dictionary-style table of findings
106export function findingRows({ h, Text }, findings, width, limit = 5) {
107 if (findings.length === 0) {
108 return [<Text key="none" color="green">✓ No unapproved words, passive voice or style issues.</Text>];
109 }
110 const itemWidth = Math.min(22, Math.max(10, Math.floor(width * 0.32)));
111 const kindWidth = 12;
112 const altWidth = Math.max(8, width - itemWidth - kindWidth - 6);
113 const shown = findings.slice(0, limit);
114 const rows = [
115 <Text key="head" dimColor>{` ${pad('Not approved', itemWidth)} ${pad('Rule', kindWidth)} Use instead`}</Text>,
116 ...shown.map((f, n) => (
117 <Text key={`f${n}`}>
118 <Text color={f.severity === 'info' ? 'yellow' : 'red'}>{f.severity === 'info' ? '! ' : '✕ '}</Text>
119 <Text color={f.severity === 'info' ? undefined : 'red'}>{pad(f.item + (f.count > 1 ? ` ×${f.count}` : ''), itemWidth)}</Text>
120 <Text dimColor>{` ${pad(RULE_LABELS[f.type] || f.type, kindWidth)} `}</Text>
121 <Text color="blue">{clip(f.alt, altWidth)}</Text>
122 </Text>
123 )),
124 ];
125 if (findings.length > shown.length) {
126 rows.push(<Text key="more" dimColor>{` +${findings.length - shown.length} more · /asd check <text> for the full report`}</Text>);
127 }
128 return rows;
129}
130hooks/view.jsx 363 lines1/**
2 * STE view: an answer laid out as an ASD-STE100 document.
3 *
4 * - Headings become section titles.
5 * - Descriptive text shows one sentence on each line.
6 * - Numbered lines become procedure steps.
7 * - WARNING, CAUTION and NOTE lines become signal blocks (STE Section 7).
8 * - Unapproved words show in red with the approved word after them.
9 * - A sentence over the word limit shows its word count in red.
10 */
11
12import { validateText } from '../lib/ste-engine.js';
13import { padTo, widthOf } from './sheet.jsx';
14
15// Words that ask for an ASD-STE100 answer
16export const STE_REQUEST = /asd[- ]?ste|ste[- ]?100|simplified technical english|\bSTE\b|karpathy/i;
17
18const SIGNAL = /^\s*(?:\*\*)?(WARNING|CAUTION|NOTE)(?:\*\*)?\s*:?\s*(?:\*\*)?\s*/i;
19const SIGNAL_COLOR = { WARNING: 'red', CAUTION: 'yellow', NOTE: 'blue' };
20
21const plain = (text) => text
22 .replace(/`([^`]+)`/g, '$1')
23 .replace(/\*\*([^*]+)\*\*/g, '$1')
24 .replace(/(^|\W)[*_]([^*_]+)[*_](?=\W|$)/g, '$1$2')
25 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
26
27/**
28 * Split a markdown answer into STE blocks.
29 * @returns {Array<{kind: string, text?: string, lines?: string[], n?: number, signal?: string}>}
30 */
31export function blocksOf(answer) {
32 const blocks = [];
33 let paragraph = [];
34 let code = null;
35 let lang = '';
36 const flush = () => {
37 if (paragraph.length) blocks.push({ kind: 'text', text: plain(paragraph.join(' ')) });
38 paragraph = [];
39 };
40
41 for (const line of (answer || '').split('\n')) {
42 if (code) {
43 if (/^\s*```/.test(line)) {
44 blocks.push({ kind: 'code', lang, lines: code });
45 code = null;
46 } else {
47 code.push(line);
48 }
49 continue;
50 }
51 if (/^\s*```/.test(line)) {
52 flush();
53 code = [];
54 lang = line.trim().slice(3).trim();
55 continue;
56 }
57 if (!line.trim()) {
58 flush();
59 continue;
60 }
61 const heading = line.match(/^\s*(#{1,6})\s+(.*)$/);
62 if (heading) {
63 flush();
64 blocks.push({ kind: 'title', level: heading[1].length, text: plain(heading[2]) });
65 continue;
66 }
67 if (SIGNAL.test(line)) {
68 flush();
69 const signal = line.match(SIGNAL)[1].toUpperCase();
70 blocks.push({ kind: 'signal', signal, text: plain(line.replace(SIGNAL, '')) });
71 continue;
72 }
73 const step = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
74 if (step) {
75 flush();
76 blocks.push({ kind: 'step', n: Number(step[1]), text: plain(step[2]) });
77 continue;
78 }
79 const item = line.match(/^\s*[-*+]\s+(.*)$/);
80 if (item) {
81 flush();
82 blocks.push({ kind: 'item', text: plain(item[1]) });
83 continue;
84 }
85 if (/^\s*\|/.test(line)) {
86 flush();
87 const last = blocks[blocks.length - 1];
88 if (last && last.kind === 'table') last.lines.push(line.trim());
89 else blocks.push({ kind: 'table', lines: [line.trim()] });
90 continue;
91 }
92 paragraph.push(line.trim());
93 }
94 if (code) blocks.push({ kind: 'code', lang, lines: code });
95 flush();
96 return blocks;
97}
98
99// Words of `text` in lines of at most `width` cells; a longer word is cut
100function wrapCell(text, width) {
101 const lines = [];
102 let line = '';
103 const cut = (word) => {
104 let head = '';
105 for (const ch of Array.from(word)) {
106 if (widthOf(head + ch) > width) break;
107 head += ch;
108 }
109 return head || Array.from(word)[0];
110 };
111 for (let word of text.split(/\s+/).filter(Boolean)) {
112 while (widthOf(word) > width) {
113 if (line) { lines.push(line); line = ''; }
114 const head = cut(word);
115 lines.push(head);
116 word = word.slice(head.length);
117 }
118 if (!line) line = word;
119 else if (widthOf(line) + 1 + widthOf(word) <= width) line += ' ' + word;
120 else { lines.push(line); line = word; }
121 }
122 if (line || lines.length === 0) lines.push(line);
123 return lines;
124}
125
126// Column widths that fit `room` cells: a narrow column keeps its width, a wide one shares the rest
127function fitColumns(natural, room) {
128 const widths = natural.map(() => 0);
129 let left = room;
130 let open = natural.map((_, i) => i);
131 while (open.length) {
132 const share = Math.floor(left / open.length);
133 const small = open.filter(i => natural[i] <= share);
134 if (small.length === 0) {
135 open.forEach((i, n) => { widths[i] = Math.max(3, share + (n < left - share * open.length ? 1 : 0)); });
136 break;
137 }
138 for (const i of small) { widths[i] = natural[i]; left -= natural[i]; }
139 open = open.filter(i => !small.includes(i));
140 }
141 return widths;
142}
143
144// A markdown table drawn as a text grid that fits `width` cells, with wrapped cells and a bold header
145function tableRows(els, key, lines, width) {
146 const { h, Text } = els;
147 const rows = lines
148 .filter(line => !/^\s*\|?[\s:|-]+\|?\s*$/.test(line))
149 .map(line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => plain(c.trim())));
150 const n = Math.max(...rows.map(r => r.length));
151 rows.forEach(r => { while (r.length < n) r.push(''); });
152 const natural = Array.from({ length: n }, (_, i) => Math.max(3, ...rows.map(r => widthOf(r[i]))));
153 const widths = fitColumns(natural, Math.max(n * 3, width - (n + 1) - 2 * n));
154 const line = (k, [l, c, r]) => <Text key={k} dimColor>{l + widths.map(w => '─'.repeat(w + 2)).join(c) + r}</Text>;
155 const out = [line(`${key}-top`, ['┌', '┬', '┐'])];
156 rows.forEach((row, ri) => {
157 const cells = row.map((c, i) => wrapCell(c, widths[i]));
158 const height = Math.max(...cells.map(c => c.length));
159 for (let li = 0; li < height; li++) {
160 const parts = [<Text key="b0" dimColor>│</Text>];
161 cells.forEach((c, i) => {
162 const text = padTo(c[li] || '', widths[i]);
163 parts.push(<Text key={`c${i}`} bold={ri === 0} color={ri === 0 ? 'cyan' : undefined}>{` ${text} `}</Text>);
164 parts.push(<Text key={`b${i + 1}`} dimColor>│</Text>);
165 });
166 out.push(<Text key={`${key}-${ri}-${li}`} wrap="truncate-end">{parts}</Text>);
167 }
168 if (ri < rows.length - 1) out.push(line(`${key}-sep${ri}`, ri === 0 ? ['╞', '╪', '╡'] : ['├', '┼', '┤']));
169 });
170 out.push(line(`${key}-bottom`, ['└', '┴', '┘']));
171 return out;
172}
173
174// A code block drawn as markdown the way an assistant reply draws it; a table as a text grid
175function markdownBlock(els, key, b, width = 80) {
176 const { h, Text, Markdown } = els;
177 if (b.kind === 'table') return tableRows(els, key, b.lines, width);
178 const text = b.kind === 'code'
179 ? ['```' + (b.lang || ''), ...b.lines, '```'].join('\n')
180 : b.lines.join('\n');
181 if (!Markdown) return b.lines.map((line, i) => <Text key={`${key}-${i}`} dimColor wrap="truncate-end">{line}</Text>);
182 return [<Markdown key={key} text={text.slice(0, 10000)} />];
183}
184
185// A heading without the number the model wrote, so the page numbers it once ("2. State" -> "State")
186const unnumbered = (name) => name.replace(/^\s*(?:\d+(?:\.\d+)*|[A-Za-z])[.)]\s+/, '');
187
188// One sentence with its unapproved words marked, and its word count when it is too long
189function sentenceLine({ h, Text }, report, key, prefix, limitOf) {
190 const s = report.sentences[0];
191 if (!s) return null;
192 const words = s.issues.filter(i => i.type === 'UNAPPROVED_WORD' && i.word);
193 const parts = [];
194 let rest = s.text;
195 for (const w of words) {
196 const at = rest.toLowerCase().indexOf(w.word.toLowerCase());
197 if (at < 0) continue;
198 parts.push(rest.slice(0, at));
199 parts.push(
200 <Text key={`${key}-${parts.length}`}>
201 <Text color="red" underline>{rest.slice(at, at + w.word.length)}</Text>
202 {w.replacement ? <Text color="blue">{` →${w.replacement.split(/[,(]/)[0].trim().toUpperCase()}`}</Text> : null}
203 </Text>
204 );
205 rest = rest.slice(at + w.word.length);
206 }
207 parts.push(rest);
208 const limit = limitOf(s);
209 return (
210 <Text key={key}>
211 {prefix}
212 {parts}
213 {s.wordCount > limit ? <Text color="red">{` ${s.wordCount}/${limit} words`}</Text> : null}
214 </Text>
215 );
216}
217
218function sentencesOf(text, mode) {
219 return validateText(text, { mode }).sentences.map(s => ({ ...s, report: { sentences: [s] } }));
220}
221
222/**
223 * Draw an answer as an STE document.
224 * @param {object} els - the surface's element table plus `h`
225 * @param {string} text - the answer
226 * @param {object} opts - { mode, limitOf, columns }
227 */
228export function viewRows(els, text, { mode, limitOf, columns }) {
229 const { h, Box, Text } = els;
230 const rows = [];
231 let n = 0;
232 const key = () => `v${n++}`;
233
234 for (const b of blocksOf(text)) {
235 if (b.kind === 'title') {
236 if (rows.length) rows.push(<Text key={key()}> </Text>);
237 rows.push(<Text key={key()} bold>{b.text}</Text>);
238 continue;
239 }
240 if (b.kind === 'signal') {
241 rows.push(
242 <Box key={key()} flexDirection="column" borderStyle="single" borderColor={SIGNAL_COLOR[b.signal]} width={Math.max(20, columns)}>
243 <Text bold color={SIGNAL_COLOR[b.signal]}>{b.signal}</Text>
244 {sentencesOf(b.text, mode).map(s => sentenceLine(els, s.report, key(), '', limitOf))}
245 </Box>
246 );
247 continue;
248 }
249 if (b.kind === 'step' || b.kind === 'item') {
250 const mark = b.kind === 'step' ? `${b.n}. ` : '• ';
251 sentencesOf(b.text, mode).forEach((s, i) => {
252 rows.push(sentenceLine(els, s.report, key(), <Text color="blue">{i === 0 ? mark : ' '.repeat(mark.length)}</Text>, limitOf));
253 });
254 continue;
255 }
256 if (b.kind === 'code' || b.kind === 'table') {
257 rows.push(...markdownBlock(els, key(), b, columns));
258 continue;
259 }
260 // Descriptive text: one sentence on each line, then a gap after the paragraph
261 for (const s of sentencesOf(b.text, mode)) rows.push(sentenceLine(els, s.report, key(), '', limitOf));
262 rows.push(<Text key={key()}> </Text>);
263 }
264 return rows.filter(Boolean);
265}
266
267// The document title: the first level-1 heading, or the first heading
268export function titleOf(text) {
269 const titles = blocksOf(text).filter(b => b.kind === 'title');
270 const first = titles.find(b => b.level === 1) || titles[0];
271 return first ? unnumbered(first.text) : '';
272}
273
274const letter = (i) => String.fromCharCode(65 + (i % 26));
275const HANGUL = /[\uac00-\ud7a3]/;
276
277/**
278 * Draw an STE document as a page of a maintenance manual.
279 * 1. SECTION a heading
280 * A. Sentence one sentence of descriptive text
281 * (1) Step a numbered procedure step
282 * (a) Item a list item
283 * WARNING, CAUTION and NOTE are boxes with the signal word in the middle of the top line.
284 * @param {object} els - the surface's element table plus `h`
285 * @param {string} text - the STE document
286 * @param {object} opts - { mode, limitOf, columns }
287 */
288export function manualRows(els, text, { mode, limitOf, columns }) {
289 const { h, Box, Text } = els;
290 const rows = [];
291 let n = 0;
292 const key = () => `m${n++}`;
293 const title = titleOf(text);
294 let section = 0;
295 let sentence = 0;
296 let item = 0;
297 let titleSkipped = false;
298
299 const startSection = (name) => {
300 section += 1;
301 sentence = 0;
302 item = 0;
303 if (rows.length) rows.push(<Text key={key()}> </Text>);
304 rows.push(<Text key={key()} bold>{`${section}. ${unnumbered(name).toUpperCase()}`}</Text>);
305 };
306 // A numbered line whose wrapped lines start under the text, not under the number
307 const numbered = (indent, mark, body, color) => (
308 <Box key={key()} flexDirection="row">
309 <Box width={indent + mark.length} flexShrink={0}>
310 <Text color={color}>{' '.repeat(indent) + mark}</Text>
311 </Box>
312 <Box flexGrow={1} flexShrink={1}>{body}</Box>
313 </Box>
314 );
315
316 for (const b of blocksOf(text)) {
317 if (b.kind === 'title') {
318 if (!titleSkipped && unnumbered(b.text) === title) {
319 titleSkipped = true;
320 continue;
321 }
322 startSection(b.text);
323 continue;
324 }
325 if (section === 0 && b.kind !== 'code' && b.kind !== 'table') startSection(HANGUL.test(text) ? '개요' : 'General');
326 if (b.kind === 'signal') {
327 const color = SIGNAL_COLOR[b.signal];
328 rows.push(
329 <Box key={key()} flexDirection="column" borderStyle="single" borderColor={color} width={Math.max(20, columns)} paddingX={1}>
330 <Box justifyContent="center"><Text bold inverse color={color}>{` ${b.signal} `}</Text></Box>
331 {sentencesOf(b.text, mode).map(s => sentenceLine(els, s.report, key(), '', limitOf))}
332 </Box>
333 );
334 continue;
335 }
336 if (b.kind === 'step') {
337 item = 0;
338 sentencesOf(b.text, mode).forEach((s, i) => {
339 const mark = i === 0 ? `(${b.n}) ` : ' '.repeat(`(${b.n}) `.length);
340 rows.push(numbered(3, mark, sentenceLine(els, s.report, key(), '', limitOf), 'blue'));
341 });
342 continue;
343 }
344 if (b.kind === 'item') {
345 const mark = `(${String.fromCharCode(97 + (item % 26))}) `;
346 item += 1;
347 rows.push(numbered(3, mark, sentenceLine(els, { sentences: sentencesOf(b.text, mode).slice(0, 1) }, key(), '', limitOf), 'blue'));
348 for (const s of sentencesOf(b.text, mode).slice(1)) rows.push(numbered(3, ' '.repeat(mark.length), sentenceLine(els, s.report, key(), '', limitOf)));
349 continue;
350 }
351 if (b.kind === 'code' || b.kind === 'table') {
352 rows.push(<Box key={key()} flexDirection="column" paddingLeft={3}>{markdownBlock(els, key(), b, columns - 3)}</Box>);
353 continue;
354 }
355 item = 0;
356 for (const s of sentencesOf(b.text, mode)) {
357 rows.push(numbered(3, `${letter(sentence)}. `, sentenceLine(els, s.report, key(), '', limitOf)));
358 sentence += 1;
359 }
360 }
361 return rows.filter(Boolean);
362}
363types/index.d.ts 22 lines1export type SteIssue = { type: string; severity: string; message: string; permitted?: boolean; word?: string; cluster?: string; replacement?: string; suggestion?: string }
2export type SteSentence = { index: number; text: string; wordCount: number; isProcedural: boolean; issues: SteIssue[] }
3export type SteReport = { mode: string; score: number; totalSentences: number; totalWords: number; averageWordsPerSentence: number; totalIssues: number; sentences: SteSentence[] }
4export type SteRecord = { text: string; source: 'fork' | 'lite'; date: string; rev: number }
5export type SteView = { text: string; original: string; source: 'answer' | 'rewrite'; isAsked: boolean; task: number; rev: number; date: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'asd-ste100': {
10 view: SteView | null
11 report: SteReport | null
12 tab: string
13 isAsked: boolean
14 isRewriting: boolean
15 paneOpen: boolean
16 record: SteRecord | null
17 recordReport: SteReport | null
18 isRecording: boolean
19 }
20 }
21}
22