wellactually

Writing principles

Detectors

A detector decides when the advice applies. It is one TypeScript file, detector.ts, that registers a handler for each event it reacts to.

The shape of a detector#

prefer-union-over-enum/detector.ts
import { onWrite } from "@wellactually/sdk/v4";

// Anchored at the start of the line, so an enum in a comment or a string is not one.
const ENUM = /^\s*(?:export\s+)?(?:declare\s+)?(?:const\s+)?enum\s+[A-Za-z_$]/;

// A declaration file describes someone else's enum. There is nothing to rewrite.
onWrite({
  globs: ["**/*.{ts,tsx,mts,cts}", "!**/*.d.{ts,mts,cts}"],
  *detect(event) {
    for (const written of event.written) {
      if (ENUM.test(written.text)) {
        yield { line: written.line };
      }
    }
  },
});

This is the whole detector of passsy/prefer-union-over-enum.

  • Each handler is a call at the top level of the file: onWrite, onRead, onDelete, onPrompt, onCommand or onOutput. The build loads the file once to learn which handlers it has, so a call inside a function or an if does not count.
  • Each call takes one object. globs says which files a file handler wants, and detect gets the event.
  • The * in front of detect makes it a generator method. It yields one finding for each thing it found, and yields nothing when all is well.
  • A detector can register as many handlers as it needs, also several for one event: one for Dart files and one for pubspec.yaml.

The events#

HandlerRuns whenThe event has
onWriteThe agent wrote or edited a file.file, written
onReadThe agent read a file.file
onPromptYou sent a message, or the agent briefed a subagent.cwd
onCommandThe agent is about to run a shell command.command, cwd
onOutputA shell command ran.command, cwd

Every event also has text, lines and project:

  • text is the file's content on a write or read, the prompt, the command line, or what a command printed.
  • lines is text split into lines. A finding's line counts in there.
  • project is the root of the project, or null. Do not rely on it, see Reading project files.

The detector API reference lists every field.

onWrite: judge what the agent wrote#

event.written holds the lines the agent wrote in this event, each with its line and its text. Code that was already in the file is not in there. Judge those lines, not the whole file: legacy code is not the agent's fault, and an agent told to clean up a whole file wanders off its task.

written.text is the line as it is in the file, comments and strings included. Where a keyword in a comment should not count, skip the line:

ts
for (const written of event.written) {
  if (written.text.trim().startsWith("//")) {
    continue;
  }
  // ...
}

event.file describes the file:

FieldExample
path/Users/you/code/app/lib/src/config_loader.dart, absolute
relativePathlib/src/config_loader.dart, from the project root. Globs match this.
nameconfig_loader.dart
ext.dart, lower case, empty when there is none
content, linesThe whole file after the write
isNewTrue when this write created the file

Globs#

onWrite and onRead take globs, matched against event.file.relativePath.

PatternMatches
**/*.dartEvery Dart file, in any folder
*.dartThe same: a pattern without a slash matches the file name in any folder
lib/**Everything below lib at the project root
**/lib/**/*.dartDart below a lib folder anywhere, as in a monorepo
**/*.{ts,tsx}Either extension
lib/?.dartA one-letter file name in lib
!**/*.g.dartTakes generated files out again

A file is selected when one pattern matches it and no ! pattern does. A handler without globs, or with only ! patterns, runs on every file the ! patterns leave in. The plugin checks the globs before it starts a detector, so narrow globs cost nothing on the files they leave out.

onRead: judge the place, not the author#

The agent did not write a file it read, so do not blame it for the code inside. A read is the moment to say something about where the agent is working.

avoid-clean-architecture/detector.ts
import { onRead, onWrite, type File } from "@wellactually/sdk/v4";

const DART = ["**/*.dart", "!**/*.g.dart"];

// No line is to blame for where a file lives, so the finding points at the file.
onRead({
  globs: DART,
  *detect(event) {
    if (inLayer(event.file)) {
      yield {};
    }
  },
});

onWrite({
  globs: DART,
  *detect(event) {
    if (inLayer(event.file)) {
      yield {};
      return;
    }
    for (const written of event.written) {
      if (/class\s+\w+(?:UseCase|Repository)\b/.test(written.text)) {
        yield { line: written.line };
      }
    }
  },
});

/** Whether a directory of the path betrays a layer-first structure. */
function inLayer(file: File): boolean {
  return ["/usecases/", "/use_cases/", "/repositories/", "/domain/entities/"].some((dir) => `/${file.relativePath}`.includes(dir));
}

This is a shorter version of passsy/avoid-clean-architecture. yield {} in a file handler says the file itself is the problem: its name, its place, or something it lacks. The agent is told the file, and no line stands in for it.

onPrompt: speak before the work starts#

A prompt is your message to the agent, or the brief the agent gives a subagent. It is the cheapest moment for advice, because nothing has been written yet. But a word in a prompt does not mean anything is wrong, so a prompt finding should be a pointer: the summary, not the whole advice.

avoid-rxdart/detector.ts
import { findUp, mentioned, onPrompt } from "@wellactually/sdk/v4";

onPrompt({
  *detect(event) {
    // A prompt says nothing about its language. A pubspec.yaml above the session says it is Dart.
    if (findUp("pubspec.yaml", event.cwd) !== null) {
      yield* mentioned(event, ["rxdart", "BehaviorSubject"]);
    }
  },
});

This is the prompt half of passsy/avoid-rxdart, with the Dart check written out.

  • event.cwd is the folder the session runs in. findUp(name, from) returns the nearest file of that name in that folder or above, or null.
  • mentioned(event, names) returns a pointer finding for the first name the text mentions. It finds a name where it stands alone, in any case and with or without separators: rxdart also finds rx_dart, rx-dart and RxDart. yield* passes on what it returns.
  • Pass the few phrases that can only mean your topic. late is about time in most prompts, late final is about Dart.

onCommand: catch it before it runs#

event.command is the command line the agent is about to run.

no-conventional-commits/detector.ts
import { onCommand } from "@wellactually/sdk/v4";

onCommand({
  *detect(event) {
    const subject = /git\s+commit\b[^\n;|&]*?(?:-m|--message)(?:\s+|=)(["'])([^"'\n]*)/.exec(event.command)?.[2];
    // A subject that leads with feat:, fix(scope):, chore!: and friends.
    if (subject && /^(?:feat|fix|chore|docs|refactor|test|style|perf|ci|build|revert)(?:\([^)]*\))?!?:/.test(subject)) {
      yield { match: subject };
    }
  },
});

A command finding has no line, because a command has no lines to point at. match names the part the advice is about, and the agent is shown it. The real passsy/no-conventional-commits also reads the subject of a heredoc message.

onOutput: react to what a command printed#

event.text is what the command printed, standard output first and then errors, and event.command is the command that printed it. Its findings can name a line of that output.

fix-analyzer-warnings/detector.ts
import { onOutput } from "@wellactually/sdk/v4";

onOutput({
  *detect(event) {
    if (!/\b(?:dart|flutter)\s+analyze\b/.test(event.command)) {
      return;
    }
    // "warning - lib/main.dart:3:8 - Unused import ..." is one finding per warning.
    for (const [index, line] of event.lines.entries()) {
      if (/^\s*warning\s+-\s/.test(line)) {
        yield { line: index + 1, match: "warning" };
      }
    }
  },
});

onDelete and removed lines: react to what the agent took out#

event.removed holds the lines a write removed, each with the line it had before the change. onDelete runs when the agent deleted a file: event.file is the file as it was, and every line of it is in event.removed. So one helper serves both, and a removed line it yields is shown to the agent with its text.

keep-tests/detector.ts
import { onDelete, onWrite, type DeleteEvent, type WriteEvent } from "@wellactually/sdk/v4";

function* removedTests(event: WriteEvent | DeleteEvent) {
  for (const removed of event.removed) {
    if (/\btest\(/.test(removed.text)) {
      yield removed;
    }
  }
}

onWrite({ globs: ["**/*_test.dart"], detect: removedTests });
onDelete({ globs: ["**/*_test.dart"], detect: removedTests });

The agent sees "removed line 3: test('saves', () {});". To name removed lines yourself, yield { line: removed.line, removed: true }. event.removed is empty when the plugin cannot tell what the file was before, as for a new file.

What a detector may import#

  • @wellactually/sdk/v4, the helpers on this page and in the reference.
  • node:fs and node:path, to read project files.
  • Its own relative files, such as ./dart.ts.
  • Packages from npm or a GitHub repository, named in its package.json.

It runs in an isolate with no network, no process and no Node beyond those two modules. It has 100 ms and 32 MB for each event, see Limits.

The v3 in the import path#

v3 is the detector API version the code is written against. A released revision records it, and every later plugin hands that principle the event of version 3, however far the API has moved. Leave the path as the starter wrote it, and your next upload stays on version 3 too. Change it when you want what a newer version offers, and the compiler and your tests tell you what to adapt. All files of a principle import the same version.

Ideas that work#

  • A name in a pubspec or package.json. Fire when the agent adds a dependency you advise against: onWrite with globs: ["**/pubspec.yaml"].
  • A call the agent writes. DateTime.now(), print(, setTimeout(, each on the lines in event.written.
  • A file in the wrong place. yield {} from onWrite when event.file.isNew and the path breaks your layout.
  • A topic in a prompt. mentioned with two or three phrases, behind a findUp that checks the kind of project.
  • A command with a flag you never want. --no-verify, --force on a push, rm -rf outside a build folder.

Each of those fires rarely and means something when it does. A detector that fires on most files is describing the language, and the agent's probe tells you so.

Advice without a detector#

A principle may have no detector at all: a folder with only principle.md, and a package.json if it has tags.

  • It never runs, because nothing decides when the advice applies.
  • It is off on every advisory board, its expert's included, and its switch cannot be turned on.
  • Its page shows the advice, so it stays there to read and to link to.

Use it for advice that needs judgement no detector can make, or for a principle whose detector fired in the wrong places and was taken out. Add a detector.ts with its tests again, and the principle runs from its next revision on.