@drkmttr/adhere 0.18.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The coding agents installed beside adhere, asked one question at a time
3
+ * without a session: `claude -p`, `codex exec`, and `omp -p`. The prompt goes
4
+ * on stdin, the agent may read the repo it runs in but not change it, and
5
+ * what comes back is the text of its last message. Given a JSON schema, the
6
+ * agent is held to it where its CLI can, and the text is that JSON.
7
+ */
8
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { Option, Schema } from "effect";
12
+
13
+ export const AGENTS = ["claude", "codex", "omp"] as const;
14
+ export type Agent = (typeof AGENTS)[number];
15
+
16
+ /** The agents on PATH, in the order adhere prefers them. */
17
+ export const installedAgents = (): ReadonlyArray<Agent> =>
18
+ AGENTS.filter((agent) => Bun.which(agent) !== null);
19
+
20
+ /** `claude -p --output-format json` prints its events, the last its result. */
21
+ const ClaudeResult = Schema.fromJsonString(
22
+ Schema.Array(
23
+ Schema.Struct({
24
+ type: Schema.String,
25
+ is_error: Schema.optionalKey(Schema.Boolean),
26
+ result: Schema.optionalKey(Schema.String),
27
+ structured_output: Schema.optionalKey(Schema.Unknown),
28
+ }),
29
+ ),
30
+ );
31
+
32
+ /** The line of `omp --mode json` that ends an assistant's message; every other line is skipped. */
33
+ const OmpAnswer = Schema.fromJsonString(
34
+ Schema.Struct({
35
+ type: Schema.Literal("message_end"),
36
+ message: Schema.Struct({
37
+ role: Schema.Literal("assistant"),
38
+ content: Schema.Array(
39
+ Schema.Struct({ type: Schema.String, text: Schema.optionalKey(Schema.String) }),
40
+ ),
41
+ }),
42
+ }),
43
+ );
44
+
45
+ /** Runs `command` in `cwd` with `input` on stdin, and returns its stdout once it exits 0. */
46
+ export const run = async (
47
+ command: string,
48
+ args: ReadonlyArray<string>,
49
+ cwd: string,
50
+ input: string,
51
+ ) => {
52
+ const child = Bun.spawn([command, ...args], {
53
+ cwd,
54
+ stdin: new Blob([input]),
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ const [stdout, stderr, code] = await Promise.all([
59
+ new Response(child.stdout).text(),
60
+ new Response(child.stderr).text(),
61
+ child.exited,
62
+ ]);
63
+ if (code !== 0) throw new Error(`${command} exited ${code}: ${stderr.trim().slice(-500)}`);
64
+ return stdout;
65
+ };
66
+
67
+ /** The text of `agent`'s answer to `prompt`, asked in `cwd`; with `schema`, its JSON. */
68
+ export const askAgent = async (
69
+ agent: Agent,
70
+ prompt: string,
71
+ cwd: string,
72
+ schema?: object,
73
+ ): Promise<string> => {
74
+ switch (agent) {
75
+ case "claude": {
76
+ const events = Schema.decodeUnknownSync(ClaudeResult)(
77
+ await run(
78
+ "claude",
79
+ [
80
+ "-p",
81
+ "--output-format=json",
82
+ "--tools=Read,Grep,Glob",
83
+ "--no-session-persistence",
84
+ ...(schema === undefined ? [] : [`--json-schema=${JSON.stringify(schema)}`]),
85
+ ],
86
+ cwd,
87
+ prompt,
88
+ ),
89
+ );
90
+ const result = events.findLast((event) => event.type === "result");
91
+ if (result === undefined || result.is_error === true) {
92
+ throw new Error(`claude did not answer: ${result?.result ?? "no result"}`);
93
+ }
94
+ return schema === undefined
95
+ ? (result.result ?? "")
96
+ : JSON.stringify(result.structured_output);
97
+ }
98
+ case "codex": {
99
+ const dir = await mkdtemp(join(tmpdir(), "adhere-codex-"));
100
+ try {
101
+ const out = join(dir, "answer");
102
+ const schemaFile = join(dir, "schema.json");
103
+ if (schema !== undefined) await writeFile(schemaFile, JSON.stringify(schema));
104
+ await run(
105
+ "codex",
106
+ [
107
+ "exec",
108
+ "--sandbox=read-only",
109
+ "--ephemeral",
110
+ "--skip-git-repo-check",
111
+ "--color=never",
112
+ `--output-last-message=${out}`,
113
+ ...(schema === undefined ? [] : [`--output-schema=${schemaFile}`]),
114
+ "-",
115
+ ],
116
+ cwd,
117
+ prompt,
118
+ );
119
+ return await readFile(out, "utf8");
120
+ } finally {
121
+ await rm(dir, { recursive: true, force: true });
122
+ }
123
+ }
124
+ case "omp": {
125
+ // omp holds no answer to a schema, so the schema goes in the prompt and
126
+ // the JSON is cut out of whatever surrounds it.
127
+ const asked =
128
+ schema === undefined
129
+ ? prompt
130
+ : `${prompt}\n\nReply with only JSON matching this schema:\n${JSON.stringify(schema)}`;
131
+ const lines = (
132
+ await run(
133
+ "omp",
134
+ ["-p", "--mode=json", "--tools=read,grep,glob", "--no-session", "--no-title"],
135
+ cwd,
136
+ asked,
137
+ )
138
+ ).split("\n");
139
+ const answer = lines
140
+ .flatMap((line) => Option.toArray(Schema.decodeUnknownOption(OmpAnswer)(line)))
141
+ .at(-1);
142
+ if (answer === undefined) throw new Error("omp did not answer");
143
+ const text = answer.message.content
144
+ .flatMap((part) => (part.type === "text" && part.text !== undefined ? [part.text] : []))
145
+ .join("");
146
+ return schema === undefined ? text : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
147
+ }
148
+ }
149
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What `adhere rules create` asks the coding agent, and the answers it
3
+ * decodes: the rule's shape, code in the repo for the user to judge, and the
4
+ * rule file itself.
5
+ */
6
+ import { type Agent, askAgent } from "#create/agents.ts";
7
+ import { spinning } from "#create/look.ts";
8
+ import { Effect, Schema } from "effect";
9
+ import skill from "../../skills/adhere/SKILL.md" with { type: "text" };
10
+
11
+ export class RuleNotCreated extends Schema.TaggedError<RuleNotCreated>()("RuleNotCreated", {
12
+ message: Schema.String,
13
+ }) {}
14
+
15
+ export const Shape = Schema.Struct({
16
+ linter: Schema.NullOr(Schema.String),
17
+ scope: Schema.Literals(["project", "org"]),
18
+ scopeReason: Schema.String,
19
+ format: Schema.Literals(["markdown", "typescript"]),
20
+ formatReason: Schema.String,
21
+ typeAware: Schema.Boolean,
22
+ reads: Schema.Array(Schema.Literals(["workspacePackages", "dependents"])),
23
+ contextReason: Schema.String,
24
+ names: Schema.Array(Schema.String),
25
+ });
26
+ export type Shape = typeof Shape.Type;
27
+
28
+ const Candidate = Schema.Struct({
29
+ file: Schema.String,
30
+ startLine: Schema.Int,
31
+ endLine: Schema.Int,
32
+ violation: Schema.Boolean,
33
+ reason: Schema.String,
34
+ });
35
+ export type Candidate = typeof Candidate.Type;
36
+ export const Candidates = Schema.Struct({ candidates: Schema.Array(Candidate) });
37
+
38
+ /** Code from the repo the user has judged: where it is, as the file holds it, and whether it breaks the rule. */
39
+ export interface Reviewed {
40
+ readonly where: string;
41
+ readonly code: string;
42
+ readonly violation: boolean;
43
+ /** Whether the user's call matched the agent's. */
44
+ readonly agreed: boolean;
45
+ }
46
+
47
+ export const shapePrompt = (
48
+ idea: string,
49
+ ) => `You are helping write a rule for adhere, a linter for conventions a normal linter cannot check: a model, Jev, judges each source file against each rule. A rule lives in .adhere/rules/<topic>/<name>/, either in this repository, for its own code, or in the organization's shared rules repository, which every repository installs from.
50
+
51
+ The user wants a rule for: ${idea}
52
+
53
+ Look at this repository as much as you need, then answer:
54
+ - linter: an existing linter rule (ESLint, oxlint, TypeScript, or the like) that already checks this, by name, or null.
55
+ - scope: "project" when the rule is about this repository's own code, "org" when it would hold in any of the organization's repositories.
56
+ - scopeReason: one sentence saying why.
57
+ - format: "markdown" for a RULE.md, the rule's text and examples; nearly every rule is one. "typescript" for a RULE.ts, only when judging a file needs something neither the file nor the shared context below shows, such as a schema or a list another file holds: its appendState hook reads it and adds it to what Jev reads for that rule.
58
+ - formatReason: one sentence saying why.
59
+ - typeAware: true when judging needs the compiler's view of the file: the types of the names it uses, or the declarations of what it uses from other files. It is slower and costs more, so false unless the file cannot show it.
60
+ - reads: the shared context it needs: "workspacePackages", the workspace packages the file imports and their directories, and "dependents", what depends on the file. Empty when the code is enough.
61
+ - contextReason: one sentence saying why typeAware and reads are as you set them.
62
+ - names: three ids for the rule, each "topic/rule-name" in lowercase kebab case. Reuse a topic from .adhere/rules/ where one fits.`;
63
+
64
+ export const examplesPrompt = (
65
+ idea: string,
66
+ id: string,
67
+ ) => `Find code in this repository to test the adhere rule ${id}: ${idea}
68
+
69
+ Give up to six excerpts, a mix of code that breaks the rule and code that follows it; borderline cases are the most useful. Each is the repo-relative file, the excerpt's first and last line numbers, at most 15 lines apart, violation: whether it breaks the rule, and reason: one sentence saying why. Use only code that exists, and give none when there is none.`;
70
+
71
+ const contextOf = (shape: Shape) =>
72
+ [
73
+ "`code`",
74
+ ...shape.reads.map((read) => `\`${read}\``),
75
+ ...(shape.typeAware ? ["`symbols`", "`references`"] : []),
76
+ ].join(", ");
77
+
78
+ const typescriptFormat = (shape: Shape) =>
79
+ `Write it as a RULE.ts, because ${shape.formatReason} It imports defineRule from "@drkmttr/adhere" and default-exports defineRule({ description, details, must, never, appendState }). appendState(state, file, Bun) runs before each request for the rule: file is { path, contents }, its path absolute, and Bun is Bun's API, as in Bun.file(path).text() or new Bun.Glob(pattern). What it returns is spread over the request's state; name each key it adds in the description, in backticks. Helpers go in the same file.`;
80
+
81
+ const reviewedOf = (reviewed: ReadonlyArray<Reviewed>) =>
82
+ reviewed.length === 0
83
+ ? "No code from the repository was judged: write short examples of your own."
84
+ : `The user judged this code from the repository. Base the rule's examples on what shows it best; you need not use all of it. Where the user overruled your first call, word the rule so the difference is clear.\n\n${reviewed
85
+ .map(
86
+ ({ where, code, violation, agreed }) =>
87
+ `${violation ? "Breaks the rule" : "Does not break the rule"}, ${where}${agreed ? "" : " (overruled)"}\n\`\`\`\n${code}\n\`\`\``,
88
+ )
89
+ .join("\n\n")}`;
90
+
91
+ export const writePrompt = (
92
+ idea: string,
93
+ id: string,
94
+ shape: Shape,
95
+ reviewed: ReadonlyArray<Reviewed>,
96
+ ) => `${skill}
97
+
98
+ ---
99
+
100
+ Using the reference above, write the rule file for this adhere rule.
101
+
102
+ Rule id: ${id}
103
+ What it enforces: ${idea}
104
+ What Jev reads beside the rule: ${contextOf(shape)}
105
+ ${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
106
+
107
+ ${reviewedOf(reviewed)}
108
+
109
+ Reply with only the file's contents, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
110
+
111
+ /** An agent's reply as the file it holds, without the fence it may wrap it in. */
112
+ export const unfenced = (text: string) =>
113
+ /^\s*```[a-z]*\n([\s\S]*?)\n```\s*$/.exec(text)?.[1] ?? text.trim();
114
+
115
+ export const asking = (agent: Agent, what: string, cwd: string, prompt: string, schema?: object) =>
116
+ spinning(
117
+ `Asking ${agent} ${what}`,
118
+ Effect.tryPromise({
119
+ try: () => askAgent(agent, prompt, cwd, schema),
120
+ catch: (cause) =>
121
+ new RuleNotCreated({ message: cause instanceof Error ? cause.message : String(cause) }),
122
+ }),
123
+ );
124
+
125
+ export const askingFor = <A>(
126
+ agent: Agent,
127
+ what: string,
128
+ cwd: string,
129
+ prompt: string,
130
+ Answer: Schema.Codec<A, A>,
131
+ ) =>
132
+ asking(
133
+ agent,
134
+ what,
135
+ cwd,
136
+ prompt,
137
+ Schema.toJsonSchemaDocument(Answer, { onExcessProperty: "error" }).schema,
138
+ ).pipe(
139
+ Effect.flatMap((text) =>
140
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Answer))(text).pipe(
141
+ Effect.mapError(
142
+ (problem) =>
143
+ new RuleNotCreated({ message: `${agent}'s answer did not decode: ${problem.message}` }),
144
+ ),
145
+ ),
146
+ ),
147
+ );
@@ -0,0 +1,140 @@
1
+ /**
2
+ * How `adhere rules create` looks: Clack's rail, drawn by effect/cli's own
3
+ * prompts through their theme, with the wizard's own lines hung on the same
4
+ * rail, and a spinner, as `gum spin` draws one, while an agent works.
5
+ */
6
+ import { highlightedLines } from "#workflows/format.ts";
7
+ import { Status, clearingStatus } from "#services/Status.ts";
8
+ import { Console, Duration, Effect, Fiber } from "effect";
9
+ import { Prompt } from "effect/cli";
10
+
11
+ /**
12
+ * The prompts' theme. Every row's text starts in the fourth column: after the
13
+ * prompt's symbol and two spaces, a select's pointer and its two-wide paging
14
+ * arrow, or the paging arrow and a space before a multi-select's checkbox.
15
+ */
16
+ export const look: Partial<Prompt.Theme> = {
17
+ prefix: "◆ ",
18
+ tick: "◇ ",
19
+ pointer: "●",
20
+ arrowUp: "↑ ",
21
+ arrowDown: "↓ ",
22
+ pointerSmall: "",
23
+ ellipsis: "",
24
+ descriptionSeparator: "\n ",
25
+ checkboxOn: "◼",
26
+ checkboxOff: "◻",
27
+ };
28
+
29
+ const columns = () => process.stderr.columns ?? 80;
30
+
31
+ /** `text` wrapped to the terminal, each further line indented under a choice's title as the first is. */
32
+ export const indented = (text: string) =>
33
+ Bun.wrapAnsi(text, Math.max(columns() - 4, 20)).replaceAll("\n", "\n ");
34
+
35
+ type Color = (theme: Prompt.Theme) => string;
36
+ const muted: Color = (theme) => theme.mutedColor;
37
+ const primary: Color = (theme) => theme.primaryColor;
38
+
39
+ const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
40
+ theme.colors && color !== undefined ? `${color(theme)}${text}\x1b[0m` : text;
41
+
42
+ /**
43
+ * Prints `text` beside `symbol`, wrapped to the terminal, its further lines
44
+ * beside `bar`, in color when the theme has colors.
45
+ */
46
+ const rail = (symbol: string, symbolColor: Color, text: string, textColor?: Color, bar = "│") =>
47
+ Effect.gen(function* () {
48
+ const theme = yield* Prompt.Theme;
49
+ const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() - 3, 20), {
50
+ hard: true,
51
+ trim: false,
52
+ }).split("\n");
53
+ yield* Console.error(
54
+ [
55
+ `${paint(theme, symbol, symbolColor)} ${paint(theme, first, textColor)}`,
56
+ ...rest.map((line) => `${paint(theme, bar, muted)} ${paint(theme, line, textColor)}`),
57
+ ].join("\n"),
58
+ );
59
+ });
60
+
61
+ export const intro = (text: string) => rail("┌", muted, text);
62
+ export const line = (text: string) => rail("│", muted, text);
63
+ export const note = (text: string) => rail("│", muted, text, muted);
64
+ export const warn = (text: string) => rail("▲", () => "\x1b[33m", text);
65
+ export const outro = (text: string) =>
66
+ rail("└", (theme) => theme.successColor, text, undefined, " ");
67
+
68
+ /** A choice the agent made, not the user: what it chose, and why, muted beneath. */
69
+ export const decided = (what: string, why: string) =>
70
+ rail("◇", (theme) => theme.successColor, what).pipe(Effect.andThen(note(why)));
71
+
72
+ /** What the agent says about something it shows the user, accented and in italics. */
73
+ export const said = (text: string) =>
74
+ rail("│", muted, text, (theme) => `${theme.primaryColor}\x1b[3m`);
75
+
76
+ /**
77
+ * Prints lines `from` to `to` of a file's `lines`, numbered from 1, on the
78
+ * rail: the excerpt's lines `start` to `end` marked and highlighted as a
79
+ * report highlights code, and the rest muted. A line too long for the
80
+ * terminal is cut.
81
+ */
82
+ export const excerpt = (
83
+ lines: ReadonlyArray<string>,
84
+ range: {
85
+ readonly from: number;
86
+ readonly to: number;
87
+ readonly start: number;
88
+ readonly end: number;
89
+ },
90
+ ) =>
91
+ Effect.gen(function* () {
92
+ const theme = yield* Prompt.Theme;
93
+ const width = String(range.to).length;
94
+ const room = Math.max(columns() - width - 8, 20);
95
+ const cut = lines.slice(range.from - 1, range.to).map((text) => {
96
+ const flat = text.replaceAll("\t", " ");
97
+ return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat;
98
+ });
99
+ // Highlighted together, so a comment or template opened above an excerpt line colors it.
100
+ const colored = highlightedLines(cut.join("\n"), theme.colors);
101
+ const rows = cut.map((text, index) => {
102
+ const number = String(range.from + index).padStart(width);
103
+ const inside = range.from + index >= range.start && range.from + index <= range.end;
104
+ const marker = inside ? paint(theme, "▌", primary) : " ";
105
+ const body = inside
106
+ ? `${paint(theme, number, muted)} ${colored[index] ?? text}`
107
+ : paint(theme, `${number} ${text}`, muted);
108
+ return `${paint(theme, "│", muted)} ${marker} ${body}`;
109
+ });
110
+ yield* Console.error(rows.join("\n"));
111
+ });
112
+
113
+ const frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
114
+
115
+ /**
116
+ * Runs `effect` beside a spinner and `text` on the status line, then leaves
117
+ * `text` on the rail with how long it took. Where the status line shows
118
+ * nothing, as off a terminal, only that last line is printed.
119
+ */
120
+ export const spinning = <A, E, R>(text: string, effect: Effect.Effect<A, E, R>) =>
121
+ Effect.gen(function* () {
122
+ const theme = yield* Prompt.Theme;
123
+ const status = yield* Status;
124
+ let frame = 0;
125
+ yield* line("");
126
+ // The status line redraws at most every 100 ms.
127
+ const spinner = yield* Effect.forkChild(
128
+ Effect.suspend(() =>
129
+ status.show(
130
+ `${paint(theme, frames[frame++ % frames.length] ?? "", primary)} ${paint(theme, `${text}…`, muted)}`,
131
+ ),
132
+ ).pipe(Effect.andThen(Effect.sleep("110 millis")), Effect.forever),
133
+ );
134
+ const [took, result] = yield* Effect.timed(effect).pipe(
135
+ Effect.ensuring(Fiber.interrupt(spinner)),
136
+ clearingStatus,
137
+ );
138
+ yield* note(`${text} · ${Math.max(1, Math.round(Duration.toSeconds(took)))}s`);
139
+ return result;
140
+ });